450 lines
13 KiB
Go
450 lines
13 KiB
Go
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
|
|
} |