package media import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "time" "gorm.io/gorm" ) // FormatDuration converts seconds to HH:MM:SS string. func FormatDuration(seconds int) string { if seconds < 0 { seconds = 0 } hours := seconds / 3600 minutes := (seconds % 3600) / 60 remainingSeconds := seconds % 60 return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds) } // NormalizationResult holds ffprobe validation output for admin uploads. type NormalizationResult struct { Valid bool `json:"valid"` Errors []string `json:"errors"` } // ValidateNormalization runs ffprobe against a media file and checks // conformance with the Linear Void normalization profile (ADR-001). func ValidateNormalization(mediaPath string) NormalizationResult { result := NormalizationResult{Valid: true, Errors: []string{}} ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath, ).CombinedOutput() if ctx.Err() == context.DeadlineExceeded { return NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}} } if err != nil { return NormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}} } var probe struct { Streams []struct { CodecName string `json:"codec_name"` CodecType string `json:"codec_type"` Width int `json:"width"` Height int `json:"height"` RFrameRate string `json:"r_frame_rate"` PixFmt string `json:"pix_fmt"` SampleRate string `json:"sample_rate"` } `json:"streams"` } if err := json.Unmarshal(out, &probe); err != nil { return NormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}} } var hasVideo, hasAudio bool for _, s := range probe.Streams { switch s.CodecType { case "video": hasVideo = true if s.CodecName != "h264" { result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName)) } if s.RFrameRate != "30/1" { result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate)) } if s.Width != 1280 && s.Width != 1920 { result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width)) } if s.Height != 720 && s.Height != 1080 { result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height)) } if s.PixFmt != "yuv420p" { result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt)) } case "audio": hasAudio = true if s.CodecName != "aac" { result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName)) } if s.SampleRate != "48000" { result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate)) } } } if !hasVideo { result.Errors = append(result.Errors, "no video stream found") } if !hasAudio { result.Errors = append(result.Errors, "no audio stream found") } if len(result.Errors) > 0 { result.Valid = false } return result } // ProbeDurationSeconds returns the duration of a media file in seconds. func ProbeDurationSeconds(mediaPath string) (int, error) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() out, err := exec.CommandContext( ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", mediaPath, ).CombinedOutput() if ctx.Err() == context.DeadlineExceeded { return 0, fmt.Errorf("ffprobe timed out") } if err != nil { return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out))) } rawDuration := strings.TrimSpace(string(out)) if rawDuration == "" { return 0, fmt.Errorf("ffprobe returned empty duration") } secondsFloat, err := strconv.ParseFloat(rawDuration, 64) if err != nil { return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err) } if secondsFloat < 0 { secondsFloat = 0 } seconds := int(secondsFloat + 0.5) if secondsFloat > 0 && seconds == 0 { seconds = 1 } return seconds, nil } // Media status values (ADR-006 media library states). const ( StatusReady = "ready" StatusNeedsNormalization = "needs_normalization" StatusNormalizing = "normalizing" StatusNormalizationFailed = "normalization_failed" ) // runCommand executes an external command and returns its combined output on failure. // It is a package variable so tests can substitute a stub. var runCommand = func(ctx context.Context, name string, args ...string) error { out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() if err != nil { return fmt.Errorf("%s failed: %v (%s)", name, err, strings.TrimSpace(string(out))) } return nil } // normalizationArgs builds the ffmpeg arguments for the Linear Void normalization profile. func normalizationArgs(srcPath, dstPath string) []string { return []string{ "-y", "-i", srcPath, "-map", "0:v:0", "-map", "0:a:0?", "-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p", "-c:v", "libx264", "-preset", "medium", "-crf", "21", "-pix_fmt", "yuv420p", "-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0", "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2", "-avoid_negative_ts", "make_zero", "-max_muxing_queue_size", "1024", "-movflags", "+faststart", dstPath, } } // normalizeVideo transcodes srcPath to the Linear Void normalization profile, // re-verifies the output with ffprobe, and returns its duration (seconds) and size (bytes). func normalizeVideo(srcPath, dstPath string) (int, int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) defer cancel() if err := runCommand(ctx, "ffmpeg", normalizationArgs(srcPath, dstPath)...); err != nil { return 0, 0, err } if res := ValidateNormalization(dstPath); !res.Valid { return 0, 0, fmt.Errorf("normalized output failed validation: %s", strings.Join(res.Errors, "; ")) } duration, err := ProbeDurationSeconds(dstPath) if err != nil { return 0, 0, fmt.Errorf("probe duration failed: %w", err) } info, err := os.Stat(dstPath) if err != nil { return 0, 0, fmt.Errorf("stat output failed: %w", err) } return duration, info.Size(), nil } // NormalizeVideo is the injectable normalization entry point. var NormalizeVideo = normalizeVideo // DetectMime reads the first 512 bytes of a file to detect its MIME type. func DetectMime(filePath string) (string, error) { f, err := os.Open(filePath) if err != nil { return "", err } defer f.Close() head := make([]byte, 512) n, err := f.Read(head) if err != nil && err != io.EOF { return "", err } return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil } // IsAllowedExtension checks if the file extension is a supported video format. func IsAllowedExtension(fileName string) bool { ext := strings.ToLower(filepath.Ext(fileName)) switch ext { case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts": return true default: return false } } // IsAllowedMime checks if the MIME type is a supported video type. func IsAllowedMime(mime string) bool { mime = strings.TrimSpace(strings.ToLower(mime)) if mime == "" { return true } if strings.HasPrefix(mime, "video/") { return true } return mime == "application/octet-stream" } // UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed. func UniqueTargetPath(mediaDir, fileName string) string { baseName := filepath.Base(fileName) ext := filepath.Ext(baseName) nameOnly := strings.TrimSuffix(baseName, ext) candidate := filepath.Join(mediaDir, baseName) for i := 1; ; i++ { if _, err := os.Stat(candidate); os.IsNotExist(err) { return candidate } candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext)) } } // FileEntry represents a media file for API responses. type FileEntry struct { ID uint `json:"id"` Name string `json:"name"` OriginalName string `json:"originalName"` Path string `json:"path"` Size int64 `json:"size"` DurationSeconds int `json:"durationSeconds"` Runtime string `json:"runtime"` Status string `json:"status"` Title string `json:"title"` Tags string `json:"tags"` UploadedAt string `json:"uploadedAt"` ModifiedAt string `json:"modifiedAt"` } // MediaRecord is the interface for media DB records. type MediaRecord interface { GetID() uint GetFilename() string GetOriginalName() string GetPath() string GetSizeBytes() int64 GetDurationSeconds() int GetStatus() string GetTitle() string GetTags() string GetUploadedAt() time.Time GetUpdatedAt() time.Time } // ListFiles returns all media files sorted by upload date descending. func ListFiles(db *gorm.DB) ([]FileEntry, error) { type record struct { ID uint Filename string OriginalName string Path string SizeBytes int64 DurationSeconds int Status string Title string Tags string UploadedAt time.Time UpdatedAt time.Time } var records []record if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil { return nil, err } files := make([]FileEntry, 0, len(records)) for _, r := range records { files = append(files, FileEntry{ ID: r.ID, Name: r.Filename, OriginalName: r.OriginalName, Path: r.Path, Size: r.SizeBytes, DurationSeconds: r.DurationSeconds, Runtime: FormatDuration(r.DurationSeconds), Status: r.Status, Title: r.Title, Tags: r.Tags, UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339), ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339), }) } sort.Slice(files, func(i, j int) bool { if files[i].UploadedAt == files[j].UploadedAt { return files[i].Name < files[j].Name } return files[i].UploadedAt > files[j].UploadedAt }) return files, nil } // ParsePlaylistEntries parses playlist content into schedule entries. type ScheduleEntry struct { Index int `json:"index"` Path string `json:"path"` Label string `json:"label"` DurationSeconds int `json:"durationSeconds"` Runtime string `json:"runtime"` Status string `json:"status"` Title string `json:"title"` } // ParsePlaylist parses playlist text content into entries. func ParsePlaylist(content string) []ScheduleEntry { entries := []ScheduleEntry{} lines := strings.Split(content, "\n") for _, rawLine := range lines { line := strings.TrimSpace(rawLine) if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "file ") { line = strings.TrimSpace(strings.TrimPrefix(line, "file ")) line = strings.Trim(line, "'\"") } entries = append(entries, ScheduleEntry{ Index: len(entries) + 1, Path: line, Label: filepath.Base(line), }) } return entries } // UploadSessionDir returns the directory for an upload session's chunks. func UploadSessionDir(mediaDir, uploadID string) string { return filepath.Join(mediaDir, ".uploads", uploadID) } // UploadSessionMetaPath returns the path to the session metadata file. func UploadSessionMetaPath(mediaDir, uploadID string) string { return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json") } // UploadChunkPath returns the path for a specific chunk file. func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string { return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex)) } // CountUploadedChunks counts how many chunks exist for a session. func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int { uploaded := 0 for i := 0; i < totalChunks; i++ { if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil { uploaded++ } } return uploaded } // ValidateUploadID checks that an upload ID is a valid hex string of appropriate length. func ValidateUploadID(uploadID string) bool { if len(uploadID) < 16 || len(uploadID) > 128 { return false } for _, r := range uploadID { if (r < 'a' || r > 'f') && (r < '0' || r > '9') { return false } } return true } // GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123. func GetMediaIDFromPath(path, prefix string) (uint, error) { if !strings.HasPrefix(path, prefix) { return 0, fmt.Errorf("invalid media path") } idPart := strings.TrimPrefix(path, prefix) idPart = strings.TrimSpace(idPart) if idPart == "" || strings.Contains(idPart, "/") { return 0, fmt.Errorf("invalid media id") } id, err := strconv.ParseUint(idPart, 10, 64) if err != nil || id == 0 { return 0, fmt.Errorf("invalid media id") } return uint(id), nil }