WIP: self-describing library items with multi-media support
Mcmd/vidarchive/main.go
@@ -17,54 +17,47 @@ import (
func main() {
cfg := config.New()
// Ensure directories exist
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
log.Fatalf("Failed to create data dir: %v", err)
}
if err := os.MkdirAll(cfg.DownloadDir, 0755); err != nil {
log.Fatalf("Failed to create download dir: %v", err)
if err := os.MkdirAll(cfg.LibraryDir, 0755); err != nil {
log.Fatalf("Failed to create library dir: %v", err)
}
if err := os.MkdirAll(cfg.SubtitleCacheDir, 0755); err != nil {
log.Fatalf("Failed to create subtitle cache dir: %v", err)
if err := os.MkdirAll(cfg.TempDir, 0755); err != nil {
log.Fatalf("Failed to create temp dir: %v", err)
}
// Initialize database
db, err := database.New(cfg)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
// Initialize repositories
presetRepo := repository.NewPresetRepository(db)
mediaRepo := repository.NewMediaRepository(db)
downloadRepo := repository.NewDownloadRepository(db)
settingsRepo := repository.NewSettingsRepository(db)
// Initialize services
presetSvc := service.NewPresetService(presetRepo)
mediaSvc := service.NewMediaService(mediaRepo, cfg.DownloadDir, cfg.SubtitleCacheDir)
librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.TempDir)
settingsSvc := service.NewSettingsService(settingsRepo)
downloadSvc := service.NewDownloadService(downloadRepo, mediaRepo, presetSvc, settingsSvc, cfg)
downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, cfg)
// Initialize worker pool
workerPool := worker.New(downloadSvc, cfg.Workers)
workerPool.Start()
defer workerPool.Stop()
// Initialize handler
h, err := handler.New(cfg, presetSvc, downloadSvc, mediaSvc, settingsSvc, workerPool)
h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, workerPool)
if err != nil {
log.Fatalf("Failed to initialize handler: %v", err)
}
// Initialize and start server
srv := server.New(cfg, h)
fmt.Printf("VidArchive starting...\n")
fmt.Printf("Data directory: %s\n", cfg.DataDir)
fmt.Printf("Download directory: %s\n", cfg.DownloadDir)
fmt.Printf("Library directory: %s\n", cfg.LibraryDir)
fmt.Printf("Temp directory: %s\n", cfg.TempDir)
if err := srv.Start(); err != nil {
log.Fatalf("Server error: %v", err)
}
Mgo.mod
@@ -3,6 +3,7 @@ module vidarchive
go 1.26.2
require (
github.com/BurntSushi/toml v1.6.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/go-chi/chi/v5 v5.2.5
modernc.org/sqlite v1.50.0
Mgo.sum
@@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
Minternal/config/config.go
@@ -9,27 +9,27 @@ import (
)
type Config struct {
Port int
DataDir string
DBPath string
DownloadDir string
SubtitleCacheDir string
YTDLPPath string
BaseURL string
Workers int
RefreshInterval int
Port int
DataDir string
DBPath string
LibraryDir string
TempDir string
YTDLPPath string
BaseURL string
Workers int
RefreshInterval int
}
func New() *Config {
dataDir := getEnv("VIDARCHIVE_DATA_DIR", "./data")
return &Config{
Port: getEnvInt("VIDARCHIVE_PORT", 8080),
DataDir: dataDir,
DBPath: getEnv("VIDARCHIVE_DB_PATH", filepath.Join(dataDir, "vidarchive.db")),
DownloadDir: getEnv("VIDARCHIVE_DOWNLOAD_DIR", filepath.Join(dataDir, "downloads")),
SubtitleCacheDir: getEnv("VIDARCHIVE_SUBTITLE_CACHE_DIR", filepath.Join(dataDir, "subtitles")),
YTDLPPath: getEnv("VIDARCHIVE_YTDLP_PATH", "yt-dlp"),
Port: getEnvInt("VIDARCHIVE_PORT", 8080),
DataDir: dataDir,
DBPath: getEnv("VIDARCHIVE_DB_PATH", filepath.Join(dataDir, "vidarchive.db")),
LibraryDir: getEnv("VIDARCHIVE_LIBRARY_DIR", filepath.Join(dataDir, "library")),
TempDir: getEnv("VIDARCHIVE_TEMP_DIR", filepath.Join(dataDir, "temp")),
YTDLPPath: getEnv("VIDARCHIVE_YTDLP_PATH", "yt-dlp"),
BaseURL: getEnv("VIDARCHIVE_BASE_URL", ""),
Workers: getEnvInt("VIDARCHIVE_WORKERS", 2),
RefreshInterval: getEnvInt("VIDARCHIVE_REFRESH_INTERVAL", 5),
Minternal/database/database.go
@@ -70,28 +70,10 @@ func migrate(db *sql.DB) error {
custom_flags TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`},
{2, `CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT,
description TEXT,
duration INTEGER,
filepath TEXT NOT NULL,
relative_path TEXT NOT NULL,
is_audio INTEGER DEFAULT 0,
has_embedded_thumbnail INTEGER DEFAULT 0,
info_json_path TEXT,
playlist_id TEXT,
playlist_index INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`},
{3, `CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
status TEXT DEFAULT 'queued',
progress_percent INTEGER DEFAULT 0,
current_speed TEXT,
eta TEXT,
logs TEXT,
error_message TEXT,
preset_id INTEGER,
@@ -112,10 +94,8 @@ func migrate(db *sql.DB) error {
{8, `ALTER TABLE presets ADD COLUMN format_mode TEXT DEFAULT 'preset'`},
{9, `ALTER TABLE presets ADD COLUMN custom_format TEXT`},
{10, `ALTER TABLE downloads ADD COLUMN output_dir TEXT`},
{11, `ALTER TABLE downloads DROP COLUMN progress_percent`},
{12, `ALTER TABLE downloads DROP COLUMN current_speed`},
{13, `ALTER TABLE downloads DROP COLUMN eta`},
{14, `INSERT OR IGNORE INTO settings (key, value) VALUES ('cookies', '')`},
{11, `DROP TABLE IF EXISTS media`},
{12, `ALTER TABLE presets DROP COLUMN output_template`},
}
for _, m := range migrations {
Minternal/handler/handler.go
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"path/filepath"
"runtime"
"strconv"
"strings"
@@ -24,12 +25,12 @@ type Handler struct {
templates *template.Template
presetSvc *service.PresetService
downloadSvc *service.DownloadService
mediaSvc *service.MediaService
librarySvc *service.LibraryService
settingsSvc *service.SettingsService
workerPool *worker.Pool
}
func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, mediaSvc *service.MediaService, settingsSvc *service.SettingsService, workerPool *worker.Pool) (*Handler, error) {
func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, librarySvc *service.LibraryService, settingsSvc *service.SettingsService, workerPool *worker.Pool) (*Handler, error) {
presetSvcGlobal = presetSvc
tmpl, err := loadTemplates()
if err != nil {
@@ -41,7 +42,7 @@ func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *serv
templates: tmpl,
presetSvc: presetSvc,
downloadSvc: downloadSvc,
mediaSvc: mediaSvc,
librarySvc: librarySvc,
settingsSvc: settingsSvc,
workerPool: workerPool,
}, nil
@@ -60,20 +61,33 @@ func loadTemplates() (*template.Template, error) {
"urlEncodePath": func(p string) string {
parts := strings.Split(strings.Trim(p, "/"), "/")
for i, part := range parts {
parts[i] = url.QueryEscape(part)
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
},
"sub": func(a, b int) int { return a - b },
})
return tmpl.ParseGlob("web/templates/*.html")
if repoRoot == "" {
return nil, fmt.Errorf("failed to determine template path")
}
return tmpl.ParseGlob(filepath.Join(repoRoot, "web", "templates", "*.html"))
}
var presetSvcGlobal *service.PresetService
var (
presetSvcGlobal *service.PresetService
repoRoot string
)
func init() {
_, file, _, ok := runtime.Caller(0)
if ok {
repoRoot = filepath.Join(filepath.Dir(file), "..", "..")
}
}
func formatDuration(seconds int) string {
if seconds == 0 {
if seconds <= 0 {
return "--:--"
}
m := seconds / 60
@@ -117,7 +131,7 @@ type PageData struct {
Theme string
}
const cookieMaxAge = 365 * 24 * 60 * 60 // 1 year
const cookieMaxAge = 365 * 24 * 60 * 60
func getCookie(r *http.Request, name, defaultVal string) string {
cookie, err := r.Cookie(name)
@@ -145,14 +159,12 @@ func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, cont
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Render content first
var contentBuf bytes.Buffer
if err := h.templates.ExecuteTemplate(&contentBuf, contentTmpl, data); err != nil {
http.Error(w, fmt.Sprintf("render content: %v", err), http.StatusInternalServerError)
return
}
// Then render layout with content
layoutData := struct {
PageData
Content template.HTML
@@ -166,7 +178,6 @@ func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, cont
}
}
// Library handlers
func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query().Get("path")
sortBy := r.URL.Query().Get("sort")
@@ -178,7 +189,7 @@ func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
setCookie(w, "library_sort", sortBy)
}
media, folders, err := h.mediaSvc.GetAll(path, sortBy, filter)
items, folders, err := h.librarySvc.GetAll(path, sortBy, filter)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -192,120 +203,152 @@ func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
AutoRefresh: settings.AutoRefreshLibrary,
RefreshSec: settings.RefreshInterval,
Data: struct {
Media []*models.Media
Items []*models.LibraryItem
Folders []string
Path string
SortBy string
Filter string
}{
Media: media,
Items: items,
Folders: folders,
Path: path,
SortBy: sortBy,
Filter: filter,
},
})
for _, item := range items {
go h.librarySvc.EnsureThumbnails(item.RelPath)
}
}
func (h *Handler) LibraryDetail(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
func normalizeRelPath(r *http.Request) string {
relPath := chi.URLParam(r, "*")
relPath = strings.ReplaceAll(relPath, "+", " ")
relPath = strings.Trim(relPath, "/")
relPath, _ = url.PathUnescape(relPath)
return relPath
}
func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
relPath := normalizeRelPath(r)
if r.Method == "POST" && strings.HasSuffix(relPath, "/delete") {
relPath = strings.TrimSuffix(relPath, "/delete")
h.deleteMedia(relPath, w, r)
return
}
h.libraryDetail(relPath, w, r)
}
media, err := h.mediaSvc.GetByID(id)
func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) {
item, err := h.librarySvc.GetByRelPath(relPath)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
meta, _ := h.mediaSvc.GetMetadata(id)
subtitles, _ := h.mediaSvc.GetSubtitles(id)
selectedFilename := r.URL.Query().Get("file")
if selectedFilename == "" && len(item.MediaFiles) > 0 {
selectedFilename = item.MediaFiles[0].Filename
}
meta, _ := h.librarySvc.GetMetadata(relPath)
subtitles, _ := h.librarySvc.GetSubtitles(relPath)
h.renderWithRequest(w, r, "library_detail", PageData{
Title: media.Title,
Title: item.Name,
ActiveTab: "library",
Data: struct {
Media *models.Media
Metadata *service.MediaMetadata
Subtitles []models.SubtitleTrack
Item *models.LibraryItem
SelectedFilename string
Metadata *service.MediaMetadata
Subtitles []models.SubtitleTrack
}{
Media: media,
Metadata: meta,
Subtitles: subtitles,
Item: item,
SelectedFilename: selectedFilename,
Metadata: meta,
Subtitles: subtitles,
},
})
go h.librarySvc.EnsureThumbnails(relPath)
}
func (h *Handler) ServeMedia(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
func (h *Handler) ServeMediaItem(w http.ResponseWriter, r *http.Request) {
relPath := normalizeRelPath(r)
if strings.HasSuffix(relPath, "/thumbnail") {
h.serveThumbnail(strings.TrimSuffix(relPath, "/thumbnail"), w, r)
return
}
media, err := h.mediaSvc.GetByID(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
if strings.HasSuffix(relPath, "/subtitles") {
h.serveSubtitles(strings.TrimSuffix(relPath, "/subtitles"), w, r)
return
}
http.ServeFile(w, r, media.Filepath)
h.serveMedia(relPath, w, r)
}
func (h *Handler) ServeThumbnail(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
if filename == "" {
http.Error(w, "Missing file", http.StatusBadRequest)
return
}
thumbPath, err := h.mediaSvc.ExtractThumbnail(id)
mediaPath, err := h.librarySvc.GetMediaFile(relPath, filename)
if err != nil {
// Serve fallback icon instead of 404 so <img> always has a valid source
media, mediaErr := h.mediaSvc.GetByID(id)
if mediaErr == nil && media.IsAudio {
http.ServeFile(w, r, "web/static/icons/audio-icon.svg")
} else {
http.ServeFile(w, r, "web/static/icons/video-icon.svg")
}
http.Error(w, "Not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, thumbPath)
http.ServeFile(w, r, mediaPath)
}
func (h *Handler) ServeSubtitles(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
func (h *Handler) serveThumbnail(relPath string, w http.ResponseWriter, r *http.Request) {
thumbs, err := h.librarySvc.ListThumbnails(relPath)
if err != nil || len(thumbs) == 0 {
icon := "video-icon.svg"
item, itemErr := h.librarySvc.GetByRelPath(relPath)
if itemErr == nil && len(item.MediaFiles) > 0 && item.MediaFiles[0].IsAudio {
icon = "audio-icon.svg"
}
http.ServeFile(w, r, filepath.Join(repoRoot, "web", "static", "icons", icon))
return
}
lang := chi.URLParam(r, "lang")
http.ServeFile(w, r, thumbs[0])
}
func (h *Handler) serveSubtitles(relPath string, w http.ResponseWriter, r *http.Request) {
lang := r.URL.Query().Get("lang")
if lang == "" {
http.Error(w, "Missing language", http.StatusBadRequest)
return
}
// Validate language to prevent path traversal
if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") {
http.Error(w, "Invalid language", http.StatusBadRequest)
return
}
subtitlePath := h.mediaSvc.GetSubtitlePath(id, lang)
subtitlePath := h.librarySvc.GetSubtitlePath(relPath, lang)
w.Header().Set("Content-Type", "text/vtt")
http.ServeFile(w, r, subtitlePath)
}
// Queue handlers
func (h *Handler) deleteMedia(relPath string, w http.ResponseWriter, r *http.Request) {
if err := h.librarySvc.Delete(relPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/library", http.StatusSeeOther)
}
func (h *Handler) Downloads(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
sortBy := r.URL.Query().Get("sort")
@@ -448,7 +491,6 @@ func (h *Handler) ClearAllDownloads(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/queue", http.StatusSeeOther)
}
// Settings handlers
func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
presets, err := h.presetSvc.GetAll()
if err != nil {
@@ -482,16 +524,15 @@ func (h *Handler) CreatePreset(w http.ResponseWriter, r *http.Request) {
}
preset := &models.Preset{
Name: r.FormValue("name"),
Description: r.FormValue("description"),
FormatMode: r.FormValue("format_mode"),
Format: r.FormValue("format"),
Quality: r.FormValue("quality"),
CustomFormat: r.FormValue("custom_format"),
AudioFormat: r.FormValue("audio_format"),
SubLangs: r.FormValue("sub_langs"),
OutputTemplate: r.FormValue("output_template"),
CustomFlags: r.FormValue("custom_flags"),
Name: r.FormValue("name"),
Description: r.FormValue("description"),
FormatMode: r.FormValue("format_mode"),
Format: r.FormValue("format"),
Quality: r.FormValue("quality"),
CustomFormat: r.FormValue("custom_format"),
AudioFormat: r.FormValue("audio_format"),
SubLangs: r.FormValue("sub_langs"),
CustomFlags: r.FormValue("custom_flags"),
}
preset.IsDefault = r.FormValue("is_default") == "1"
@@ -536,7 +577,6 @@ func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) {
preset.CustomFormat = r.FormValue("custom_format")
preset.AudioFormat = r.FormValue("audio_format")
preset.SubLangs = r.FormValue("sub_langs")
preset.OutputTemplate = r.FormValue("output_template")
preset.CustomFlags = r.FormValue("custom_flags")
preset.IsDefault = r.FormValue("is_default") == "1"
preset.ExtractAudio = r.FormValue("extract_audio") == "1"
@@ -586,7 +626,6 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// Download form page
func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
presets, err := h.presetSvc.GetAll()
if err != nil {
@@ -627,7 +666,6 @@ func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
})
}
// API handlers for HTMX-style interactions
func (h *Handler) GetPresetFlags(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseInt(idStr, 10, 64)
@@ -647,7 +685,6 @@ func (h *Handler) GetPresetFlags(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(flags))
}
// Theme handler
func (h *Handler) Theme(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -661,36 +698,9 @@ func (h *Handler) Theme(w http.ResponseWriter, r *http.Request) {
setCookie(w, "theme", theme)
// Redirect back to the referring page, or home
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
}
// Scan library
func (h *Handler) ScanLibrary(w http.ResponseWriter, r *http.Request) {
if err := h.mediaSvc.ScanLibrary(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/library", http.StatusSeeOther)
}
// Delete media
func (h *Handler) DeleteMedia(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
if err := h.mediaSvc.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/library", http.StatusSeeOther)
}
Minternal/models/models.go
@@ -21,25 +21,26 @@ type Preset struct {
EmbedThumbnail bool
EmbedMetadata bool
WriteInfoJSON bool
OutputTemplate string
CustomFlags string
CreatedAt time.Time
}
type Media struct {
ID int64
URL string
Title string
Description string
Duration int
type LibraryItem struct {
Name string
RelPath string
DirPath string
SourceURL string
Duration int
Description string
MediaFiles []MediaFile
}
type MediaFile struct {
Filename string
Filepath string
RelativePath string
IsAudio bool
HasEmbeddedThumbnail bool
InfoJSONPath string
PlaylistID sql.NullString
PlaylistIndex sql.NullInt64
CreatedAt time.Time
Duration int
}
type Download struct {
@@ -65,14 +66,14 @@ type Settings struct {
}
type FormatInfo struct {
ID string
Ext string
ID string
Ext string
Resolution string
FPS string
Channels string
FileSize string
Codec string
Note string
FPS string
Channels string
FileSize string
Codec string
Note string
}
type SubtitleTrack struct {
@@ -80,3 +81,11 @@ type SubtitleTrack struct {
Label string
Src string
}
type ItemMetadata struct {
Name string `toml:"name"`
SourceURL string `toml:"source_url"`
Duration int `toml:"duration"`
Description string `toml:"description"`
FileDurations map[string]int `toml:"file_durations"`
}
Dinternal/repository/media.go-149
@@ -1,149 +0,0 @@
package repository
import (
"database/sql"
"strings"
"vidarchive/internal/models"
)
type MediaRepository struct {
db *sql.DB
}
func NewMediaRepository(db *sql.DB) *MediaRepository {
return &MediaRepository{db: db}
}
func (r *MediaRepository) Create(m *models.Media) error {
result, err := r.db.Exec(
`INSERT INTO media (url, title, description, duration, filepath, relative_path, is_audio, has_embedded_thumbnail, info_json_path, playlist_id, playlist_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.URL, m.Title, m.Description, m.Duration, m.Filepath, m.RelativePath,
boolToInt(m.IsAudio), boolToInt(m.HasEmbeddedThumbnail), m.InfoJSONPath,
m.PlaylistID, m.PlaylistIndex,
)
if err != nil {
return err
}
m.ID, _ = result.LastInsertId()
return nil
}
func (r *MediaRepository) GetByID(id int64) (*models.Media, error) {
row := r.db.QueryRow(
`SELECT id, url, title, description, duration, filepath, relative_path, is_audio, has_embedded_thumbnail, info_json_path, playlist_id, playlist_index, created_at
FROM media WHERE id = ?`, id,
)
return scanMedia(row)
}
func (r *MediaRepository) GetAll(path string, sortBy, filter string) ([]*models.Media, error) {
query := `SELECT id, url, title, description, duration, filepath, relative_path, is_audio, has_embedded_thumbnail, info_json_path, playlist_id, playlist_index, created_at
FROM media WHERE relative_path LIKE ?`
args := []interface{}{path + "%"}
// For non-root paths, restrict to immediate items (not nested inside subfolders).
// For root path, only show media not inside any folder.
if path != "" {
query += ` AND relative_path NOT LIKE ?`
args = append(args, path+"%/%")
} else {
query += ` AND relative_path NOT LIKE ?`
args = append(args, "%/%")
}
if filter != "" {
query += ` AND (title LIKE ? OR description LIKE ?)`
args = append(args, "%"+filter+"%", "%"+filter+"%")
}
switch sortBy {
case "title":
query += ` ORDER BY title`
case "date":
query += ` ORDER BY created_at DESC`
case "duration":
query += ` ORDER BY duration DESC`
default:
query += ` ORDER BY created_at DESC`
}
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var media []*models.Media
for rows.Next() {
m, err := scanMedia(rows)
if err != nil {
return nil, err
}
media = append(media, m)
}
return media, rows.Err()
}
func (r *MediaRepository) GetByRelativePath(relPath string) (*models.Media, error) {
row := r.db.QueryRow(
`SELECT id, url, title, description, duration, filepath, relative_path, is_audio, has_embedded_thumbnail, info_json_path, playlist_id, playlist_index, created_at
FROM media WHERE relative_path = ?`, relPath,
)
return scanMedia(row)
}
func (r *MediaRepository) Delete(id int64) error {
_, err := r.db.Exec(`DELETE FROM media WHERE id = ?`, id)
return err
}
func (r *MediaRepository) GetFolders(parentPath string) ([]string, error) {
rows, err := r.db.Query(
`SELECT relative_path FROM media WHERE relative_path LIKE ?`,
parentPath+"%",
)
if err != nil {
return nil, err
}
defer rows.Close()
folders := make(map[string]bool)
for rows.Next() {
var relPath string
if err := rows.Scan(&relPath); err != nil {
return nil, err
}
// Get the folder part after parentPath
remaining := strings.TrimPrefix(relPath, parentPath)
remaining = strings.TrimPrefix(remaining, "/")
if idx := strings.Index(remaining, "/"); idx >= 0 {
folder := remaining[:idx]
if folder != "" {
folders[folder] = true
}
}
}
var result []string
for f := range folders {
result = append(result, f)
}
return result, rows.Err()
}
func scanMedia(row interface{ Scan(...interface{}) error }) (*models.Media, error) {
var m models.Media
var isAudio, hasThumb int
err := row.Scan(
&m.ID, &m.URL, &m.Title, &m.Description, &m.Duration, &m.Filepath,
&m.RelativePath, &isAudio, &hasThumb, &m.InfoJSONPath,
&m.PlaylistID, &m.PlaylistIndex, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.IsAudio = isAudio == 1
m.HasEmbeddedThumbnail = hasThumb == 1
return &m, nil
}
Minternal/repository/preset.go
@@ -16,12 +16,12 @@ func NewPresetRepository(db *sql.DB) *PresetRepository {
func (r *PresetRepository) Create(p *models.Preset) error {
result, err := r.db.Exec(
`INSERT INTO presets (name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, output_template, custom_flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO presets (name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.Description, boolToInt(p.IsDefault), p.FormatMode, p.Format, p.Quality, p.CustomFormat,
boolToInt(p.ExtractAudio), p.AudioFormat, boolToInt(p.EmbedSubs), p.SubLangs,
boolToInt(p.EmbedThumbnail), boolToInt(p.EmbedMetadata), boolToInt(p.WriteInfoJSON),
p.OutputTemplate, p.CustomFlags,
p.CustomFlags,
)
if err != nil {
return err
@@ -32,7 +32,7 @@ func (r *PresetRepository) Create(p *models.Preset) error {
func (r *PresetRepository) GetByID(id int64) (*models.Preset, error) {
row := r.db.QueryRow(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, output_template, custom_flags, created_at
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags, created_at
FROM presets WHERE id = ?`, id,
)
return scanPreset(row)
@@ -40,7 +40,7 @@ func (r *PresetRepository) GetByID(id int64) (*models.Preset, error) {
func (r *PresetRepository) GetAll() ([]*models.Preset, error) {
rows, err := r.db.Query(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, output_template, custom_flags, created_at
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags, created_at
FROM presets ORDER BY name`,
)
if err != nil {
@@ -61,7 +61,7 @@ func (r *PresetRepository) GetAll() ([]*models.Preset, error) {
func (r *PresetRepository) GetDefault() (*models.Preset, error) {
row := r.db.QueryRow(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, output_template, custom_flags, created_at
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags, created_at
FROM presets WHERE is_default = 1 LIMIT 1`,
)
return scanPreset(row)
@@ -69,12 +69,12 @@ func (r *PresetRepository) GetDefault() (*models.Preset, error) {
func (r *PresetRepository) Update(p *models.Preset) error {
_, err := r.db.Exec(
`UPDATE presets SET name=?, description=?, is_default=?, format_mode=?, format=?, quality=?, custom_format=?, extract_audio=?, audio_format=?, embed_subs=?, sub_langs=?, embed_thumbnail=?, embed_metadata=?, write_info_json=?, output_template=?, custom_flags=?
`UPDATE presets SET name=?, description=?, is_default=?, format_mode=?, format=?, quality=?, custom_format=?, extract_audio=?, audio_format=?, embed_subs=?, sub_langs=?, embed_thumbnail=?, embed_metadata=?, write_info_json=?, custom_flags=?
WHERE id=?`,
p.Name, p.Description, boolToInt(p.IsDefault), p.FormatMode, p.Format, p.Quality, p.CustomFormat,
boolToInt(p.ExtractAudio), p.AudioFormat, boolToInt(p.EmbedSubs), p.SubLangs,
boolToInt(p.EmbedThumbnail), boolToInt(p.EmbedMetadata), boolToInt(p.WriteInfoJSON),
p.OutputTemplate, p.CustomFlags, p.ID,
p.CustomFlags, p.ID,
)
return err
}
@@ -92,12 +92,12 @@ func (r *PresetRepository) ClearDefault() error {
func scanPreset(row interface{ Scan(...interface{}) error }) (*models.Preset, error) {
var p models.Preset
var isDefault, extractAudio, embedSubs, embedThumbnail, embedMetadata, writeInfoJSON int
var description, formatMode, format, quality, customFormat, audioFormat, subLangs, outputTemplate, customFlags sql.NullString
var description, formatMode, format, quality, customFormat, audioFormat, subLangs, customFlags sql.NullString
err := row.Scan(
&p.ID, &p.Name, &description, &isDefault, &formatMode, &format, &quality, &customFormat,
&extractAudio, &audioFormat, &embedSubs, &subLangs,
&embedThumbnail, &embedMetadata, &writeInfoJSON,
&outputTemplate, &customFlags, &p.CreatedAt,
&customFlags, &p.CreatedAt,
)
if err != nil {
return nil, err
@@ -109,7 +109,6 @@ func scanPreset(row interface{ Scan(...interface{}) error }) (*models.Preset, er
p.CustomFormat = customFormat.String
p.AudioFormat = audioFormat.String
p.SubLangs = subLangs.String
p.OutputTemplate = outputTemplate.String
p.CustomFlags = customFlags.String
p.IsDefault = isDefault == 1
p.ExtractAudio = extractAudio == 1
Minternal/server/server.go
@@ -3,6 +3,8 @@ package server
import (
"fmt"
"net/http"
"path/filepath"
"runtime"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -28,27 +30,27 @@ func New(cfg *config.Config, h *handler.Handler) *Server {
}
func (s *Server) setupRoutes() {
// Middleware
_, file, _, ok := runtime.Caller(0)
staticRoot := "web/static"
if ok {
root := filepath.Join(filepath.Dir(file), "..", "..")
staticRoot = filepath.Join(root, "web", "static")
}
s.router.Use(middleware.Logger)
s.router.Use(middleware.Recoverer)
s.router.Use(s.securityHeaders)
// Static files
s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(staticRoot))))
// Media serving (with range request support via http.ServeFile)
s.router.Get("/media/{id}", s.handler.ServeMedia)
s.router.Get("/media/{id}/thumbnail", s.handler.ServeThumbnail)
s.router.Get("/media/{id}/subtitles/{lang}", s.handler.ServeSubtitles)
s.router.Get("/media/item/*", s.handler.ServeMediaItem)
// Pages
s.router.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/library", http.StatusSeeOther)
})
s.router.Get("/library", s.handler.Library)
s.router.Get("/library/{id}", s.handler.LibraryDetail)
s.router.Post("/library/scan", s.handler.ScanLibrary)
s.router.Post("/library/{id}/delete", s.handler.DeleteMedia)
s.router.Get("/library/item/*", s.handler.LibraryItem)
s.router.Post("/library/item/*", s.handler.LibraryItem)
s.router.Get("/queue", s.handler.Downloads)
s.router.Get("/queue/{id}", s.handler.DownloadDetail)
@@ -66,7 +68,6 @@ func (s *Server) setupRoutes() {
s.router.Post("/theme", s.handler.Theme)
// API endpoints
s.router.Get("/api/presets/{id}/flags", s.handler.GetPresetFlags)
}
@@ -86,6 +87,10 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
})
}
func (s *Server) Router() http.Handler {
return s.router
}
func (s *Server) Start() error {
addr := fmt.Sprintf(":%d", s.cfg.Port)
fmt.Printf("Starting server on %s\n", addr)
Ainternal/server/server_test.go
@@ -0,0 +1,219 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"vidarchive/internal/config"
"vidarchive/internal/database"
"vidarchive/internal/handler"
"vidarchive/internal/repository"
"vidarchive/internal/service"
"vidarchive/internal/worker"
)
func setupTestServer(t *testing.T) (*Server, *config.Config, func()) {
t.Helper()
dataDir := t.TempDir()
t.Setenv("VIDARCHIVE_DATA_DIR", dataDir)
cfg := config.New()
if err := os.MkdirAll(cfg.LibraryDir, 0755); err != nil {
t.Fatalf("create library dir: %v", err)
}
if err := os.MkdirAll(cfg.TempDir, 0755); err != nil {
t.Fatalf("create temp dir: %v", err)
}
db, err := database.New(cfg)
if err != nil {
t.Fatalf("init db: %v", err)
}
presetRepo := repository.NewPresetRepository(db)
downloadRepo := repository.NewDownloadRepository(db)
settingsRepo := repository.NewSettingsRepository(db)
presetSvc := service.NewPresetService(presetRepo)
librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.TempDir)
settingsSvc := service.NewSettingsService(settingsRepo)
downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, cfg)
workerPool := worker.New(downloadSvc, cfg.Workers)
h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, workerPool)
if err != nil {
t.Fatalf("init handler: %v", err)
}
srv := New(cfg, h)
cleanup := func() {
workerPool.Stop()
db.Close()
}
return srv, cfg, cleanup
}
func createItem(t *testing.T, libraryDir, relPath, name string, files map[string]string) {
t.Helper()
itemDir := filepath.Join(libraryDir, relPath)
if err := os.MkdirAll(itemDir, 0755); err != nil {
t.Fatalf("create item dir: %v", err)
}
marker := filepath.Join(itemDir, ".vidarchive-item.toml")
if err := os.WriteFile(marker, []byte("name = \""+name+"\"\nduration = -1\n"), 0644); err != nil {
t.Fatalf("write marker: %v", err)
}
for filename, content := range files {
path := filepath.Join(itemDir, filename)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write file %s: %v", filename, err)
}
}
}
func TestNestedLibraryItem(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "test/My Item [id]", "My Item", map[string]string{
"My Item [id].mp4": "dummy video",
})
router := srv.Router()
tests := []string{
"/library/item/test/My%20Item%20%5Bid%5D",
"/library/item/test/My+Item+%5Bid%5D",
}
for _, path := range tests {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Errorf("%s: expected 200, got %d: %s", path, w.Code, string(body))
}
}
}
func TestMediaFileQueryDecoding(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "My Item [id]", "My Item", map[string]string{
"My Item [id].mp4": "dummy video",
"My+Other.mp4": "dummy video plus",
})
router := srv.Router()
tests := []struct {
path string
expected int
}{
{"/media/item/My%20Item%20%5Bid%5D?file=My+Item+%5Bid%5D.mp4", http.StatusOK},
{"/media/item/My%20Item%20%5Bid%5D?file=My%20Item%20%5Bid%5D.mp4", http.StatusOK},
{"/media/item/My%20Item%20%5Bid%5D?file=My%2BOther.mp4", http.StatusOK},
{"/media/item/My%20Item%20%5Bid%5D?file=missing.mp4", http.StatusNotFound},
}
for _, tc := range tests {
req := httptest.NewRequest("GET", tc.path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tc.expected {
body, _ := io.ReadAll(w.Body)
t.Errorf("%s: expected %d, got %d: %s", tc.path, tc.expected, w.Code, string(body))
}
}
}
func TestPathTraversalBlocked(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
outside := filepath.Join(cfg.DataDir, "secret")
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatalf("create outside dir: %v", err)
}
marker := filepath.Join(outside, ".vidarchive-item.toml")
if err := os.WriteFile(marker, []byte("name = \"secret\"\nduration = -1\n"), 0644); err != nil {
t.Fatalf("write marker: %v", err)
}
router := srv.Router()
req := httptest.NewRequest("GET", "/library/item/../secret", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for path traversal, got %d", w.Code)
}
}
func TestExistingImageThumbnail(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "thumb-item", "Thumb Item", map[string]string{
"cover.jpg": "fake image",
"video.mp4": "dummy video",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/media/item/thumb-item/thumbnail", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
}
if body, _ := io.ReadAll(w.Body); string(body) != "fake image" {
t.Errorf("expected cover.jpg contents, got %q", string(body))
}
}
func TestAudioThumbnailPlaceholder(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "audio-item", "Audio Item", map[string]string{
"song.mp3": "dummy audio",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/media/item/audio-item/thumbnail", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
}
body, _ := io.ReadAll(w.Body)
if len(body) == 0 {
t.Errorf("placeholder thumbnail body was empty")
}
}
func TestLibraryPageIsFast(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
for i := 0; i < 50; i++ {
createItem(t, cfg.LibraryDir, "item-"+string(rune('a'+i)), "Item", map[string]string{
"video.mp4": "dummy",
})
}
router := srv.Router()
req := httptest.NewRequest("GET", "/library", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
}
}
Minternal/service/download.go
@@ -3,15 +3,19 @@ package service
import (
"bufio"
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/BurntSushi/toml"
"github.com/gabriel-vasile/mimetype"
"vidarchive/internal/config"
@@ -21,7 +25,7 @@ import (
type DownloadService struct {
repo *repository.DownloadRepository
mediaRepo *repository.MediaRepository
librarySvc *LibraryService
presetSvc *PresetService
settingsSvc *SettingsService
cfg *config.Config
@@ -30,10 +34,10 @@ type DownloadService struct {
processes map[int64]*os.Process
}
func NewDownloadService(repo *repository.DownloadRepository, mediaRepo *repository.MediaRepository, presetSvc *PresetService, settingsSvc *SettingsService, cfg *config.Config) *DownloadService {
func NewDownloadService(repo *repository.DownloadRepository, librarySvc *LibraryService, presetSvc *PresetService, settingsSvc *SettingsService, cfg *config.Config) *DownloadService {
return &DownloadService{
repo: repo,
mediaRepo: mediaRepo,
librarySvc: librarySvc,
presetSvc: presetSvc,
settingsSvc: settingsSvc,
cfg: cfg,
@@ -62,7 +66,6 @@ func (s *DownloadService) Create(url string, presetID *int64, formatOverride, cu
}
func (s *DownloadService) GetByID(id int64) (*models.Download, error) {
// Check cache first for live logs
if live, ok := s.cache.Get(id); ok {
d, err := s.repo.GetByID(id)
if err != nil {
@@ -83,7 +86,6 @@ func (s *DownloadService) GetAll(status, sortBy string) ([]*models.Download, err
return nil, err
}
// Overlay live logs from cache
for _, d := range downloads {
if live, ok := s.cache.Get(d.ID); ok {
logs := live.Logs.String()
@@ -116,8 +118,6 @@ func (s *DownloadService) killProcess(id int64) {
return
}
// Kill the process group (negative PID kills the group on Linux)
// The process group was created by Setpgid in ExecuteDownload
_ = syscall.Kill(-proc.Pid, syscall.SIGKILL)
}
@@ -144,10 +144,7 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
return err
}
// Initialize in-memory cache for logs
s.cache.Set(d.ID, &LiveDownload{
LastUpdate: time.Now(),
})
s.cache.Set(d.ID, &LiveDownload{LastUpdate: time.Now()})
defer s.cache.Delete(d.ID)
var preset *models.Preset
@@ -163,36 +160,16 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
}
if preset == nil {
preset = &models.Preset{
OutputTemplate: "%(title)s.%(ext)s",
}
preset = &models.Preset{}
}
// Build output path with optional subdirectory
downloadDir := s.cfg.DownloadDir
if d.OutputDir.Valid && d.OutputDir.String != "" {
// Sanitize and validate output directory
cleanDir := filepath.Clean(d.OutputDir.String)
// Prevent path traversal: ensure the cleaned path doesn't escape the download dir
fullPath := filepath.Join(downloadDir, cleanDir)
resolvedPath, err := filepath.Abs(fullPath)
if err != nil {
return fmt.Errorf("invalid output directory: %w", err)
}
resolvedDownloadDir, _ := filepath.Abs(downloadDir)
if !strings.HasPrefix(resolvedPath, resolvedDownloadDir+string(filepath.Separator)) && resolvedPath != resolvedDownloadDir {
return fmt.Errorf("invalid output directory: path traversal attempt detected")
}
downloadDir = fullPath
// Ensure directory exists
if err := os.MkdirAll(downloadDir, 0755); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
tempDownloadDir := filepath.Join(s.cfg.TempDir, fmt.Sprintf("%d", d.ID))
if err := os.MkdirAll(tempDownloadDir, 0755); err != nil {
return fmt.Errorf("create temp download dir: %w", err)
}
args := s.presetSvc.BuildArgs(preset, d.FormatOverride, d.CustomFlags)
// Write cookies to temp file if configured
cookies, err := s.settingsSvc.GetCookies()
if err == nil && strings.TrimSpace(cookies) != "" {
tmpFile, err := os.CreateTemp("", "cookies-*.txt")
@@ -204,7 +181,8 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
}
}
args = append(args, "-P", downloadDir)
args = append(args, "-P", tempDownloadDir)
args = append(args, "-o", "item-%(autonumber)05d/%(title)s.%(ext)s")
args = append(args, d.URL)
cmd := exec.Command(s.cfg.YTDLPPath, args...)
@@ -222,7 +200,6 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
return err
}
// Track the process so it can be killed on delete
s.processMu.Lock()
s.processes[d.ID] = cmd.Process
s.processMu.Unlock()
@@ -233,7 +210,7 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
}()
scanner := bufio.NewScanner(stdout)
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
done := make(chan struct{})
@@ -258,7 +235,6 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
close(done)
// Final log flush
logs := s.cache.FlushLogs(d.ID)
if logs != "" {
s.repo.UpdateLogs(d.ID, logs)
@@ -269,13 +245,13 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
return err
}
// Mark as completed
if err := s.repo.MarkCompleted(d.ID, "completed"); err != nil {
return err
}
// Scan for new media files
s.scanForNewMedia(d.URL, downloadDir)
if err := s.importDownloadedItems(d, tempDownloadDir); err != nil {
log.Printf("Download %d completed but import failed: %v", d.ID, err)
}
return nil
}
@@ -288,87 +264,225 @@ func (s *DownloadService) finalizeError(id int64, err error) {
s.repo.MarkError(id, err.Error())
}
func (s *DownloadService) scanForNewMedia(url string, downloadDir string) {
// Give filesystem a moment
time.Sleep(100 * time.Millisecond)
// Walk download dir and add any new files
filepath.Walk(downloadDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
func (s *DownloadService) importDownloadedItems(d *models.Download, tempDownloadDir string) error {
entries, err := os.ReadDir(tempDownloadDir)
if err != nil {
return err
}
baseLibraryDir := s.cfg.LibraryDir
if d.OutputDir.Valid && d.OutputDir.String != "" {
cleanDir := filepath.Clean(d.OutputDir.String)
fullPath := filepath.Join(baseLibraryDir, cleanDir)
resolvedPath, err := filepath.Abs(fullPath)
if err != nil {
return fmt.Errorf("invalid output directory: %w", err)
}
resolvedLibraryDir, _ := filepath.Abs(baseLibraryDir)
if !strings.HasPrefix(resolvedPath, resolvedLibraryDir+string(filepath.Separator)) && resolvedPath != resolvedLibraryDir {
return fmt.Errorf("invalid output directory: path traversal attempt detected")
}
baseLibraryDir = fullPath
}
if err := os.MkdirAll(baseLibraryDir, 0755); err != nil {
return err
}
var itemDirs []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, "item-") {
itemDirs = append(itemDirs, filepath.Join(tempDownloadDir, name))
}
}
sort.Strings(itemDirs)
relPath, _ := filepath.Rel(s.cfg.DownloadDir, path)
relPath = filepath.ToSlash(relPath)
for _, itemDir := range itemDirs {
if err := s.importItemDir(d.URL, itemDir, baseLibraryDir); err != nil {
log.Printf("warning: failed to import item %s: %v", itemDir, err)
}
}
_, err = s.mediaRepo.GetByRelativePath(relPath)
if err == nil {
return nil // already exists
os.Remove(tempDownloadDir)
return nil
}
func (s *DownloadService) importItemDir(url, itemDir, baseLibraryDir string) error {
entries, err := os.ReadDir(itemDir)
if err != nil {
return err
}
var mediaFiles []os.DirEntry
var infoJSONPath string
var subtitleFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
path := filepath.Join(itemDir, name)
ext := strings.ToLower(filepath.Ext(name))
if name == "info.json" || strings.HasSuffix(name, ".info.json") {
infoJSONPath = path
continue
}
if ext == ".vtt" || ext == ".srt" || ext == ".ass" || ext == ".ssa" {
subtitleFiles = append(subtitleFiles, path)
continue
}
// Use content-based MIME detection
mtype, err := mimetype.DetectFile(path)
if err != nil {
return nil
if err == nil && mtype != nil && (strings.HasPrefix(mtype.String(), "audio/") || strings.HasPrefix(mtype.String(), "video/")) {
mediaFiles = append(mediaFiles, entry)
}
}
isAudio := mtype != nil && strings.HasPrefix(mtype.String(), "audio/")
isVideo := mtype != nil && strings.HasPrefix(mtype.String(), "video/")
if len(mediaFiles) == 0 {
return fmt.Errorf("no media files found in %s", itemDir)
}
name := s.deriveItemName(itemDir, infoJSONPath, mediaFiles)
targetDir := s.uniqueDir(baseLibraryDir, name)
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
}
if !isAudio && !isVideo {
return nil
if infoJSONPath != "" {
if err := os.Rename(infoJSONPath, filepath.Join(targetDir, "info.json")); err != nil {
return err
}
}
ext := strings.ToLower(filepath.Ext(path))
basePath := strings.TrimSuffix(path, ext)
infoJSONPath := basePath + ".info.json"
if _, err := os.Stat(infoJSONPath); err != nil {
infoJSONPath = ""
for _, entry := range mediaFiles {
if err := os.Rename(filepath.Join(itemDir, entry.Name()), filepath.Join(targetDir, entry.Name())); err != nil {
return err
}
}
media := &models.Media{
URL: url,
Filepath: path,
RelativePath: relPath,
IsAudio: isAudio,
HasEmbeddedThumbnail: true,
InfoJSONPath: infoJSONPath,
Title: strings.TrimSuffix(filepath.Base(path), ext),
Duration: extractDuration(infoJSONPath, path),
if len(subtitleFiles) > 0 {
subtitlesDir := filepath.Join(targetDir, subtitlesDirName)
if err := os.MkdirAll(subtitlesDir, 0755); err != nil {
return err
}
for _, sf := range subtitleFiles {
if err := os.Rename(sf, filepath.Join(subtitlesDir, filepath.Base(sf))); err != nil {
return err
}
}
}
s.mediaRepo.Create(media)
return nil
metadata := models.ItemMetadata{
Name: name,
SourceURL: url,
Duration: -1,
}
markerPath := filepath.Join(targetDir, itemMarkerName)
f, err := os.Create(markerPath)
if err != nil {
return err
}
defer f.Close()
if err := toml.NewEncoder(f).Encode(metadata); err != nil {
return err
}
return nil
}
func (s *DownloadService) deriveItemName(itemDir, infoJSONPath string, mediaFiles []os.DirEntry) string {
if infoJSONPath != "" {
data, err := os.ReadFile(infoJSONPath)
if err == nil {
var info struct {
Title string `json:"title"`
}
if err := json.Unmarshal(data, &info); err == nil && info.Title != "" {
return sanitizeDirName(info.Title)
}
}
}
sort.Slice(mediaFiles, func(i, j int) bool {
ii, _ := os.Stat(filepath.Join(itemDir, mediaFiles[i].Name()))
jj, _ := os.Stat(filepath.Join(itemDir, mediaFiles[j].Name()))
if ii == nil || jj == nil {
return false
}
return ii.Size() > jj.Size()
})
base := strings.TrimSuffix(mediaFiles[0].Name(), filepath.Ext(mediaFiles[0].Name()))
return sanitizeDirName(base)
}
func (s *DownloadService) uniqueDir(base, name string) string {
dir := filepath.Join(base, name)
if _, err := os.Stat(dir); os.IsNotExist(err) {
return dir
}
for i := 1; ; i++ {
candidate := fmt.Sprintf("%s-%d", dir, i)
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
}
func sanitizeDirName(name string) string {
name = strings.TrimSpace(name)
replacer := strings.NewReplacer(
"/", "-",
"\\", "-",
":", "-",
"*", "-",
"?", "-",
"\"", "-",
"<", "-",
">", "-",
"|", "-",
)
name = replacer.Replace(name)
name = strings.TrimSpace(name)
if name == "" {
name = "untitled"
}
return name
}
func parseFormatList(output string) []*models.FormatInfo {
lines := strings.Split(output, "\n")
var formats []*models.FormatInfo
inFormats := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.Contains(line, "ID") && strings.Contains(line, "EXT") {
inFormats = true
continue
}
if !inFormats {
continue
}
parts := strings.Fields(line)
if len(parts) >= 4 {
format := &models.FormatInfo{
ID: parts[0],
Ext: parts[1],
ID: parts[0],
Ext: parts[1],
}
for i, part := range parts {
if strings.Contains(part, "x") && !strings.Contains(part, "http") {
format.Resolution = part
@@ -378,15 +492,15 @@ func parseFormatList(output string) []*models.FormatInfo {
break
}
}
if len(parts) > 3 {
format.Note = strings.Join(parts[3:], " ")
}
formats = append(formats, format)
}
}
return formats
}
Ainternal/service/library.go
@@ -0,0 +1,922 @@
package service
import (
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"github.com/BurntSushi/toml"
"vidarchive/internal/models"
)
const itemMarkerName = ".vidarchive-item.toml"
const subtitlesDirName = "subtitles"
var mediaExts = map[string]bool{
".mp4": true, ".webm": true, ".mkv": true, ".avi": true, ".mov": true,
".mp3": true, ".wav": true, ".flac": true, ".aac": true, ".opus": true,
}
var imageExts = map[string]bool{
".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true,
}
type LibraryService struct {
libraryDir string
tempDir string
thumbOnce sync.Map
}
func NewLibraryService(libraryDir, tempDir string) *LibraryService {
return &LibraryService{libraryDir: libraryDir, tempDir: tempDir}
}
func (s *LibraryService) resolveItemDir(relPath string) (string, error) {
relPath = strings.Trim(relPath, string(filepath.Separator))
if relPath == "" {
return s.libraryDir, nil
}
itemDir := filepath.Join(s.libraryDir, relPath)
cleanDir, err := filepath.EvalSymlinks(itemDir)
if err != nil {
cleanDir = filepath.Clean(itemDir)
}
base, err := filepath.EvalSymlinks(s.libraryDir)
if err != nil {
base = filepath.Clean(s.libraryDir)
}
if !strings.HasPrefix(cleanDir, base+string(filepath.Separator)) && cleanDir != base {
return "", fmt.Errorf("invalid path")
}
return itemDir, nil
}
func (s *LibraryService) GetAll(path, sortBy, filter string) ([]*models.LibraryItem, []string, error) {
if path != "" && !strings.HasSuffix(path, "/") {
path += "/"
}
dir, err := s.resolveItemDir(path)
if err != nil {
return nil, nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, nil
}
return nil, nil, err
}
var items []*models.LibraryItem
var folders []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if name == subtitlesDirName {
continue
}
itemDir := filepath.Join(dir, name)
relPath := filepath.ToSlash(filepath.Join(path, name))
markerPath := filepath.Join(itemDir, itemMarkerName)
if _, err := os.Stat(markerPath); err == nil {
item, err := s.scanItem(itemDir, relPath)
if err != nil {
log.Printf("warning: failed to scan item %s: %v", relPath, err)
continue
}
if filter != "" && !strings.Contains(strings.ToLower(item.Name), strings.ToLower(filter)) && !strings.Contains(strings.ToLower(item.RelPath), strings.ToLower(filter)) {
continue
}
items = append(items, item)
} else {
folders = append(folders, name)
}
}
switch sortBy {
case "title":
sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) })
case "duration":
sort.Slice(items, func(i, j int) bool { return items[i].Duration > items[j].Duration })
case "date":
fallthrough
default:
sort.Slice(items, func(i, j int) bool {
ii, _ := os.Stat(filepath.Join(s.libraryDir, items[i].RelPath))
jj, _ := os.Stat(filepath.Join(s.libraryDir, items[j].RelPath))
if ii == nil || jj == nil {
return false
}
return ii.ModTime().After(jj.ModTime())
})
}
sort.Strings(folders)
return items, folders, nil
}
func (s *LibraryService) GetByRelPath(relPath string) (*models.LibraryItem, error) {
relPath = strings.Trim(relPath, "/")
itemDir, err := s.resolveItemDir(relPath)
if err != nil {
return nil, fmt.Errorf("item not found")
}
markerPath := filepath.Join(itemDir, itemMarkerName)
if _, err := os.Stat(markerPath); err != nil {
return nil, fmt.Errorf("item not found")
}
return s.scanItem(itemDir, relPath)
}
func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem, error) {
metadata, err := s.readOrCreateMetadata(itemDir)
if err != nil {
return nil, err
}
mediaFiles, imageFiles, infoJSONPath, err := s.listItemFiles(itemDir)
if err != nil {
return nil, err
}
var info map[string]interface{}
if infoJSONPath != "" {
data, err := os.ReadFile(infoJSONPath)
if err == nil {
_ = json.Unmarshal(data, &info)
}
}
if metadata.Name == "" {
if title, ok := infoString(info, "title"); ok && title != "" {
metadata.Name = title
} else if len(mediaFiles) > 0 {
metadata.Name = mediaFileStem(mediaFiles[0])
} else {
metadata.Name = filepath.Base(itemDir)
}
}
if metadata.SourceURL == "" {
if url, ok := infoString(info, "webpage_url"); ok {
metadata.SourceURL = url
} else if url, ok := infoString(info, "url"); ok {
metadata.SourceURL = url
}
}
if metadata.Description == "" {
if desc, ok := infoString(info, "description"); ok {
metadata.Description = desc
}
}
if metadata.FileDurations == nil {
metadata.FileDurations = make(map[string]int)
}
if metadata.Duration < 0 {
if duration, ok := infoDuration(info); ok && duration > 0 {
metadata.Duration = duration
}
}
for i := range mediaFiles {
mf := &mediaFiles[i]
if mf.Duration < 0 {
if d, ok := metadata.FileDurations[mf.Filename]; ok && d > 0 {
mf.Duration = d
} else if metadata.Duration > 0 {
mf.Duration = metadata.Duration
metadata.FileDurations[mf.Filename] = mf.Duration
}
}
}
if metadata.Duration < 0 {
metadata.Duration = commonDuration(mediaFiles)
}
item := &models.LibraryItem{
Name: metadata.Name,
RelPath: relPath,
DirPath: itemDir,
SourceURL: metadata.SourceURL,
Duration: metadata.Duration,
Description: metadata.Description,
MediaFiles: mediaFiles,
}
_ = s.writeMetadata(itemDir, metadata)
_ = imageFiles
return item, nil
}
func (s *LibraryService) readOrCreateMetadata(itemDir string) (models.ItemMetadata, error) {
markerPath := filepath.Join(itemDir, itemMarkerName)
var metadata models.ItemMetadata
data, err := os.ReadFile(markerPath)
if err == nil {
if _, err := toml.Decode(string(data), &metadata); err != nil {
log.Printf("warning: failed to parse %s: %v", markerPath, err)
}
}
if metadata.Duration == 0 && !hasField(data, "duration") {
metadata.Duration = -1
}
return metadata, nil
}
func hasField(data []byte, key string) bool {
return strings.Contains(string(data), key+" =") || strings.Contains(string(data), key+"=")
}
func (s *LibraryService) writeMetadata(itemDir string, metadata models.ItemMetadata) error {
markerPath := filepath.Join(itemDir, itemMarkerName)
f, err := os.Create(markerPath)
if err != nil {
return err
}
defer f.Close()
return toml.NewEncoder(f).Encode(metadata)
}
func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, []string, string, error) {
entries, err := os.ReadDir(itemDir)
if err != nil {
return nil, nil, "", err
}
var mediaFiles []models.MediaFile
var imageFiles []string
var infoJSONFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
path := filepath.Join(itemDir, name)
ext := strings.ToLower(filepath.Ext(name))
if name == itemMarkerName {
continue
}
if name == "info.json" || strings.HasSuffix(name, ".info.json") {
infoJSONFiles = append(infoJSONFiles, path)
continue
}
if imageExts[ext] {
imageFiles = append(imageFiles, path)
continue
}
if !mediaExts[ext] {
continue
}
isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
duration := -1
mediaFiles = append(mediaFiles, models.MediaFile{
Filename: name,
Filepath: path,
IsAudio: isAudio,
Duration: duration,
})
}
sort.Slice(mediaFiles, func(i, j int) bool {
return mediaFiles[i].Filepath > mediaFiles[j].Filepath
})
var infoJSONPath string
if len(infoJSONFiles) > 0 {
sort.Strings(infoJSONFiles)
infoJSONPath = infoJSONFiles[0]
if len(infoJSONFiles) > 1 {
log.Printf("warning: multiple info.json files in %s, using %s", itemDir, infoJSONPath)
}
}
return mediaFiles, imageFiles, infoJSONPath, nil
}
func infoString(info map[string]interface{}, key string) (string, bool) {
if info == nil {
return "", false
}
v, ok := info[key]
if !ok {
return "", false
}
switch s := v.(type) {
case string:
return s, true
default:
return fmt.Sprintf("%v", v), true
}
}
func infoDuration(info map[string]interface{}) (int, bool) {
if info == nil {
return 0, false
}
v, ok := info["duration"]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n + 0.5), true
case int:
return n, true
case int64:
return int(n), true
case string:
if f, err := strconv.ParseFloat(n, 64); err == nil {
return int(f + 0.5), true
}
}
return 0, false
}
func mediaFileStem(mf models.MediaFile) string {
return strings.TrimSuffix(filepath.Base(mf.Filename), filepath.Ext(mf.Filename))
}
func commonDuration(files []models.MediaFile) int {
if len(files) == 0 {
return -1
}
d := files[0].Duration
if d < 0 {
return -1
}
for _, f := range files[1:] {
if f.Duration != d {
return -1
}
}
return d
}
func (s *LibraryService) GetMediaFile(relPath, filename string) (string, error) {
item, err := s.GetByRelPath(relPath)
if err != nil {
return "", err
}
for _, mf := range item.MediaFiles {
if mf.Filename == filename {
return mf.Filepath, nil
}
}
return "", fmt.Errorf("media file not found")
}
func (s *LibraryService) ListThumbnails(relPath string) ([]string, error) {
item, err := s.GetByRelPath(relPath)
if err != nil {
return nil, err
}
itemDir := item.DirPath
entries, err := os.ReadDir(itemDir)
if err != nil {
return nil, err
}
var images []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if name == itemMarkerName {
continue
}
path := filepath.Join(itemDir, name)
if imageExts[strings.ToLower(filepath.Ext(name))] && !strings.Contains(name, ".thumbnail.") {
images = append(images, path)
continue
}
if strings.Contains(name, ".thumbnail.") {
if info, err := entry.Info(); err == nil && info.Size() > 0 {
images = append(images, path)
}
}
}
sort.Strings(images)
return images, nil
}
func (s *LibraryService) HasThumbnail(relPath string) bool {
thumbs, _ := s.ListThumbnails(relPath)
return len(thumbs) > 0
}
func (s *LibraryService) EnsureThumbnails(relPath string) {
item, err := s.GetByRelPath(relPath)
if err != nil {
return
}
for _, mf := range item.MediaFiles {
mf := mf
go s.ensureThumbnailForFile(mf)
}
}
func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) {
actual, _ := s.thumbOnce.LoadOrStore(mf.Filepath, new(sync.Once))
once := actual.(*sync.Once)
once.Do(func() {
if _, ok := s.findExistingThumbnail(mf.Filepath); ok {
return
}
if _, err := s.extractThumbnail(mf); err != nil {
log.Printf("thumbnail extraction failed for %s: %v", mf.Filepath, err)
}
})
}
func (s *LibraryService) findExistingThumbnail(path string) (string, bool) {
ext := filepath.Ext(path)
base := strings.TrimSuffix(path, ext) + ".thumbnail"
for _, candidate := range []string{base + ".webp", base + ".jpg", base + ".jpeg", base + ".png"} {
if info, err := os.Stat(candidate); err == nil && info.Size() > 0 {
return candidate, true
}
}
return "", false
}
func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
ext := filepath.Ext(mf.Filepath)
base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail"
tryWrite := func(outputPath string, args []string) (string, error) {
tmpPath := outputPath + ".tmp"
os.Remove(tmpPath)
cmd := exec.Command("ffmpeg", append(args, tmpPath)...)
if output, err := cmd.CombinedOutput(); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("ffmpeg failed: %v\n%s", err, string(output))
}
if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
os.Remove(tmpPath)
return "", fmt.Errorf("ffmpeg produced empty output")
}
if err := os.Rename(tmpPath, outputPath); err != nil {
os.Remove(tmpPath)
return "", err
}
return outputPath, nil
}
// First try to extract an embedded cover art stream.
webpPath := base + ".webp"
embeddedArgs := []string{
"-i", mf.Filepath,
"-map", "0:v",
"-map", "-0:V",
"-vframes", "1",
"-c:v", "libwebp",
}
if path, err := tryWrite(webpPath, embeddedArgs); err == nil {
return path, nil
}
// Fallback to jpeg for embedded covers if libwebp or webp encoding failed.
jpgPath := base + ".jpg"
embeddedJpgArgs := []string{
"-i", mf.Filepath,
"-map", "0:v",
"-map", "-0:V",
"-vframes", "1",
"-q:v", "2",
}
if path, err := tryWrite(jpgPath, embeddedJpgArgs); err == nil {
return path, nil
}
if mf.IsAudio {
return "", fmt.Errorf("audio file has no thumbnail")
}
seekTime := "00:00:01"
if mf.Duration > 0 {
midpoint := mf.Duration / 2
m := midpoint / 60
sec := midpoint % 60
h := m / 60
m = m % 60
if h > 0 {
seekTime = fmt.Sprintf("%02d:%02d:%02d", h, m, sec)
} else {
seekTime = fmt.Sprintf("%02d:%02d", m, sec)
}
}
frameArgs := []string{
"-ss", seekTime,
"-i", mf.Filepath,
"-vframes", "1",
"-c:v", "libwebp",
}
if path, err := tryWrite(webpPath, frameArgs); err == nil {
return path, nil
}
frameJpgArgs := []string{
"-ss", seekTime,
"-i", mf.Filepath,
"-vframes", "1",
"-q:v", "2",
}
return tryWrite(jpgPath, frameJpgArgs)
}
func (s *LibraryService) Delete(relPath string) error {
itemDir, err := s.resolveItemDir(relPath)
if err != nil {
return err
}
return os.RemoveAll(itemDir)
}
func (s *LibraryService) SubtitleDir(relPath string) string {
itemDir, err := s.resolveItemDir(relPath)
if err != nil {
return ""
}
return filepath.Join(itemDir, subtitlesDirName)
}
func (s *LibraryService) GetSubtitlePath(relPath, lang string) string {
return filepath.Join(s.SubtitleDir(relPath), lang+".vtt")
}
func (s *LibraryService) GetSubtitles(relPath string) ([]models.SubtitleTrack, error) {
item, err := s.GetByRelPath(relPath)
if err != nil {
return nil, err
}
cacheDir := s.SubtitleDir(relPath)
entries, err := os.ReadDir(cacheDir)
if err == nil && len(entries) > 0 {
var tracks []models.SubtitleTrack
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" {
continue
}
lang := strings.TrimSuffix(entry.Name(), ".vtt")
tracks = append(tracks, models.SubtitleTrack{
Lang: lang,
Label: lang,
Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang),
})
}
return tracks, nil
}
for _, mf := range item.MediaFiles {
if mf.IsAudio {
continue
}
streams, err := extractSubtitleInfo(mf.Filepath)
if err != nil || len(streams) == 0 {
continue
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return nil, err
}
var tracks []models.SubtitleTrack
for _, stream := range streams {
lang := stream.Lang
if lang == "" {
lang = fmt.Sprintf("track%d", stream.Index)
}
outPath := filepath.Join(cacheDir, lang+".vtt")
if err := extractSubtitleToVTT(mf.Filepath, outPath, stream.Index); err != nil {
continue
}
tracks = append(tracks, models.SubtitleTrack{
Lang: lang,
Label: stream.Label,
Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang),
})
}
return tracks, nil
}
return nil, nil
}
type subtitleStream struct {
Index int
Lang string
Label string
}
func extractSubtitleInfo(path string) ([]subtitleStream, error) {
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_streams",
"-select_streams", "s",
"-of", "json",
path,
)
output, err := cmd.Output()
if err != nil {
return nil, err
}
var probe struct {
Streams []struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &probe); err != nil {
return nil, err
}
var streams []subtitleStream
subIndex := 0
for _, stream := range probe.Streams {
switch stream.CodecName {
case "subrip", "ass", "ssa", "webvtt", "mov_text":
lang := stream.Tags.Language
if lang == "" {
lang = fmt.Sprintf("track%d", subIndex)
}
label := stream.Tags.Title
if label == "" {
label = strings.ToUpper(lang)
}
streams = append(streams, subtitleStream{
Index: subIndex,
Lang: lang,
Label: label,
})
subIndex++
}
}
return streams, nil
}
func extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
cmd := exec.Command("ffmpeg",
"-i", inputPath,
"-map", fmt.Sprintf("0:s:%d", streamIndex),
"-f", "webvtt",
outputPath,
"-y",
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output))
}
return nil
}
func (s *LibraryService) GetMetadata(relPath string) (*MediaMetadata, error) {
item, err := s.GetByRelPath(relPath)
if err != nil {
return nil, err
}
var largest *models.MediaFile
for i := range item.MediaFiles {
if item.MediaFiles[i].IsAudio {
continue
}
if largest == nil {
largest = &item.MediaFiles[i]
continue
}
if info, err := os.Stat(item.MediaFiles[i].Filepath); err == nil {
if li, err := os.Stat(largest.Filepath); err == nil && info.Size() > li.Size() {
largest = &item.MediaFiles[i]
}
}
}
if largest == nil {
return nil, fmt.Errorf("no video file")
}
return probeMedia(largest.Filepath)
}
func probeMedia(path string) (*MediaMetadata, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
meta := &MediaMetadata{
FileSize: info.Size(),
}
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_format",
"-show_streams",
"-of", "json",
path,
)
output, err := cmd.Output()
if err != nil {
return meta, nil
}
var probe ffprobeOutput
if err := json.Unmarshal(output, &probe); err != nil {
return meta, nil
}
if probe.Format.FormatName != "" {
parts := strings.Split(probe.Format.FormatName, ",")
meta.Container = parts[0]
}
for _, stream := range probe.Streams {
switch stream.CodecType {
case "video":
vs := VideoStream{
Codec: stream.CodecName,
Profile: stream.Profile,
Width: stream.Width,
Height: stream.Height,
FPS: parseFPS(stream.RFrameRate),
PixelFormat: stream.PixFmt,
Bitrate: formatBitrate(stream.BitRate),
}
meta.VideoStreams = append(meta.VideoStreams, vs)
if stream.Width > 0 && stream.Height > 0 {
meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
as := AudioStream{
Codec: stream.CodecName,
SampleRate: stream.SampleRate,
Channels: stream.Channels,
ChannelLayout: stream.ChannelLayout,
SampleFormat: stream.SampleFmt,
Bitrate: formatBitrate(stream.BitRate),
Language: stream.Tags.Language,
}
meta.AudioStreams = append(meta.AudioStreams, as)
case "subtitle":
ss := SubtitleStream{
Codec: stream.CodecName,
Language: stream.Tags.Language,
Title: stream.Tags.Title,
}
meta.SubtitleStreams = append(meta.SubtitleStreams, ss)
}
}
return meta, nil
}
func urlEncodePath(p string) string {
parts := strings.Split(strings.Trim(p, "/"), "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func formatBitrate(bitRate string) string {
if bitRate == "" {
return ""
}
br, err := strconv.ParseInt(bitRate, 10, 64)
if err != nil {
return ""
}
return fmt.Sprintf("%d", br/1000)
}
func parseFPS(rate string) string {
if rate == "" || rate == "0/0" {
return ""
}
parts := strings.Split(rate, "/")
if len(parts) != 2 {
return ""
}
num, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
den, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err1 != nil || err2 != nil || den == 0 {
return ""
}
fps := num / den
if fps == math.Trunc(fps) {
return fmt.Sprintf("%.0f", fps)
}
return fmt.Sprintf("%.2f", fps)
}
type MediaMetadata struct {
Container string
Resolution string
FileSize int64
VideoStreams []VideoStream
AudioStreams []AudioStream
SubtitleStreams []SubtitleStream
}
type VideoStream struct {
Codec string
Profile string
Width int
Height int
FPS string
PixelFormat string
Bitrate string
}
type AudioStream struct {
Codec string
SampleRate string
Channels int
ChannelLayout string
SampleFormat string
Bitrate string
Language string
}
type SubtitleStream struct {
Language string
Title string
Codec string
}
type ffprobeOutput struct {
Format struct {
FormatName string `json:"format_name"`
BitRate string `json:"bit_rate"`
} `json:"format"`
Streams []ffprobeStream `json:"streams"`
}
type ffprobeStream struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Profile string `json:"profile"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
AvgFrameRate string `json:"avg_frame_rate"`
PixFmt string `json:"pix_fmt"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
ChannelLayout string `json:"channel_layout"`
SampleFmt string `json:"sample_fmt"`
BitRate string `json:"bit_rate"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
Dinternal/service/media.go-661
@@ -1,661 +0,0 @@
package service
import (
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"vidarchive/internal/models"
"vidarchive/internal/repository"
)
type MediaService struct {
repo *repository.MediaRepository
downloadDir string
subtitleCacheDir string
}
func NewMediaService(repo *repository.MediaRepository, downloadDir, subtitleCacheDir string) *MediaService {
return &MediaService{repo: repo, downloadDir: downloadDir, subtitleCacheDir: subtitleCacheDir}
}
func (s *MediaService) GetByID(id int64) (*models.Media, error) {
return s.repo.GetByID(id)
}
func (s *MediaService) GetByRelativePath(relPath string) (*models.Media, error) {
return s.repo.GetByRelativePath(relPath)
}
func (s *MediaService) GetAll(path, sortBy, filter string) ([]*models.Media, []string, error) {
if path != "" && !strings.HasSuffix(path, "/") {
path += "/"
}
media, err := s.repo.GetAll(path, sortBy, filter)
if err != nil {
return nil, nil, err
}
folders, err := s.repo.GetFolders(path)
if err != nil {
return nil, nil, err
}
return media, folders, nil
}
func (s *MediaService) ScanLibrary() error {
return filepath.Walk(s.downloadDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// Check if this file is already in DB
relPath, _ := filepath.Rel(s.downloadDir, path)
relPath = filepath.ToSlash(relPath)
_, err = s.repo.GetByRelativePath(relPath)
if err == nil {
return nil // already exists
}
// Determine file type
ext := strings.ToLower(filepath.Ext(path))
isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
isVideo := ext == ".mp4" || ext == ".webm" || ext == ".mkv" || ext == ".avi" || ext == ".mov"
if !isAudio && !isVideo {
return nil
}
// Try to find info.json
basePath := strings.TrimSuffix(path, ext)
infoJSONPath := basePath + ".info.json"
if _, err := os.Stat(infoJSONPath); err != nil {
infoJSONPath = ""
}
media := &models.Media{
Filepath: path,
RelativePath: relPath,
IsAudio: isAudio,
InfoJSONPath: infoJSONPath,
}
// Try to extract title from filename
media.Title = strings.TrimSuffix(filepath.Base(path), ext)
media.Duration = extractDuration(infoJSONPath, path)
return s.repo.Create(media)
})
}
func extractDuration(infoJSONPath, mediaPath string) int {
if infoJSONPath != "" {
if data, err := os.ReadFile(infoJSONPath); err == nil {
var info struct {
Duration float64 `json:"duration"`
}
if err := json.Unmarshal(data, &info); err == nil && info.Duration > 0 {
return int(info.Duration + 0.5)
}
}
}
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
mediaPath,
)
output, err := cmd.Output()
if err != nil {
return 0
}
if sec, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64); err == nil && sec > 0 {
return int(sec + 0.5)
}
return 0
}
func (s *MediaService) Delete(id int64) error {
media, err := s.repo.GetByID(id)
if err != nil {
return err
}
// Delete file
if err := os.Remove(media.Filepath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove file: %w", err)
}
// Delete info json if exists
if media.InfoJSONPath != "" {
os.Remove(media.InfoJSONPath)
}
// Delete thumbnail if exists
thumbPath := s.thumbnailPath(media.Filepath)
os.Remove(thumbPath)
// Delete subtitle cache if exists
os.RemoveAll(s.subtitleCachePath(id))
return s.repo.Delete(id)
}
func (s *MediaService) thumbnailPath(path string) string {
ext := filepath.Ext(path)
return strings.TrimSuffix(path, ext) + ".webp"
}
// ffprobeAttachmentStream represents an attachment stream from ffprobe output
type ffprobeAttachmentStream struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
Tags struct {
Mimetype string `json:"mimetype"`
Filename string `json:"filename"`
} `json:"tags"`
}
type ffprobeAttachmentOutput struct {
Streams []ffprobeAttachmentStream `json:"streams"`
}
// findImageAttachment uses ffprobe to find the best image attachment in a media file.
// It returns the attachment index (0-based among all attachment streams), or -1 if none found.
func (s *MediaService) findImageAttachment(path string) int {
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_streams",
"-of", "json",
path,
)
output, err := cmd.Output()
if err != nil {
return -1
}
var probe ffprobeAttachmentOutput
if err := json.Unmarshal(output, &probe); err != nil {
return -1
}
bestAttachmentIndex := -1
bestScore := 0
attachmentCount := 0
for _, stream := range probe.Streams {
// Only consider attachment streams with image/* mimetype
if stream.CodecType != "attachment" {
continue
}
currentAttachmentIndex := attachmentCount
attachmentCount++
if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") {
continue
}
score := 0
filenameLower := strings.ToLower(stream.Tags.Filename)
switch {
case strings.Contains(filenameLower, "cover"):
score = 100
case strings.Contains(filenameLower, "thumbnail"), strings.Contains(filenameLower, "thumb"):
score = 80
case strings.Contains(filenameLower, "poster"):
score = 60
case strings.Contains(filenameLower, "art"):
score = 40
default:
score = 20
}
if score > bestScore {
bestScore = score
bestAttachmentIndex = currentAttachmentIndex
}
}
return bestAttachmentIndex
}
func (s *MediaService) ExtractThumbnail(id int64) (string, error) {
media, err := s.repo.GetByID(id)
if err != nil {
return "", err
}
if media.IsAudio {
return "", fmt.Errorf("audio files don't have thumbnails")
}
thumbPath := s.thumbnailPath(media.Filepath)
// Check if thumbnail already exists
if _, err := os.Stat(thumbPath); err == nil {
return thumbPath, nil
}
// Try to extract embedded image attachment
streamIndex := s.findImageAttachment(media.Filepath)
if streamIndex >= 0 {
// Create temp file for raw attachment
tempFile, err := os.CreateTemp("", "vidarchive-attachment-*")
if err != nil {
goto frameFallback
}
tempPath := tempFile.Name()
tempFile.Close()
defer os.Remove(tempPath)
// Extract attachment (-dump_attachment is an input option, must come before -i)
cmd := exec.Command("ffmpeg",
fmt.Sprintf("-dump_attachment:t:%d", streamIndex),
tempPath,
"-i", media.Filepath,
"-y",
"-t", "0",
"-f", "null", "-",
)
if err := cmd.Run(); err != nil {
goto frameFallback
}
// Convert to canonical WebP
cmd = exec.Command("ffmpeg",
"-i", tempPath,
thumbPath,
"-y",
)
if err := cmd.Run(); err != nil {
goto frameFallback
}
return thumbPath, nil
}
frameFallback:
// Extract a video frame at 50% duration
// Get video duration
durationCmd := exec.Command("ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
media.Filepath,
)
durationOut, durationErr := durationCmd.Output()
seekTime := "00:00:01"
if durationErr == nil {
if durationSec, parseErr := strconv.ParseFloat(strings.TrimSpace(string(durationOut)), 64); parseErr == nil && durationSec > 0 {
midpoint := durationSec / 2
// Format as HH:MM:SS or MM:SS
minutes := int(midpoint) / 60
seconds := int(midpoint) % 60
hours := minutes / 60
minutes = minutes % 60
if hours > 0 {
seekTime = fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
} else {
seekTime = fmt.Sprintf("%02d:%02d", minutes, seconds)
}
}
}
cmd := exec.Command("ffmpeg", "-ss", seekTime, "-i", media.Filepath, "-vframes", "1", thumbPath)
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("ffmpeg failed: %w", err)
}
return thumbPath, nil
}
func (s *MediaService) HasThumbnail(id int64) bool {
media, err := s.repo.GetByID(id)
if err != nil {
return false
}
thumbPath := s.thumbnailPath(media.Filepath)
_, err = os.Stat(thumbPath)
return err == nil
}
type MediaMetadata struct {
Container string
Resolution string
FileSize int64
VideoStreams []VideoStream
AudioStreams []AudioStream
SubtitleStreams []SubtitleStream
}
type VideoStream struct {
Codec string
Profile string
Width int
Height int
FPS string
PixelFormat string
Bitrate string
}
type AudioStream struct {
Codec string
SampleRate string
Channels int
ChannelLayout string
SampleFormat string
Bitrate string
Language string
}
type SubtitleStream struct {
Language string
Title string
Codec string
}
type ffprobeStream struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Profile string `json:"profile"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
AvgFrameRate string `json:"avg_frame_rate"`
PixFmt string `json:"pix_fmt"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
ChannelLayout string `json:"channel_layout"`
SampleFmt string `json:"sample_fmt"`
BitRate string `json:"bit_rate"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
}
type ffprobeOutput struct {
Format struct {
FormatName string `json:"format_name"`
BitRate string `json:"bit_rate"`
} `json:"format"`
Streams []ffprobeStream `json:"streams"`
}
func (s *MediaService) GetMetadata(id int64) (*MediaMetadata, error) {
media, err := s.repo.GetByID(id)
if err != nil {
return nil, err
}
// Get file size
info, err := os.Stat(media.Filepath)
if err != nil {
return nil, err
}
meta := &MediaMetadata{
FileSize: info.Size(),
}
// Get metadata via ffprobe JSON output
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_format",
"-show_streams",
"-of", "json",
media.Filepath,
)
output, err := cmd.Output()
if err != nil {
return meta, nil // Return partial metadata (file size only)
}
var probe ffprobeOutput
if err := json.Unmarshal(output, &probe); err != nil {
return meta, nil
}
// Container format
if probe.Format.FormatName != "" {
parts := strings.Split(probe.Format.FormatName, ",")
meta.Container = parts[0]
}
// Stream info
for _, stream := range probe.Streams {
switch stream.CodecType {
case "video":
vs := VideoStream{
Codec: stream.CodecName,
Profile: stream.Profile,
Width: stream.Width,
Height: stream.Height,
FPS: parseFPS(stream.RFrameRate),
PixelFormat: stream.PixFmt,
Bitrate: formatBitrate(stream.BitRate),
}
meta.VideoStreams = append(meta.VideoStreams, vs)
if stream.Width > 0 && stream.Height > 0 {
meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
as := AudioStream{
Codec: stream.CodecName,
SampleRate: stream.SampleRate,
Channels: stream.Channels,
ChannelLayout: stream.ChannelLayout,
SampleFormat: stream.SampleFmt,
Bitrate: formatBitrate(stream.BitRate),
Language: stream.Tags.Language,
}
meta.AudioStreams = append(meta.AudioStreams, as)
case "subtitle":
ss := SubtitleStream{
Codec: stream.CodecName,
Language: stream.Tags.Language,
Title: stream.Tags.Title,
}
meta.SubtitleStreams = append(meta.SubtitleStreams, ss)
}
}
return meta, nil
}
func formatBitrate(bitRate string) string {
if bitRate == "" {
return ""
}
br, err := strconv.ParseInt(bitRate, 10, 64)
if err != nil {
return ""
}
return fmt.Sprintf("%d", br/1000)
}
func parseFPS(rate string) string {
if rate == "" || rate == "0/0" {
return ""
}
parts := strings.Split(rate, "/")
if len(parts) != 2 {
return ""
}
num, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
den, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err1 != nil || err2 != nil || den == 0 {
return ""
}
fps := num / den
if fps == math.Trunc(fps) {
return fmt.Sprintf("%.0f", fps)
}
return fmt.Sprintf("%.2f", fps)
}
func (s *MediaService) subtitleCachePath(id int64) string {
return filepath.Join(s.subtitleCacheDir, fmt.Sprintf("%d", id))
}
// GetSubtitlePath returns the full path to a cached subtitle file for a given media ID and language.
func (s *MediaService) GetSubtitlePath(id int64, lang string) string {
return filepath.Join(s.subtitleCachePath(id), lang+".vtt")
}
// GetSubtitles extracts embedded text subtitles from a media file, converts them to VTT,
// and returns track info for the HTML5 video player. Results are cached.
func (s *MediaService) GetSubtitles(id int64) ([]models.SubtitleTrack, error) {
media, err := s.repo.GetByID(id)
if err != nil {
return nil, err
}
cacheDir := s.subtitleCachePath(id)
// Check cache first
entries, err := os.ReadDir(cacheDir)
if err == nil && len(entries) > 0 {
var tracks []models.SubtitleTrack
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" {
continue
}
lang := strings.TrimSuffix(entry.Name(), ".vtt")
label := lang
tracks = append(tracks, models.SubtitleTrack{
Lang: lang,
Label: label,
Src: fmt.Sprintf("/media/%d/subtitles/%s", id, lang),
})
}
return tracks, nil
}
// Extract subtitle streams
streams, err := s.extractSubtitleInfo(media.Filepath)
if err != nil || len(streams) == 0 {
return nil, err
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return nil, err
}
var tracks []models.SubtitleTrack
for _, stream := range streams {
lang := stream.Lang
if lang == "" {
lang = fmt.Sprintf("track%d", stream.Index)
}
outPath := filepath.Join(cacheDir, lang+".vtt")
if err := s.extractSubtitleToVTT(media.Filepath, outPath, stream.Index); err != nil {
continue // Skip failed extractions
}
tracks = append(tracks, models.SubtitleTrack{
Lang: lang,
Label: stream.Label,
Src: fmt.Sprintf("/media/%d/subtitles/%s", id, lang),
})
}
return tracks, nil
}
type subtitleStream struct {
Index int
Lang string
Label string
}
// extractSubtitleInfo finds text-based subtitle streams in a media file using ffprobe.
func (s *MediaService) extractSubtitleInfo(path string) ([]subtitleStream, error) {
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_streams",
"-select_streams", "s",
"-of", "json",
path,
)
output, err := cmd.Output()
if err != nil {
return nil, err
}
var probe struct {
Streams []struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &probe); err != nil {
return nil, err
}
var streams []subtitleStream
subIndex := 0
for _, stream := range probe.Streams {
// Only text-based subtitles
switch stream.CodecName {
case "subrip", "ass", "ssa", "webvtt", "mov_text":
lang := stream.Tags.Language
if lang == "" {
lang = fmt.Sprintf("track%d", subIndex)
}
label := stream.Tags.Title
if label == "" {
label = strings.ToUpper(lang)
}
streams = append(streams, subtitleStream{
Index: subIndex,
Lang: lang,
Label: label,
})
subIndex++
}
}
return streams, nil
}
// extractSubtitleToVTT extracts a subtitle stream and converts it to WebVTT format.
func (s *MediaService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
cmd := exec.Command("ffmpeg",
"-i", inputPath,
"-map", fmt.Sprintf("0:s:%d", streamIndex),
"-f", "webvtt",
outputPath,
"-y",
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output))
}
return nil
}
Minternal/service/preset.go
@@ -97,11 +97,6 @@ func (s *PresetService) BuildArgs(p *models.Preset, formatOverride, customFlags
args = append(args, "--write-info-json")
}
// Output template
if p.OutputTemplate != "" {
args = append(args, "-o", p.OutputTemplate)
}
// Custom flags
if p.CustomFlags != "" {
args = append(args, strings.Fields(p.CustomFlags)...)
Minternal/service/preset_test.go
@@ -19,7 +19,6 @@ func TestPresetServiceBuildArgs(t *testing.T) {
EmbedThumbnail: true,
EmbedMetadata: true,
WriteInfoJSON: true,
OutputTemplate: "%(title)s.%(ext)s",
CustomFlags: "--no-playlist",
}
@@ -46,7 +45,7 @@ func TestPresetServiceBuildArgs(t *testing.T) {
hasSubs = true
case "-o":
if i+1 < len(args) {
if args[i+1] == "%(title)s.%(ext)s" {
if args[i+1] == "item-%(autonumber)05d/%(title)s.%(ext)s" {
hasOutput = true
}
}
@@ -62,8 +61,8 @@ func TestPresetServiceBuildArgs(t *testing.T) {
if !hasSubs {
t.Error("expected --embed-subs flag")
}
if !hasOutput {
t.Error("expected output template")
if hasOutput {
t.Error("output template should not be added by BuildArgs; it is enforced by the download service")
}
}
Mweb/templates/download_form.html
@@ -5,6 +5,8 @@
<div class="download-form">
<article>
<p class="note"><strong>Note:</strong> The output template option (<code>-o</code>) is always overridden by VidArchive so that each downloaded item lives in its own self-contained directory.</p>
<form method="post" action="/download">
<label for="url">Video/Playlist URL</label>
<input type="url" id="url" name="url" placeholder="https://youtube.com/watch?v=..." required value="{{.Data.URL}}">
@@ -20,8 +22,8 @@
</select>
<label for="output_dir">Output Directory</label>
<input type="text" id="output_dir" name="output_dir" placeholder="e.g., videos/music (relative to downloads folder)" value="{{.Data.OutputDir}}">
<small>Optional sub-directory within the downloads folder. Use / for nested paths.</small>
<input type="text" id="output_dir" name="output_dir" placeholder="e.g., videos/music (relative to library folder)" value="{{.Data.OutputDir}}">
<small>Optional sub-directory within the library folder. Use / for nested paths.</small>
<label for="format_override">Format Override</label>
<input type="text" id="format_override" name="format_override" placeholder="e.g., bestvideo+bestaudio/best or format ID like 22" value="{{.Data.FormatOverride}}">
Mweb/templates/library.html
@@ -2,9 +2,6 @@
<div class="page-header">
<h2>Library</h2>
<div class="actions">
<form method="post" action="/library/scan" class="inline">
<button type="submit">Scan Library</button>
</form>
<form method="get" action="/library" class="inline filter-form">
<input type="hidden" name="path" value="{{.Data.Path}}">
<input type="text" name="filter" placeholder="Search..." value="{{.Data.Filter}}">
@@ -45,29 +42,24 @@
</a>
{{end}}
{{range .Data.Media}}
<a href="/library/{{.ID}}" class="media-item {{if .IsAudio}}audio{{else}}video{{end}}">
{{range .Data.Items}}
<a href="/library/item/{{urlEncodePath .RelPath}}" class="media-item {{if gt (len .MediaFiles) 0}}{{if (index .MediaFiles 0).IsAudio}}audio{{else}}video{{end}}{{end}}">
<div class="media-thumbnail">
{{if .IsAudio}}
<img src="/static/icons/audio-icon.svg" alt="">
{{else}}
<img src="/media/{{.ID}}/thumbnail" alt="" loading="lazy">
{{end}}
{{if .Duration}}
<img src="/media/item/{{urlEncodePath .RelPath}}/thumbnail" alt="" loading="lazy">
{{if ge .Duration 0}}
<span class="media-duration">{{formatDuration .Duration}}</span>
{{end}}
</div>
<div class="media-info">
<div class="media-title">{{.Title}}</div>
<div class="media-path">{{.RelativePath}}</div>
<div class="media-title">{{.Name}}</div>
</div>
</a>
{{end}}
</div>
{{if and (eq (len .Data.Folders) 0) (eq (len .Data.Media) 0)}}
{{if and (eq (len .Data.Folders) 0) (eq (len .Data.Items) 0)}}
<div class="empty-state">
<p>No media found. <a href="/download">Start a download</a> or scan for existing files.</p>
<p>No media found. <a href="/download">Start a download</a> or add existing files.</p>
</div>
{{end}}
{{end}}
Mweb/templates/library_detail.html
@@ -1,57 +1,69 @@
{{define "library_detail"}}
<div class="detail-view">
<div class="detail-header">
<h2>{{.Data.Media.Title}}</h2>
<h2>{{.Data.Item.Name}}</h2>
<details class="delete-overlay">
<summary role="button" class="small danger">Delete</summary>
<div class="overlay-panel">
<p>Are you sure you want to delete this media?</p>
<form method="post" action="/library/{{.Data.Media.ID}}/delete" class="form-actions">
<p>Are you sure you want to delete this item?</p>
<form method="post" action="/library/item/{{urlEncodePath .Data.Item.RelPath}}/delete" class="form-actions">
<button type="submit" class="danger">Yes, Delete</button>
</form>
</div>
</details>
</div>
{{if gt (len .Data.Item.MediaFiles) 1}}
<form method="get" action="/library/item/{{urlEncodePath .Data.Item.RelPath}}" class="file-selector">
<label for="file">Playing</label>
<select id="file" name="file" onchange="this.form.submit()">
{{range .Data.Item.MediaFiles}}
<option value="{{.Filename}}" {{if eq .Filename $.Data.SelectedFilename}}selected{{end}}>{{.Filename}}</option>
{{end}}
</select>
</form>
{{end}}
{{range .Data.Item.MediaFiles}}
{{if eq .Filename $.Data.SelectedFilename}}
<div class="player-container">
{{if .Data.Media.IsAudio}}
{{if .IsAudio}}
<audio controls>
<source src="/media/{{.Data.Media.ID}}" type="{{mimeType .Data.Media.Filepath}}">
<source src="/media/item/{{urlEncodePath $.Data.Item.RelPath}}?file={{.Filename}}" type="{{mimeType .Filepath}}">
Your browser does not support the audio element.
</audio>
{{else}}
<video controls preload="metadata">
<source src="/media/{{.Data.Media.ID}}" type="{{mimeType .Data.Media.Filepath}}">
{{range .Data.Subtitles}}
<source src="/media/item/{{urlEncodePath $.Data.Item.RelPath}}?file={{.Filename}}" type="{{mimeType .Filepath}}">
{{range $.Data.Subtitles}}
<track kind="subtitles" src="{{.Src}}" srclang="{{.Lang}}" label="{{.Label}}">
{{end}}
Your browser does not support the video element.
</video>
{{end}}
</div>
{{end}}
{{end}}
<div class="detail-meta">
<dl>
<dt>Filename</dt>
<dd>{{baseName .Data.Media.Filepath}}</dd>
<dt>Files</dt>
{{range .Data.Item.MediaFiles}}
<dd>{{.Filename}}</dd>
{{end}}
{{if .Data.Media.URL}}
{{if .Data.Item.SourceURL}}
<dt>Source URL</dt>
<dd><a href="{{.Data.Media.URL}}" target="_blank">{{.Data.Media.URL}}</a></dd>
<dd><a href="{{.Data.Item.SourceURL}}" target="_blank">{{.Data.Item.SourceURL}}</a></dd>
{{end}}
</dl>
<details>
<summary role="button" class="small secondary">More Details</summary>
<dl>
{{if .Data.Media.Duration}}
<dt>Duration</dt>
<dd>{{formatDuration .Data.Media.Duration}}</dd>
{{end}}
{{if .Data.Media.Description}}
{{if .Data.Item.Description}}
<dt>Description</dt>
<dd>{{.Data.Media.Description}}</dd>
<dd>{{.Data.Item.Description}}</dd>
{{end}}
{{if .Data.Metadata}}
@@ -103,9 +115,6 @@
<dd>{{formatFileSize .Data.Metadata.FileSize}}</dd>
{{end}}
{{end}}
<dt>File</dt>
<dd>{{.Data.Media.Filepath}}</dd>
</dl>
</details>
</div>
Mweb/templates/settings.html
@@ -252,13 +252,6 @@
</label>
</div>
<div class="form-group">
<label for="output_template">Output Template</label>
<input type="text" name="output_template"
value="{{if .}}{{.OutputTemplate}}{{else}}%(title)s [%(id)s].%(ext)s{{end}}">
<small>Use / for subfolders. Example: %(playlist_title)s/%(title)s.%(ext)s</small>
</div>
<div class="form-group">
<label for="custom_flags">Custom Flags</label>
<textarea name="custom_flags" rows="2" placeholder="Any additional yt-dlp flags...">{{if .}}{{.CustomFlags}}{{end}}</textarea>
@@ -394,13 +387,6 @@
</label>
</div>
<div class="form-group">
<label for="output_template">Output Template</label>
<input type="text" name="output_template"
value="%(title)s.%(ext)s">
<small>Use / for subfolders. Example: %(playlist_title)s/%(title)s.%(ext)s</small>
</div>
<div class="form-group">
<label for="custom_flags">Custom Flags</label>
<textarea name="custom_flags" rows="2" placeholder="Any additional yt-dlp flags..."></textarea>