nsfwapp/backend/meta.go
2026-03-16 12:46:38 +01:00

702 lines
17 KiB
Go

// backend/meta.go
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// --------------------------
// generated/meta/<id>/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/<id>
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/<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 "", 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.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 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
}