refactor: modularize chat backend, update docs for .m4s segments
This commit is contained in:
1 parent
46e878bef6
commit
b3a10ba3ef
24 files changed
+3771
-2781
No files matched your search
@@ -0,0 +1,385 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user