From 4dd9015bd7c99c80ce91a9ab28d0b8bd24fe6acc Mon Sep 17 00:00:00 2001 From: nianzhibai Date: Sat, 13 Jun 2026 09:41:08 +0800 Subject: [PATCH] feat: add per-storage manual transcode for browser-incompatible videos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a transcode control to each storage in the admin drives page, modeled after the cover/preview generation controls: - Manual start/stop button per storage; transcoding is off by default and never runs automatically (not triggered by scans or the nightly pipeline) - New transcode worker probes candidates (non mp4/webm extensions) with ffprobe: already-compatible files are marked skipped; AVI with H.264 is remuxed losslessly; incompatible codecs (MPEG-4 Part 2, WMV, RMVB, HEVC...) are transcoded to H.264/AAC MP4 with +faststart - Transcoded output is uploaded back to the same storage under a "91转码" directory which is auto-added to the drive's scan skip list so the scanner never re-imports the artifacts - Playback source automatically prefers the transcoded file once ready, keeping the 302 direct-link mode for cloud drives - videos table gains transcode_status/error/file_id/size columns via startup migration; counts and live task status surface in the admin drives API and generation panel UI - Stop semantics: per-drive stop button, drive-level "stop all tasks" and global stop all include the transcode task; interrupted videos keep their candidate status and resume on next start Co-Authored-By: Claude Fable 5 --- backend/cmd/server/main.go | 158 +++++++++- backend/internal/api/admin.go | 59 +++- backend/internal/api/api.go | 15 + backend/internal/catalog/catalog.go | 86 ++++++ backend/internal/catalog/schema.sql | 4 + backend/internal/catalog/tags.go | 15 + backend/internal/transcode/transcode.go | 178 +++++++++++ backend/internal/transcode/transcode_test.go | 125 ++++++++ backend/internal/transcode/worker.go | 308 +++++++++++++++++++ src/admin/DrivesPage.tsx | 40 +++ src/admin/api.ts | 26 ++ src/admin/drive/DriveComponents.tsx | 48 ++- 12 files changed, 1056 insertions(+), 6 deletions(-) create mode 100644 backend/internal/transcode/transcode.go create mode 100644 backend/internal/transcode/transcode_test.go create mode 100644 backend/internal/transcode/worker.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8504859..8aaa9c0 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -43,6 +43,7 @@ import ( "github.com/video-site/backend/internal/proxy" "github.com/video-site/backend/internal/scanner" "github.com/video-site/backend/internal/spider91migrate" + "github.com/video-site/backend/internal/transcode" ) const fingerprintReconcileInterval = time.Minute @@ -218,6 +219,12 @@ func main() { OnRegenFailedFingerprints: func(driveID string) { go app.regenFailedFingerprints(ctx, driveID) }, + OnStartDriveTranscode: func(driveID string) (bool, string) { + return app.startDriveTranscode(ctx, driveID) + }, + OnStopDriveTranscode: func(driveID string) bool { + return app.stopDriveTranscode(driveID) + }, OnDeleteVideo: func(reqCtx context.Context, videoID string, deleteSource bool) (api.DeleteVideoResult, error) { return app.deleteVideo(reqCtx, videoID, deleteSource) }, @@ -368,6 +375,13 @@ type App struct { // uploadProgress 跟踪脚本爬虫迁移到云盘时的实时上传状态。 uploadProgressMu sync.Mutex uploadProgress map[string]driveUploadProgress + + // transcodeMu 保护 transcodeWorkers / transcodeCancels。 + // 浏览器兼容性转码每盘最多一个任务,且只能由管理员手动开启 + // (不随扫盘/夜间流水线自动运行),手动停止或处理完即从 map 清除。 + transcodeMu sync.Mutex + transcodeWorkers map[string]*transcode.Worker + transcodeCancels map[string]context.CancelFunc } type driveScanProgress struct { @@ -557,7 +571,14 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses { } a.mu.Unlock() - out := make(map[string]api.DriveGenerationStatuses, len(scanningDrives)+len(previewWorkers)+len(thumbWorkers)+len(fingerprintWorkers)+len(uploadProgresses)) + a.transcodeMu.Lock() + transcodeWorkers := make(map[string]*transcode.Worker, len(a.transcodeWorkers)) + for id, worker := range a.transcodeWorkers { + transcodeWorkers[id] = worker + } + a.transcodeMu.Unlock() + + out := make(map[string]api.DriveGenerationStatuses, len(scanningDrives)+len(previewWorkers)+len(thumbWorkers)+len(fingerprintWorkers)+len(uploadProgresses)+len(transcodeWorkers)) for id, running := range scanningDrives { if !running { continue @@ -601,6 +622,11 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses { } out[id] = status } + for id, worker := range transcodeWorkers { + status := out[id] + status.Transcode = generationStatusFromTranscode(worker.Status()) + out[id] = status + } return out } @@ -687,6 +713,126 @@ func generationStatusFromFingerprint(status fingerprint.TaskStatus) api.Generati return out } +func generationStatusFromTranscode(status transcode.TaskStatus) api.GenerationStatus { + state := status.State + if state == "" { + state = "idle" + } + return api.GenerationStatus{ + State: state, + CurrentTitle: status.CurrentTitle, + QueueLength: status.QueueLength, + DoneCount: status.DoneCount, + TotalCount: status.TotalCount, + } +} + +// transcodeWorkDir 返回转码用的本地临时目录(下载原片 / 写产物),与 +// localUploadDir 一样挂在数据目录下,避免 /tmp 空间不足。 +func (a *App) transcodeWorkDir() string { + return filepath.Join(filepath.Dir(a.cfg.Storage.LocalPreviewDir), "transcode-tmp") +} + +// startDriveTranscode 手动开启某盘的浏览器兼容性转码。 +// 转码从不自动运行:扫盘、夜间流水线都不会触发,这里是唯一入口。 +// 任务跑完候选列表后自然结束;中途可用 stopDriveTranscode / 停止所有任务中断。 +func (a *App) startDriveTranscode(ctx context.Context, driveID string) (bool, string) { + driveID = strings.TrimSpace(driveID) + if driveID == "" { + return false, "缺少存储 ID" + } + drv, ok := a.registry.Get(driveID) + if !ok { + return false, "存储未挂载或不可用" + } + switch drv.Kind() { + case spider91.Kind, scriptcrawler.Kind: + return false, "爬虫存储不支持转码" + } + workDir := a.transcodeWorkDir() + if err := os.MkdirAll(workDir, 0o755); err != nil { + return false, "创建转码临时目录失败: " + err.Error() + } + + a.transcodeMu.Lock() + if a.transcodeWorkers == nil { + a.transcodeWorkers = make(map[string]*transcode.Worker) + a.transcodeCancels = make(map[string]context.CancelFunc) + } + if existing := a.transcodeWorkers[driveID]; existing != nil { + a.transcodeMu.Unlock() + return false, "该存储的转码任务已在运行" + } + worker := transcode.NewWorker(transcode.Config{ + FFmpegPath: a.cfg.Preview.FFmpegPath, + FFprobePath: a.cfg.Preview.FFprobePath, + WorkDir: workDir, + }, a.cat, drv) + taskCtx, done := a.registerDriveTaskContext(ctx, driveID) + runCtx, cancel := context.WithCancel(taskCtx) + a.transcodeWorkers[driveID] = worker + a.transcodeCancels[driveID] = cancel + a.transcodeMu.Unlock() + + go func() { + defer func() { + cancel() + done() + a.transcodeMu.Lock() + if a.transcodeWorkers[driveID] == worker { + delete(a.transcodeWorkers, driveID) + delete(a.transcodeCancels, driveID) + } + a.transcodeMu.Unlock() + }() + candidates, err := a.cat.ListTranscodeCandidates(runCtx, driveID, 0) + if err != nil { + log.Printf("[transcode] list candidates drive=%s: %v", driveID, err) + return + } + if len(candidates) == 0 { + log.Printf("[transcode] drive=%s no candidates", driveID) + return + } + log.Printf("[transcode] drive=%s start, %d candidates", driveID, len(candidates)) + worker.Run(runCtx, candidates) + }() + return true, "" +} + +// stopAllDriveTranscodes 停掉所有盘的转码任务,返回被停的 driveID 列表。 +func (a *App) stopAllDriveTranscodes() []string { + a.transcodeMu.Lock() + cancels := a.transcodeCancels + a.transcodeCancels = nil + a.transcodeWorkers = nil + a.transcodeMu.Unlock() + ids := make([]string, 0, len(cancels)) + for id, cancel := range cancels { + if cancel != nil { + cancel() + } + ids = append(ids, id) + } + return ids +} + +// stopDriveTranscode 手动停止某盘的转码任务。返回是否有任务被停。 +func (a *App) stopDriveTranscode(driveID string) bool { + driveID = strings.TrimSpace(driveID) + a.transcodeMu.Lock() + cancel := a.transcodeCancels[driveID] + delete(a.transcodeCancels, driveID) + delete(a.transcodeWorkers, driveID) + a.transcodeMu.Unlock() + if cancel == nil { + return false + } + cancel() + log.Printf("[transcode] stop drive=%s", driveID) + return true +} + func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error { a.driveAttachMu.Lock() defer a.driveAttachMu.Unlock() @@ -1435,10 +1581,11 @@ func (a *App) stopDriveTasks(ctx context.Context, driveID string) bool { queued := a.clearQueuedDriveTask(driveID) fingerprintQueued := a.clearFingerprintQueueing(driveID) uploading := a.clearCrawlerUploadProgress(driveID) + transcoding := a.stopDriveTranscode(driveID) hadWorkers := a.resetDriveGenerationWorkers(ctx, driveID) - stopped := canceled > 0 || queued || fingerprintQueued || uploading || hadWorkers - log.Printf("[tasks] stop drive=%s stopped=%v canceled_tasks=%d queued=%v fingerprint_queue=%v uploading=%v workers=%v", - driveID, stopped, canceled, queued, fingerprintQueued, uploading, hadWorkers) + stopped := canceled > 0 || queued || fingerprintQueued || uploading || transcoding || hadWorkers + log.Printf("[tasks] stop drive=%s stopped=%v canceled_tasks=%d queued=%v fingerprint_queue=%v uploading=%v transcoding=%v workers=%v", + driveID, stopped, canceled, queued, fingerprintQueued, uploading, transcoding, hadWorkers) return stopped } @@ -1459,6 +1606,9 @@ func (a *App) stopAllDriveTasks(ctx context.Context) int { for _, id := range a.clearAllCrawlerUploadProgress() { stoppedIDs[id] = struct{}{} } + for _, id := range a.stopAllDriveTranscodes() { + stoppedIDs[id] = struct{}{} + } for _, id := range a.resetAllDriveGenerationWorkers(ctx) { stoppedIDs[id] = struct{}{} } diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index 7731195..ba56b16 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -59,7 +59,13 @@ type AdminServer struct { OnRegenFailedPreviews func(driveID string) OnRegenFailedThumbnails func(driveID string) OnRegenFailedFingerprints func(driveID string) - OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) + // OnStartDriveTranscode 手动开启某盘的浏览器兼容性转码任务。 + // 返回 (是否接受, 拒绝原因)。转码从不自动运行,只能在这里手动触发; + // 处理完候选列表后任务自然结束。 + OnStartDriveTranscode func(driveID string) (bool, string) + // OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。 + OnStopDriveTranscode func(driveID string) bool + OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses // OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。 // enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开); @@ -118,6 +124,7 @@ type DriveGenerationStatuses struct { Preview GenerationStatus `json:"preview"` Fingerprint GenerationStatus `json:"fingerprint"` Upload GenerationStatus `json:"upload"` + Transcode GenerationStatus `json:"transcode"` } type NightlyJobStatus struct { @@ -169,6 +176,8 @@ func (a *AdminServer) Register(r chi.Router) { r.Post("/drives/{id}/previews/failed/regenerate", a.handleRegenFailedPreviews) r.Post("/drives/{id}/thumbnails/failed/regenerate", a.handleRegenFailedThumbnails) r.Post("/drives/{id}/fingerprints/failed/regenerate", a.handleRegenFailedFingerprints) + r.Post("/drives/{id}/transcode/start", a.handleStartDriveTranscode) + r.Post("/drives/{id}/transcode/stop", a.handleStopDriveTranscode) // 爬虫 r.Get("/crawlers", a.handleListCrawlers) @@ -431,6 +440,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, err) return } + transcodeCounts, err := a.Catalog.CountTranscodesByDrive(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } generationStatuses := map[string]DriveGenerationStatuses{} if a.GetDriveGenerationStatuses != nil { generationStatuses = a.GetDriveGenerationStatuses() @@ -470,6 +484,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { FingerprintReadyCount int `json:"fingerprintReadyCount"` FingerprintPendingCount int `json:"fingerprintPendingCount"` FingerprintFailedCount int `json:"fingerprintFailedCount"` + TranscodeGenerationStatus GenerationStatus `json:"transcodeGenerationStatus"` + TranscodePendingCount int `json:"transcodePendingCount"` + TranscodeReadyCount int `json:"transcodeReadyCount"` + TranscodeFailedCount int `json:"transcodeFailedCount"` + TranscodeSkippedCount int `json:"transcodeSkippedCount"` } list := make([]out, 0, len(drives)) for _, d := range drives { @@ -479,6 +498,7 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { counts := teaserCounts[d.ID] thumbCounts := thumbnailCounts[d.ID] fingerprintCount := fingerprintCounts[d.ID] + transcodeCount := transcodeCounts[d.ID] generation := generationStatuses[d.ID] if generation.Scan.State == "" { generation.Scan.State = "idle" @@ -492,6 +512,9 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { if generation.Fingerprint.State == "" { generation.Fingerprint.State = "idle" } + if generation.Transcode.State == "" { + generation.Transcode.State = "idle" + } // spider91 没有用户凭证概念;只要存在 drive 行就视为"已配置"。 // last_crawl_at 是后端自动写入的运行状态字段,不计入 hasCredential 判定。 hasCred := false @@ -537,6 +560,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { FingerprintReadyCount: fingerprintCount.Ready, FingerprintPendingCount: fingerprintCount.Pending, FingerprintFailedCount: fingerprintCount.Failed, + TranscodeGenerationStatus: generation.Transcode, + TranscodePendingCount: transcodeCount.Pending, + TranscodeReadyCount: transcodeCount.Ready, + TranscodeFailedCount: transcodeCount.Failed, + TranscodeSkippedCount: transcodeCount.Skipped, }) } writeJSON(w, http.StatusOK, list) @@ -1547,6 +1575,35 @@ func (a *AdminServer) handleStopDriveTasks(w http.ResponseWriter, r *http.Reques }) } +// handleStartDriveTranscode 手动开启某盘的浏览器兼容性转码。 +// 转码默认不开启、从不自动运行;本接口是唯一入口。 +func (a *AdminServer) handleStartDriveTranscode(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if a.OnStartDriveTranscode == nil { + writeErr(w, http.StatusNotImplemented, errors.New("transcode not supported")) + return + } + accepted, message := a.OnStartDriveTranscode(id) + writeJSON(w, http.StatusAccepted, map[string]any{ + "ok": true, + "accepted": accepted, + "message": message, + }) +} + +// handleStopDriveTranscode 手动停止某盘正在进行的转码任务。 +func (a *AdminServer) handleStopDriveTranscode(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + stopped := false + if a.OnStopDriveTranscode != nil { + stopped = a.OnStopDriveTranscode(id) + } + writeJSON(w, http.StatusAccepted, map[string]any{ + "ok": true, + "stopped": stopped, + }) +} + func (a *AdminServer) p123QRClient() *p123.QRClient { return p123.NewQRClient(p123.QRConfig{ UserAPIBaseURL: a.P123UserAPIBaseURL, diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index a057756..62d4bbf 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -970,6 +970,15 @@ func thumbnailURL(v *catalog.Video) string { return base + "?v=" + strconv.FormatInt(v.UpdatedAt.UnixMilli(), 10) } +// transcodedSource 在视频有就绪的浏览器兼容性转码产物时返回产物的播放地址。 +// 产物和原始文件在同一个 drive 上,走同一条 /p/stream 代理/302 链路。 +func transcodedSource(v *catalog.Video) (string, bool) { + if v.TranscodeStatus == "ready" && v.TranscodedFileID != "" && v.DriveID != localUploadDriveID { + return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.TranscodedFileID)), true + } + return "", false +} + func (s *Server) videoSource(v *catalog.Video) string { if v.DriveID == localUploadDriveID { return "/p/upload/" + pathSegment(v.ID) @@ -982,6 +991,9 @@ func (s *Server) videoSource(v *catalog.Video) string { } } } + if src, ok := transcodedSource(v); ok { + return src + } return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID)) } @@ -991,6 +1003,9 @@ func videoSource(v *catalog.Video) string { if v.DriveID == localUploadDriveID { return "/p/upload/" + pathSegment(v.ID) } + if src, ok := transcodedSource(v); ok { + return src + } return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID)) } diff --git a/backend/internal/catalog/catalog.go b/backend/internal/catalog/catalog.go index f0f1786..e924f28 100644 --- a/backend/internal/catalog/catalog.go +++ b/backend/internal/catalog/catalog.go @@ -71,6 +71,12 @@ type Video struct { PreviewFileID string `json:"previewFileId"` PreviewLocal string `json:"previewLocal"` PreviewStatus string `json:"previewStatus"` + // TranscodeStatus:浏览器兼容性转码状态。 + // ''=未检测 / pending=已入队 / ready=已转码 / skipped=无需转码 / failed=失败。 + TranscodeStatus string `json:"transcodeStatus"` + TranscodeError string `json:"transcodeError"` + TranscodedFileID string `json:"transcodedFileId"` + TranscodedSize int64 `json:"transcodedSize"` Views int `json:"views"` Favorites int `json:"favorites"` Comments int `json:"comments"` @@ -190,6 +196,84 @@ func (c *Catalog) UpdatePreview(ctx context.Context, id, previewLocal, status st return err } +// transcodeCandidateWhereSQL 圈定"可能需要浏览器兼容性转码"的视频: +// mp4/webm/m4v 默认浏览器可播不进候选;strm 是远程引用没有本体。 +// 其余扩展名都先入候选,由转码 worker probe 实际编码后决定转码还是跳过 +// (skipped)。failed 也保留在候选里,重新点开始转码时会自动重试。 +const transcodeCandidateWhereSQL = `COALESCE(ext, '') NOT IN ('mp4', 'webm', 'm4v', 'strm') + AND COALESCE(transcode_status, '') IN ('', 'pending', 'failed')` + +// ListTranscodeCandidates 列出某盘所有转码候选视频。limit<=0 表示不限制。 +func (c *Catalog) ListTranscodeCandidates(ctx context.Context, driveID string, limit int) ([]*Video, error) { + query := `SELECT ` + allVideoCols + ` FROM videos + WHERE drive_id = ? AND ` + transcodeCandidateWhereSQL + ` + ORDER BY created_at ASC, id ASC` + args := []any{driveID} + if limit > 0 { + query += ` LIMIT ?` + args = append(args, limit) + } + rows, err := c.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Video + for rows.Next() { + v, err := scanVideo(rows) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// UpdateVideoTranscode 写回单条视频的转码结果。 +// status=ready 时 transcodedFileID/transcodedSize 指向转码产物; +// 其它 status 调用方应传空值,本函数会按传入值原样覆盖。 +func (c *Catalog) UpdateVideoTranscode(ctx context.Context, id, status, errMsg, transcodedFileID string, transcodedSize int64) error { + _, err := c.db.ExecContext(ctx, + `UPDATE videos SET transcode_status = ?, transcode_error = ?, transcoded_file_id = ?, transcoded_size = ?, updated_at = ? WHERE id = ?`, + status, errMsg, transcodedFileID, transcodedSize, time.Now().UnixMilli(), id) + return err +} + +// DriveTranscodeCounts 是单盘的转码进度统计。 +type DriveTranscodeCounts struct { + // Pending 是仍在候选集合里、还没有出结果的数量(含从未检测过的)。 + Pending int + Ready int + Failed int + Skipped int +} + +func (c *Catalog) CountTranscodesByDrive(ctx context.Context) (map[string]DriveTranscodeCounts, error) { + rows, err := c.db.QueryContext(ctx, ` + SELECT drive_id, + COUNT(CASE WHEN COALESCE(ext, '') NOT IN ('mp4', 'webm', 'm4v', 'strm') + AND COALESCE(transcode_status, '') IN ('', 'pending') THEN 1 END) AS pending_count, + COUNT(CASE WHEN COALESCE(transcode_status, '') = 'ready' THEN 1 END) AS ready_count, + COUNT(CASE WHEN COALESCE(transcode_status, '') = 'failed' THEN 1 END) AS failed_count, + COUNT(CASE WHEN COALESCE(transcode_status, '') = 'skipped' THEN 1 END) AS skipped_count + FROM videos + GROUP BY drive_id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[string]DriveTranscodeCounts) + for rows.Next() { + var driveID string + var counts DriveTranscodeCounts + if err := rows.Scan(&driveID, &counts.Pending, &counts.Ready, &counts.Failed, &counts.Skipped); err != nil { + return nil, err + } + out[driveID] = counts + } + return out, rows.Err() +} + func (c *Catalog) HideVideo(ctx context.Context, id string) error { res, err := c.db.ExecContext(ctx, `UPDATE videos SET hidden = 1, updated_at = ? WHERE id = ?`, @@ -2165,6 +2249,7 @@ COALESCE(sampled_sha256, ''), COALESCE(fingerprint_status, 'pending'), COALESCE( COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'), duration_seconds, size_bytes, COALESCE(ext, ''), COALESCE(quality, ''), COALESCE(thumbnail_url, ''), COALESCE(preview_file_id, ''), COALESCE(preview_local, ''), COALESCE(preview_status, 'pending'), +COALESCE(transcode_status, ''), COALESCE(transcode_error, ''), COALESCE(transcoded_file_id, ''), COALESCE(transcoded_size, 0), views, favorites, comments, likes, dislikes, COALESCE(category, ''), COALESCE(hidden, 0), COALESCE(badges, '[]'), COALESCE(description, ''), published_at, created_at, updated_at @@ -2236,6 +2321,7 @@ func scanVideo(row rowScanner) (*Video, error) { &v.ParentID, &v.Title, &v.Author, &tagsJSON, &v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL, &v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus, + &v.TranscodeStatus, &v.TranscodeError, &v.TranscodedFileID, &v.TranscodedSize, &v.Views, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes, &v.Category, &hidden, &badgesJSON, &v.Description, &publishedAt, &createdAt, &updatedAt, diff --git a/backend/internal/catalog/schema.sql b/backend/internal/catalog/schema.sql index d85c35f..bc4ce69 100644 --- a/backend/internal/catalog/schema.sql +++ b/backend/internal/catalog/schema.sql @@ -22,6 +22,10 @@ CREATE TABLE IF NOT EXISTS videos ( preview_file_id TEXT, -- deprecated: 旧版回写网盘后的预览视频 file id preview_local TEXT, -- 本地预览视频路径(兜底) preview_status TEXT DEFAULT 'pending', -- pending / ready / failed + transcode_status TEXT DEFAULT '', -- '' / pending / ready / skipped / failed(浏览器兼容性转码) + transcode_error TEXT DEFAULT '', + transcoded_file_id TEXT DEFAULT '', -- 转码产物在同一 drive 上的 fileID,播放源优先用它 + transcoded_size INTEGER DEFAULT 0, views INTEGER DEFAULT 0, favorites INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, diff --git a/backend/internal/catalog/tags.go b/backend/internal/catalog/tags.go index 2717c12..534861d 100644 --- a/backend/internal/catalog/tags.go +++ b/backend/internal/catalog/tags.go @@ -66,6 +66,21 @@ func (c *Catalog) migrate(ctx context.Context) error { if err := c.addColumnIfMissing(ctx, "videos", "thumbnail_failures", "INTEGER DEFAULT 0"); err != nil { return err } + // videos.transcode_*:浏览器兼容性转码状态。 + // status:''=未检测 / pending=已入队 / ready=已转码 / skipped=检测后无需转码 / failed=失败。 + // transcoded_file_id 指向转码产物在同一 drive 上的 fileID,播放源优先使用它。 + if err := c.addColumnIfMissing(ctx, "videos", "transcode_status", "TEXT DEFAULT ''"); err != nil { + return err + } + if err := c.addColumnIfMissing(ctx, "videos", "transcode_error", "TEXT DEFAULT ''"); err != nil { + return err + } + if err := c.addColumnIfMissing(ctx, "videos", "transcoded_file_id", "TEXT DEFAULT ''"); err != nil { + return err + } + if err := c.addColumnIfMissing(ctx, "videos", "transcoded_size", "INTEGER DEFAULT 0"); err != nil { + return err + } // drives.teaser_enabled:每盘预览视频开关,替代旧的全局 preview.enabled。 // 升级路径:直接让 ALTER TABLE 的 DEFAULT 1 兜底 —— 每个现存 drive 都默认开启, // 不读旧的 settings.preview.enabled 字段。这样老用户即便之前关过全局开关, diff --git a/backend/internal/transcode/transcode.go b/backend/internal/transcode/transcode.go new file mode 100644 index 0000000..346f77d --- /dev/null +++ b/backend/internal/transcode/transcode.go @@ -0,0 +1,178 @@ +// Package transcode 实现"浏览器兼容性转码":把网盘/本地存储中浏览器 +//