// 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 (z.B. 5) CellWidth int `json:"cellWidth,omitempty"` CellHeight int `json:"cellHeight,omitempty"` } type videoMeta struct { Version int `json:"version"` DurationSeconds float64 `json:"durationSeconds"` FileSize int64 `json:"fileSize"` FileModUnix int64 `json:"fileModUnix"` VideoWidth int `json:"videoWidth,omitempty"` VideoHeight int `json:"videoHeight,omitempty"` FPS float64 `json:"fps,omitempty"` Resolution string `json:"resolution,omitempty"` SourceURL string `json:"sourceUrl,omitempty"` PreviewClips []previewClip `json:"previewClips,omitempty"` PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"` AI *aiAnalysisMeta `json:"ai,omitempty"` UpdatedAtUnix int64 `json:"updatedAtUnix"` } type aiSegmentMeta struct { Label string `json:"label"` StartSeconds float64 `json:"startSeconds"` EndSeconds float64 `json:"endSeconds"` DurationSeconds float64 `json:"durationSeconds"` Score float64 `json:"score,omitempty"` AutoSelected bool `json:"autoSelected,omitempty"` } type aiAnalysisMeta struct { Goal string `json:"goal,omitempty"` Mode string `json:"mode,omitempty"` Hits []analyzeHit `json:"hits,omitempty"` Segments []aiSegmentMeta `json:"segments,omitempty"` AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"` } // liest Meta (v2 ODER altes v1) und validiert gegen fi (Size/ModTime) func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { return 0, 0, 0, 0, false } // 1) Neues Format (oder v1 mit gleichen Feldern) var m videoMeta if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) { if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() { return 0, 0, 0, 0, false } if m.DurationSeconds <= 0 { return 0, 0, 0, 0, false } return m.DurationSeconds, m.VideoWidth, m.VideoHeight, m.FPS, true } // 2) Fallback: ganz altes v1-Format (nur Duration etc.) var m1 struct { Version int `json:"version"` DurationSeconds float64 `json:"durationSeconds"` FileSize int64 `json:"fileSize"` FileModUnix int64 `json:"fileModUnix"` UpdatedAtUnix int64 `json:"updatedAtUnix"` } if err := json.Unmarshal(b, &m1); err != nil { return 0, 0, 0, 0, false } if m1.Version != 1 { return 0, 0, 0, 0, false } if m1.FileSize != fi.Size() || m1.FileModUnix != fi.ModTime().Unix() { return 0, 0, 0, 0, false } if m1.DurationSeconds <= 0 { return 0, 0, 0, 0, false } return m1.DurationSeconds, 0, 0, 0, true } func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) { m, ok := readVideoMetaIfValid(metaPath, fi) if !ok || m == nil || m.DurationSeconds <= 0 { return 0, false } return m.DurationSeconds, true } func metaJSONPathForAssetID(assetID string) (string, error) { root, err := generatedMetaRoot() if err != nil { return "", err } if strings.TrimSpace(root) == "" { return "", fmt.Errorf("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) { // assetID aus Dateiname stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return nil, false } // sanitize wie bei deinen generated Ordnern var err error assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { return nil, false } metaPath, err := metaJSONPathForAssetID(assetID) if err != nil { return nil, false } // 1) valid meta vorhanden? if m, ok := readVideoMetaIfValid(metaPath, fi); ok { return m, true } // 2) sonst neu erzeugen (mit Concurrency-Limit) 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() } // Dauer dur, derr := durationSecondsCached(cctx, fullPath) if derr != nil || dur <= 0 { return nil, false } // Video props w, h, fps, perr := probeVideoProps(cctx, fullPath) if perr != nil { // width/height/fps dürfen 0 bleiben, duration ist aber trotzdem nützlich w, h, fps = 0, 0, 0 } // meta dir anlegen _ = os.MkdirAll(filepath.Dir(metaPath), 0o755) m := &videoMeta{ Version: 2, DurationSeconds: dur, FileSize: fi.Size(), FileModUnix: fi.ModTime().Unix(), VideoWidth: w, VideoHeight: h, FPS: fps, Resolution: formatResolution(w, h), SourceURL: strings.TrimSpace(sourceURL), UpdatedAtUnix: time.Now().Unix(), } b, _ := json.MarshalIndent(m, "", " ") b = append(b, '\n') _ = atomicWriteFile(metaPath, b) // best effort return m, true } func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath string) { if job == nil { return } fullPath = strings.TrimSpace(fullPath) if fullPath == "" { return } // Stat fi, err := os.Stat(fullPath) if err != nil || fi == nil || fi.IsDir() { return } // Größe immer mitgeben (macht Sort/Anzeige einfacher) if job.SizeBytes <= 0 { job.SizeBytes = fi.Size() } // Meta.json lesen/erzeugen (best effort) m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL) if !ok || m == nil { return } // Optional: komplettes Meta mitsenden job.Meta = m // Und zusätzlich die "Top-Level" Felder befüllen (für Frontend bequem) if job.DurationSeconds <= 0 && m.DurationSeconds > 0 { job.DurationSeconds = m.DurationSeconds } if job.VideoWidth <= 0 && m.VideoWidth > 0 { job.VideoWidth = m.VideoWidth } if job.VideoHeight <= 0 && m.VideoHeight > 0 { job.VideoHeight = m.VideoHeight } if job.FPS <= 0 && m.FPS > 0 { job.FPS = m.FPS } } // ensureVideoMetaForFileBestEffort: // - versucht zuerst echtes Generieren (ffprobe/ffmpeg) via ensureVideoMetaForFile // - wenn das fehlschlägt, aber durationSecondsCacheOnly schon was weiß: // schreibt eine Duration-only meta.json, damit wir künftig "aus meta.json" lesen können. 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 } // 1) Normaler Weg: meta erzeugen/lesen (ffprobe/ffmpeg) if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil { return m, true } // 2) Fallback: wenn wir Duration schon im RAM-Cache haben -> meta.json (Duration-only) persistieren 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) // nochmal lesen/validieren 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 != 1 && m.Version != 2 { return nil, false } if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() { return nil, false } if m.DurationSeconds <= 0 { return nil, false } return &m, true } func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) { m, ok := readVideoMetaIfValid(metaPath, fi) if !ok || m == nil { return "", false } u := strings.TrimSpace(m.SourceURL) if u == "" { return "", false } return u, true } // Voll-Write (wenn du dur + props schon hast) 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 := readVideoMetaIfValid(metaPath, fi); ok && old != nil { existing = old } m := videoMeta{ Version: 2, DurationSeconds: dur, FileSize: fi.Size(), FileModUnix: fi.ModTime().Unix(), VideoWidth: w, VideoHeight: h, FPS: fps, Resolution: formatResolution(w, h), SourceURL: strings.TrimSpace(sourceURL), UpdatedAtUnix: time.Now().Unix(), PreviewClips: nil, PreviewSprite: nil, AI: nil, } if existing != nil { m.PreviewClips = existing.PreviewClips m.PreviewSprite = existing.PreviewSprite m.AI = existing.AI } buf, err := json.Marshal(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 } var existing *videoMeta if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { existing = old } m := videoMeta{ Version: 2, DurationSeconds: dur, FileSize: fi.Size(), FileModUnix: fi.ModTime().Unix(), VideoWidth: w, VideoHeight: h, FPS: fps, Resolution: formatResolution(w, h), SourceURL: strings.TrimSpace(sourceURL), PreviewClips: clips, UpdatedAtUnix: time.Now().Unix(), } if existing != nil { m.AI = existing.AI } // ✅ vorhandenes Sprite (inkl. stepSeconds) nicht wegwerfen if existing != nil && existing.PreviewSprite != nil { m.PreviewSprite = existing.PreviewSprite } 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 } m := videoMeta{ Version: 2, DurationSeconds: dur, FileSize: fi.Size(), FileModUnix: fi.ModTime().Unix(), VideoWidth: w, VideoHeight: h, FPS: fps, Resolution: formatResolution(w, h), SourceURL: strings.TrimSpace(sourceURL), PreviewClips: clips, PreviewSprite: sprite, UpdatedAtUnix: time.Now().Unix(), } if sprite == nil { if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.PreviewSprite != nil { m.PreviewSprite = old.PreviewSprite } } if len(clips) == 0 { if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && len(old.PreviewClips) > 0 { m.PreviewClips = old.PreviewClips } } if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.AI != nil { m.AI = old.AI } buf, err := json.Marshal(m) if err != nil { return err } buf = append(buf, '\n') return atomicWriteFile(metaPath, buf) } // Duration-only Write (ohne props) 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 "", fmt.Errorf("empty assetID") } // exakt wie beim Schreiben normalisieren id, err := sanitizeID(assetID) if err != nil || id == "" { return "", fmt.Errorf("invalid assetID: %w", err) } return metaJSONPathForAssetID(id) } // ✅ Neu: /generated/meta//... 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 "", fmt.Errorf("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.webp"), 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.webp"), nil } func ensureGeneratedDirs() error { root, err := generatedMetaRoot() if err != nil { return err } if strings.TrimSpace(root) == "" { return fmt.Errorf("generated meta root ist leer") } return os.MkdirAll(root, 0o755) } func sanitizeID(id string) (string, error) { id = strings.TrimSpace(id) if id == "" { return "", fmt.Errorf("id fehlt") } if strings.ContainsAny(id, `/\`) { return "", fmt.Errorf("ungültige id") } return id, nil } 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 := readVideoMetaIfValid(metaPath, fi); ok && old != nil { existing = old } m := videoMeta{ Version: 2, DurationSeconds: dur, FileSize: fi.Size(), FileModUnix: fi.ModTime().Unix(), VideoWidth: w, VideoHeight: h, FPS: fps, Resolution: formatResolution(w, h), SourceURL: strings.TrimSpace(sourceURL), UpdatedAtUnix: time.Now().Unix(), PreviewClips: nil, PreviewSprite: nil, AI: ai, } if existing != nil { m.PreviewClips = existing.PreviewClips m.PreviewSprite = existing.PreviewSprite if m.VideoWidth <= 0 { m.VideoWidth = existing.VideoWidth } if m.VideoHeight <= 0 { m.VideoHeight = existing.VideoHeight } if m.FPS <= 0 { m.FPS = existing.FPS } if m.Resolution == "" { m.Resolution = existing.Resolution } if m.SourceURL == "" { m.SourceURL = existing.SourceURL } } buf, err := json.MarshalIndent(m, "", " ") if err != nil { return err } buf = append(buf, '\n') return atomicWriteFile(metaPath, buf) } 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 fmt.Errorf("datei nicht gefunden") } m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, sourceURL) if !ok || m == nil { return fmt.Errorf("meta konnte nicht erzeugt werden") } stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return fmt.Errorf("asset id fehlt") } assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { return fmt.Errorf("asset id ungültig: %w", err) } metaPath, err := metaJSONPathForAssetID(assetID) if err != nil { return err } return writeVideoMetaAI( metaPath, fi, m.DurationSeconds, m.VideoWidth, m.VideoHeight, m.FPS, sourceURL, ai, ) } func readVideoMetaAI(metaPath 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 } ai, ok := m["ai"].(map[string]any) if !ok || ai == nil { return nil, false } return ai, true }