// RUTUBE API integration на Go (стандартный net/http, без фреймворков). // // Стек: Go 1.22+, database/sql + mattn/go-sqlite3. // // Конфигурация через env-переменные: // RUTUBE_TOKEN=d9fec6ee60f40d2d937b36ea9c4920bca2219150 // RUTUBE_CALLBACK_SECRET=random_secret_string_here // SITE_BASE_URL=https://woman.ru // PORT=8000 // // Сборка и запуск: // go mod init rutube-integration // go get github.com/mattn/go-sqlite3 // go build -o rutube-server // ./rutube-server package main import ( "database/sql" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "strings" _ "github.com/mattn/go-sqlite3" ) const rutubeAPI = "https://rutube.ru/api" var ( rutubeToken = os.Getenv("RUTUBE_TOKEN") rutubeCallbackSecret = os.Getenv("RUTUBE_CALLBACK_SECRET") siteBaseURL = getEnv("SITE_BASE_URL", "https://woman.ru") db *sql.DB ) func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // ---- БД ---- func initDB() { var err error db, err = sql.Open("sqlite3", "rutube_videos.db") if err != nil { log.Fatal(err) } _, err = db.Exec(` CREATE TABLE IF NOT EXISTS rutube_videos ( article_id TEXT PRIMARY KEY, video_id TEXT NOT NULL, title TEXT, status TEXT DEFAULT 'pending', embed_url TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `) if err != nil { log.Fatal(err) } } // ---- Шаг 1: загрузка видео в RUTUBE ---- // uploadToRutube постит видео в RUTUBE API и возвращает временный video_id. // Финальный video_id придёт позже в callback. // // videoURL - HTTPS-ссылка на видеофайл на вашем CDN // articleID - ваш внутренний ID статьи (вернётся в callback.session.extra) // title - название ролика (до 100 символов) func uploadToRutube(videoURL, articleID, title string) (string, error) { callbackURL := siteBaseURL + "/rutube/callback" if rutubeCallbackSecret != "" { callbackURL += "?secret=" + url.QueryEscape(rutubeCallbackSecret) } extra, _ := json.Marshal(map[string]string{"article_id": articleID}) if len(title) > 100 { title = title[:100] } form := url.Values{ "url": {videoURL}, "callback_url": {callbackURL}, "errback_url": {siteBaseURL + "/rutube/error"}, "title": {title}, "category_id": {"13"}, "extra": {string(extra)}, } req, _ := http.NewRequest("POST", rutubeAPI+"/video/", strings.NewReader(form.Encode())) req.Header.Set("Authorization", "Token "+rutubeToken) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return "", fmt.Errorf("RUTUBE upload failed: %d %s", resp.StatusCode, body) } var data struct { VideoID string `json:"video_id"` } if err := json.Unmarshal(body, &data); err != nil { return "", fmt.Errorf("parse response: %w", err) } if data.VideoID == "" { return "", fmt.Errorf("no video_id in RUTUBE response: %s", body) } // Сохраняем pending-запись _, err = db.Exec(` INSERT INTO rutube_videos (article_id, video_id, title, status) VALUES (?, ?, ?, 'pending') ON CONFLICT(article_id) DO UPDATE SET video_id=excluded.video_id, status='pending' `, articleID, data.VideoID, title) if err != nil { return "", err } log.Printf("[RUTUBE] Upload started: article_id=%s video_id=%s", articleID, data.VideoID) return data.VideoID, nil } // ---- Шаг 3: callback от RUTUBE после конвертации ---- type callbackPayload struct { ID string `json:"id"` Title string `json:"title"` EmbedURL string `json:"embed_url"` Session struct { Extra struct { ArticleID string `json:"article_id"` } `json:"extra"` } `json:"session"` } func rutubeCallback(w http.ResponseWriter, r *http.Request) { // Защита secret if rutubeCallbackSecret != "" && r.URL.Query().Get("secret") != rutubeCallbackSecret { http.Error(w, `{"error":"Invalid secret"}`, http.StatusForbidden) return } var p callbackPayload if err := json.NewDecoder(r.Body).Decode(&p); err != nil { http.Error(w, `{"error":"Bad JSON"}`, http.StatusBadRequest) return } if p.ID == "" || p.Session.Extra.ArticleID == "" { http.Error(w, `{"error":"Missing id or extra.article_id"}`, http.StatusBadRequest) return } // Проверяем что мы помним эту связку var known string err := db.QueryRow(`SELECT video_id FROM rutube_videos WHERE article_id = ?`, p.Session.Extra.ArticleID).Scan(&known) if err == sql.ErrNoRows { log.Printf("[RUTUBE] Callback for unknown article_id=%s", p.Session.Extra.ArticleID) http.Error(w, `{"error":"article_id not found"}`, http.StatusNotFound) return } if err != nil { http.Error(w, `{"error":"DB error"}`, http.StatusInternalServerError) return } _, err = db.Exec(` UPDATE rutube_videos SET video_id = ?, status = 'ready', embed_url = ? WHERE article_id = ? `, p.ID, p.EmbedURL, p.Session.Extra.ArticleID) if err != nil { http.Error(w, `{"error":"DB error"}`, http.StatusInternalServerError) return } log.Printf("[RUTUBE] Ready: article_id=%s video_id=%s", p.Session.Extra.ArticleID, p.ID) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"ok":true}`)) } func rutubeError(w http.ResponseWriter, r *http.Request) { var p callbackPayload _ = json.NewDecoder(r.Body).Decode(&p) if p.Session.Extra.ArticleID != "" { _, _ = db.Exec(`UPDATE rutube_videos SET status = 'error' WHERE article_id = ?`, p.Session.Extra.ArticleID) } log.Printf("[RUTUBE] Error for article_id=%s", p.Session.Extra.ArticleID) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"ok":true}`)) } // ---- Шаг 4: получить video_id для рендера статьи ---- func getArticleVideo(w http.ResponseWriter, r *http.Request) { // /api/article/{article_id}/video parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/article/"), "/") if len(parts) < 2 || parts[1] != "video" { http.NotFound(w, r) return } articleID := parts[0] var videoID, embedURL string err := db.QueryRow(` SELECT video_id, embed_url FROM rutube_videos WHERE article_id = ? AND status = 'ready' `, articleID).Scan(&videoID, &embedURL) w.Header().Set("Content-Type", "application/json") if err == sql.ErrNoRows { w.Write([]byte(`{"video_id":null,"status":"not_ready"}`)) return } if err != nil { http.Error(w, `{"error":"DB error"}`, http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]any{ "video_id": videoID, "embed_url": embedURL, "status": "ready", }) } // ---- Пример endpoint'а для CMS-админки ---- func cmsAttachVideo(w http.ResponseWriter, r *http.Request) { // /cms/article/{article_id}/attach-video parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/cms/article/"), "/") if len(parts) < 2 || parts[1] != "attach-video" { http.NotFound(w, r) return } articleID := parts[0] var body struct { VideoURL string `json:"video_url"` Title string `json:"title"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, `{"error":"Bad JSON"}`, http.StatusBadRequest) return } videoID, err := uploadToRutube(body.VideoURL, articleID, body.Title) if err != nil { http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "video_id": videoID, "status": "uploading", }) } // ---- main ---- func main() { if rutubeToken == "" { log.Fatal("RUTUBE_TOKEN env var is required") } initDB() defer db.Close() http.HandleFunc("/rutube/callback", rutubeCallback) http.HandleFunc("/rutube/error", rutubeError) http.HandleFunc("/api/article/", getArticleVideo) http.HandleFunc("/cms/article/", cmsAttachVideo) port := getEnv("PORT", "8000") log.Printf("Listening on :%s", port) log.Fatal(http.ListenAndServe(":"+port, nil)) } /* --- В шаблоне Go (например html/template) --- {{if .Video.Ready}}
{{end}} Плюс один раз в layout.html: */