// backend\meta.go package main import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" ) // -------------------------- // generated/meta//meta.json // -------------------------- type previewClip struct { StartSeconds float64 `json:"startSeconds"` DurationSeconds float64 `json:"durationSeconds"` } type previewSpriteMeta struct { Path string `json:"path"` // z.B. /api/preview-sprite/ Count int `json:"count"` // Anzahl Frames im Sprite Cols int `json:"cols"` // Spalten im Tile Rows int `json:"rows"` // Zeilen im Tile StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames CellWidth int `json:"cellWidth,omitempty"` CellHeight int `json:"cellHeight,omitempty"` } type videoFileMeta struct { Size int64 `json:"size"` ModUnix int64 `json:"modUnix"` SourceURL string `json:"sourceUrl,omitempty"` } type videoStreamMeta struct { Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` FPS float64 `json:"fps,omitempty"` Resolution string `json:"resolution,omitempty"` } type videoMediaMeta struct { DurationSeconds float64 `json:"durationSeconds"` Video videoStreamMeta `json:"video"` } type previewThumbMeta struct { Path string `json:"path,omitempty"` Exists bool `json:"exists,omitempty"` } type previewMeta struct { Clips []previewClip `json:"clips,omitempty"` Thumb *previewThumbMeta `json:"thumb,omitempty"` Sprite *previewSpriteMeta `json:"sprite,omitempty"` } type analysisMeta struct { Highlights *aiAnalysisMeta `json:"highlights,omitempty"` } type postworkHistoryEntry struct { Queue string `json:"queue"` Phase string `json:"phase"` State string `json:"state"` Label string `json:"label,omitempty"` Position int `json:"position,omitempty"` Waiting int `json:"waiting,omitempty"` Running int `json:"running,omitempty"` MaxParallel int `json:"maxParallel,omitempty"` TS int64 `json:"ts"` } type postworkCompletedMeta struct { Meta bool `json:"meta,omitempty"` Teaser bool `json:"teaser,omitempty"` Thumb bool `json:"thumb,omitempty"` Sprites bool `json:"sprites,omitempty"` Analyze bool `json:"analyze,omitempty"` } type postworkMeta struct { Completed postworkCompletedMeta `json:"completed,omitempty"` History []postworkHistoryEntry `json:"history,omitempty"` } type videoMeta struct { Version int `json:"version"` UpdatedAtUnix int64 `json:"updatedAtUnix"` File videoFileMeta `json:"file"` Media videoMediaMeta `json:"media"` Preview previewMeta `json:"preview,omitempty"` Analysis analysisMeta `json:"analysis,omitempty"` Postwork *postworkMeta `json:"postwork,omitempty"` Validation map[string]any `json:"validation,omitempty"` } type aiSegmentMeta struct { Label string `json:"label"` StartSeconds float64 `json:"startSeconds"` EndSeconds float64 `json:"endSeconds"` DurationSeconds float64 `json:"durationSeconds"` Score float64 `json:"score,omitempty"` RatingIntensity float64 `json:"ratingIntensity,omitempty"` AutoSelected bool `json:"autoSelected,omitempty"` Position string `json:"position,omitempty"` Tags []string `json:"tags,omitempty"` MarkerSeconds float64 `json:"markerSeconds,omitempty"` PreviewSeconds float64 `json:"previewSeconds,omitempty"` } type aiAnalysisMeta struct { Goal string `json:"goal,omitempty"` Mode string `json:"mode,omitempty"` Completed bool `json:"completed,omitempty"` Hits []analyzeHit `json:"hits,omitempty"` Segments []aiSegmentMeta `json:"segments,omitempty"` Rating *aiRatingMeta `json:"rating,omitempty"` AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"` } func isZeroPostworkCompletedMeta(v postworkCompletedMeta) bool { return !v.Meta && !v.Teaser && !v.Thumb && !v.Sprites && !v.Analyze } func isZeroPostworkMeta(v *postworkMeta) bool { if v == nil { return true } return isZeroPostworkCompletedMeta(v.Completed) && len(v.History) == 0 } func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) { m, ok := readVideoMetaIfValid(metaPath, fi) if !ok || m == nil { return 0, 0, 0, 0, false } return m.Media.DurationSeconds, m.Media.Video.Width, m.Media.Video.Height, m.Media.Video.FPS, true } func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) { m, ok := readVideoMetaIfValid(metaPath, fi) if !ok || m == nil || m.Media.DurationSeconds <= 0 { return 0, false } return m.Media.DurationSeconds, true } func metaJSONPathForAssetID(assetID string) (string, error) { root, err := generatedMetaRoot() if err != nil { return "", err } if strings.TrimSpace(root) == "" { return "", appErrorf("generated/meta root leer") } return filepath.Join(root, assetID, "meta.json"), nil } func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo, sourceURL string) (*videoMeta, bool) { stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return nil, false } var err error assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { return nil, false } metaPath, err := metaJSONPathForAssetID(assetID) if err != nil { return nil, false } if m, ok := readVideoMetaIfValid(metaPath, fi); ok { return m, true } if ctx == nil { ctx = context.Background() } cctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() if durSem != nil { if err := durSem.Acquire(cctx); err != nil { return nil, false } defer durSem.Release() } dur, derr := durationSecondsCached(cctx, fullPath) if derr != nil || dur <= 0 { return nil, false } w, h, fps, perr := probeVideoProps(cctx, fullPath) if perr != nil { w, h, fps = 0, 0, 0 } _ = os.MkdirAll(filepath.Dir(metaPath), 0o755) if err := writeVideoMeta(metaPath, fi, dur, w, h, fps, sourceURL); err != nil { return nil, false } m, ok := readVideoMetaLoose(metaPath) if !ok || m == nil { return nil, false } return m, true } func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath string) { if job == nil { return } fullPath = strings.TrimSpace(fullPath) if fullPath == "" { return } fi, err := os.Stat(fullPath) if err != nil || fi == nil || fi.IsDir() { return } if job.SizeBytes <= 0 { job.SizeBytes = fi.Size() } m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL) if !ok || m == nil { return } job.Meta = m if job.DurationSeconds <= 0 && m.Media.DurationSeconds > 0 { job.DurationSeconds = m.Media.DurationSeconds } if job.VideoWidth <= 0 && m.Media.Video.Width > 0 { job.VideoWidth = m.Media.Video.Width } if job.VideoHeight <= 0 && m.Media.Video.Height > 0 { job.VideoHeight = m.Media.Video.Height } if job.FPS <= 0 && m.Media.Video.FPS > 0 { job.FPS = m.Media.Video.FPS } } func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) { fullPath = strings.TrimSpace(fullPath) if fullPath == "" { return nil, false } fi, err := os.Stat(fullPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return nil, false } if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil { return m, true } dur := durationSecondsCacheOnly(fullPath, fi) if dur <= 0 { return nil, false } stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return nil, false } assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { return nil, false } metaPath, err := metaJSONPathForAssetID(assetID) if err != nil || strings.TrimSpace(metaPath) == "" { return nil, false } _ = os.MkdirAll(filepath.Dir(metaPath), 0o755) _ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL) if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil { return m, true } return nil, false } func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { return nil, false } var m videoMeta if err := json.Unmarshal(b, &m); err != nil { return nil, false } if m.Version != 3 { return nil, false } if m.File.Size != fi.Size() || m.File.ModUnix != fi.ModTime().Unix() { return nil, false } if m.Media.DurationSeconds <= 0 { return nil, false } return &m, true } func readVideoMetaLoose(metaPath string) (*videoMeta, bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { return nil, false } var m videoMeta if err := json.Unmarshal(b, &m); err != nil { return nil, false } if m.Version == 0 { m.Version = 3 } return &m, true } func mergePreviewSpriteMeta(existing, incoming *previewSpriteMeta) *previewSpriteMeta { if existing == nil && incoming == nil { return nil } if existing == nil { cp := *incoming return &cp } if incoming == nil { cp := *existing return &cp } out := *incoming if strings.TrimSpace(out.Path) == "" { out.Path = existing.Path } if out.Count <= 0 { out.Count = existing.Count } if out.Cols <= 0 { out.Cols = existing.Cols } if out.Rows <= 0 { out.Rows = existing.Rows } if out.StepSeconds <= 0 { out.StepSeconds = existing.StepSeconds } if out.CellWidth <= 0 { out.CellWidth = existing.CellWidth } if out.CellHeight <= 0 { out.CellHeight = existing.CellHeight } return &out } func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool { if ai == nil { return false } if strings.TrimSpace(ai.Goal) != "" { return true } if strings.TrimSpace(ai.Mode) != "" { return true } if len(ai.Hits) > 0 { return true } if len(ai.Segments) > 0 { return true } if ai.Rating != nil { return true } if ai.AnalyzedAtUnix > 0 { return true } return false } func pickAnalysisAIForGoal(a analysisMeta, goal string) *aiAnalysisMeta { if hasAIAnalysisMeta(a.Highlights) { return a.Highlights } return nil } func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) { m, ok := readVideoMetaIfValid(metaPath, fi) if !ok || m == nil { return "", false } u := strings.TrimSpace(m.File.SourceURL) if u == "" { return "", false } return u, true } func writeVideoMeta( metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string, ) error { if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } var existing *videoMeta if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ Version: 3, UpdatedAtUnix: time.Now().Unix(), File: videoFileMeta{ Size: fi.Size(), ModUnix: fi.ModTime().Unix(), SourceURL: strings.TrimSpace(sourceURL), }, Media: videoMediaMeta{ DurationSeconds: dur, Video: videoStreamMeta{ Width: w, Height: h, FPS: fps, Resolution: formatResolution(w, h), }, }, } if existing != nil { m.Preview = existing.Preview m.Analysis = existing.Analysis m.Validation = existing.Validation m.Postwork = existing.Postwork if m.File.SourceURL == "" { m.File.SourceURL = existing.File.SourceURL } if m.Media.Video.Width <= 0 { m.Media.Video.Width = existing.Media.Video.Width } if m.Media.Video.Height <= 0 { m.Media.Video.Height = existing.Media.Video.Height } if m.Media.Video.FPS <= 0 { m.Media.Video.FPS = existing.Media.Video.FPS } if m.Media.Video.Resolution == "" { m.Media.Video.Resolution = existing.Media.Video.Resolution } } if isZeroPostworkMeta(m.Postwork) { m.Postwork = nil } buf, err := json.MarshalIndent(m, "", " ") if err != nil { return err } buf = append(buf, '\n') return atomicWriteFile(metaPath, buf) } func writeVideoMetaAI( metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string, ai *aiAnalysisMeta, ) error { if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } var existing *videoMeta if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ Version: 3, UpdatedAtUnix: time.Now().Unix(), File: videoFileMeta{ Size: fi.Size(), ModUnix: fi.ModTime().Unix(), SourceURL: strings.TrimSpace(sourceURL), }, Media: videoMediaMeta{ DurationSeconds: dur, Video: videoStreamMeta{ Width: w, Height: h, FPS: fps, Resolution: formatResolution(w, h), }, }, } if existing != nil { m.Preview = existing.Preview m.Validation = existing.Validation m.Postwork = existing.Postwork if m.Media.Video.Width <= 0 { m.Media.Video.Width = existing.Media.Video.Width } if m.Media.Video.Height <= 0 { m.Media.Video.Height = existing.Media.Video.Height } if m.Media.Video.FPS <= 0 { m.Media.Video.FPS = existing.Media.Video.FPS } if m.Media.Video.Resolution == "" { m.Media.Video.Resolution = existing.Media.Video.Resolution } if m.File.SourceURL == "" { m.File.SourceURL = existing.File.SourceURL } if m.Media.DurationSeconds <= 0 { m.Media.DurationSeconds = existing.Media.DurationSeconds } // Wichtig: // analysis wird NICHT aus existing übernommen. // Dadurch verschwinden alte analysis.ai / analysis.nsfw beim nächsten Schreiben. } if hasAIAnalysisMeta(ai) { ai.Goal = "highlights" m.Analysis.Highlights = ai } if isZeroPostworkMeta(m.Postwork) { m.Postwork = nil } buf, err := json.MarshalIndent(m, "", " ") if err != nil { return err } buf = append(buf, '\n') return atomicWriteFile(metaPath, buf) } func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string, clips []previewClip) error { if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath))) var existing *videoMeta if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ Version: 3, UpdatedAtUnix: time.Now().Unix(), File: videoFileMeta{ Size: fi.Size(), ModUnix: fi.ModTime().Unix(), SourceURL: strings.TrimSpace(sourceURL), }, Media: videoMediaMeta{ DurationSeconds: dur, Video: videoStreamMeta{ Width: w, Height: h, FPS: fps, Resolution: formatResolution(w, h), }, }, Preview: previewMeta{ Clips: clips, Thumb: buildPreviewThumbMeta(assetID), }, } if existing != nil { m.Analysis = existing.Analysis m.Postwork = existing.Postwork if existing.Preview.Sprite != nil && m.Preview.Sprite == nil { m.Preview.Sprite = existing.Preview.Sprite } if m.Preview.Thumb == nil && existing.Preview.Thumb != nil { m.Preview.Thumb = existing.Preview.Thumb } if m.File.SourceURL == "" { m.File.SourceURL = existing.File.SourceURL } if m.Media.Video.Width <= 0 { m.Media.Video.Width = existing.Media.Video.Width } if m.Media.Video.Height <= 0 { m.Media.Video.Height = existing.Media.Video.Height } if m.Media.Video.FPS <= 0 { m.Media.Video.FPS = existing.Media.Video.FPS } if m.Media.Video.Resolution == "" { m.Media.Video.Resolution = existing.Media.Video.Resolution } } if isZeroPostworkMeta(m.Postwork) { m.Postwork = nil } buf, err := json.Marshal(m) if err != nil { return err } buf = append(buf, '\n') return atomicWriteFile(metaPath, buf) } func writeVideoMetaWithPreviewClipsAndSprite( metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string, clips []previewClip, sprite *previewSpriteMeta, ) error { if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath))) var existing *videoMeta if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ Version: 3, UpdatedAtUnix: time.Now().Unix(), File: videoFileMeta{ Size: fi.Size(), ModUnix: fi.ModTime().Unix(), SourceURL: strings.TrimSpace(sourceURL), }, Media: videoMediaMeta{ DurationSeconds: dur, Video: videoStreamMeta{ Width: w, Height: h, FPS: fps, Resolution: formatResolution(w, h), }, }, Preview: previewMeta{ Clips: clips, Thumb: buildPreviewThumbMeta(assetID), Sprite: sprite, }, } if existing != nil { m.Analysis = existing.Analysis m.Postwork = existing.Postwork m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite) if m.Preview.Thumb == nil && existing.Preview.Thumb != nil { m.Preview.Thumb = existing.Preview.Thumb } if len(m.Preview.Clips) == 0 && len(existing.Preview.Clips) > 0 { m.Preview.Clips = existing.Preview.Clips } if m.File.SourceURL == "" { m.File.SourceURL = existing.File.SourceURL } if m.Media.Video.Width <= 0 { m.Media.Video.Width = existing.Media.Video.Width } if m.Media.Video.Height <= 0 { m.Media.Video.Height = existing.Media.Video.Height } if m.Media.Video.FPS <= 0 { m.Media.Video.FPS = existing.Media.Video.FPS } if m.Media.Video.Resolution == "" { m.Media.Video.Resolution = existing.Media.Video.Resolution } if m.Media.DurationSeconds <= 0 { m.Media.DurationSeconds = existing.Media.DurationSeconds } } if isZeroPostworkMeta(m.Postwork) { m.Postwork = nil } buf, err := json.Marshal(m) if err != nil { return err } buf = append(buf, '\n') return atomicWriteFile(metaPath, buf) } func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error { return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL) } func generatedMetaFile(assetID string) (string, error) { assetID = stripHotPrefix(strings.TrimSpace(assetID)) if assetID == "" { return "", appErrorf("empty assetID") } id, err := sanitizeID(assetID) if err != nil || id == "" { return "", appErrorf("invalid assetID: %w", err) } return metaJSONPathForAssetID(id) } func generatedDirForID(id string) (string, error) { id, err := sanitizeID(id) if err != nil { return "", err } root, err := generatedMetaRoot() if err != nil { return "", err } if strings.TrimSpace(root) == "" { return "", appErrorf("generated meta root ist leer") } return filepath.Join(root, id), nil } func ensureGeneratedDir(id string) (string, error) { dir, err := generatedDirForID(id) if err != nil { return "", err } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } return dir, nil } func formatResolution(w, h int) string { if w <= 0 || h <= 0 { return "" } return fmt.Sprintf("%dx%d", w, h) } func generatedThumbFile(id string) (string, error) { dir, err := generatedDirForID(id) if err != nil { return "", err } return filepath.Join(dir, "preview.jpg"), nil } func generatedPreviewFile(id string) (string, error) { dir, err := generatedDirForID(id) if err != nil { return "", err } return filepath.Join(dir, "preview.mp4"), nil } func generatedPreviewSpriteFile(id string) (string, error) { dir, err := generatedDirForID(id) if err != nil { return "", err } return filepath.Join(dir, "preview-sprite.jpg"), nil } func buildPreviewThumbMeta(assetID string) *previewThumbMeta { assetID = stripHotPrefix(strings.TrimSpace(assetID)) if assetID == "" { return nil } thumbPath, err := generatedThumbFile(assetID) if err != nil || strings.TrimSpace(thumbPath) == "" { return nil } if _, err := os.Stat(thumbPath); err != nil { return nil } return &previewThumbMeta{ Path: "/api/generated/cover?id=" + assetID, Exists: true, } } func ensureGeneratedDirs() error { root, err := generatedMetaRoot() if err != nil { return err } if strings.TrimSpace(root) == "" { return appErrorf("generated meta root ist leer") } return os.MkdirAll(root, 0o755) } func sanitizeID(id string) (string, error) { id = strings.TrimSpace(id) if id == "" { return "", appErrorf("id fehlt") } if strings.ContainsAny(id, `/\`) { return "", appErrorf("ungültige id") } return id, nil } func writeVideoAIForFile( ctx context.Context, fullPath string, sourceURL string, ai *aiAnalysisMeta, ) error { fullPath = strings.TrimSpace(fullPath) if fullPath == "" || ai == nil { return nil } fi, err := os.Stat(fullPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return appErrorf("datei nicht gefunden") } m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, sourceURL) if !ok || m == nil { return appErrorf("meta konnte nicht erzeugt werden") } stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return appErrorf("asset id fehlt") } assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { return appErrorf("asset id ungültig: %w", err) } metaPath, err := metaJSONPathForAssetID(assetID) if err != nil { return err } if strings.TrimSpace(sourceURL) == "" { sourceURL = m.File.SourceURL } return writeVideoMetaAI( metaPath, fi, m.Media.DurationSeconds, m.Media.Video.Width, m.Media.Video.Height, m.Media.Video.FPS, sourceURL, ai, ) } func readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { return nil, false } var m map[string]any dec := json.NewDecoder(strings.NewReader(string(b))) dec.UseNumber() if err := dec.Decode(&m); err != nil { return nil, false } rawAnalysis, ok := m["analysis"].(map[string]any) if !ok || rawAnalysis == nil { return nil, false } // Neuer einziger Zweig. if highlights, ok := rawAnalysis["highlights"].(map[string]any); ok && highlights != nil { return highlights, true } return nil, false }