// backend/meta.go package main import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" ) // -------------------------- // generated/meta//meta.json // -------------------------- type previewClip struct { StartSeconds float64 `json:"startSeconds"` DurationSeconds float64 `json:"durationSeconds"` } 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"` // z.B. "1920x1080" SourceURL string `json:"sourceUrl,omitempty"` PreviewClips []previewClip `json:"previewClips,omitempty"` UpdatedAtUnix int64 `json:"updatedAtUnix"` } // 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 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 } 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(), } 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 } 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(), } 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, "thumbs.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 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 }