simplification

AuthorKonata <konata@posteo.jp>
Date
Commit07d014a9b37e6ba5ffcb0d7a9ffb5f3088bb634e
Parent04d3761
13 files changed, 325 insertions(+), 394 deletions(-)
▾Minternal/config/config.go
@@ -19,7 +19,6 @@ type Config struct {
FFprobePath string
BaseURL string
Workers int
RefreshInterval int
SchedulerInterval int
}
@@ -37,7 +36,6 @@ func New() *Config {
FFprobePath: getEnv("VIDARCHIVE_FFPROBE_PATH", "ffprobe"),
BaseURL: getEnv("VIDARCHIVE_BASE_URL", ""),
Workers: getEnvIntMin("VIDARCHIVE_WORKERS", 2, 1),
RefreshInterval: getEnvIntMin("VIDARCHIVE_REFRESH_INTERVAL", 5, 1),
SchedulerInterval: getEnvIntMin("VIDARCHIVE_SCHEDULER_INTERVAL", 60, 1),
}
}
▾Minternal/config/config_test.go
@@ -17,9 +17,6 @@ func TestNewDefaults(t *testing.T) {
if cfg.Workers != 2 {
t.Errorf("expected default workers 2, got %d", cfg.Workers)
}
if cfg.RefreshInterval != 5 {
t.Errorf("expected default refresh interval 5, got %d", cfg.RefreshInterval)
}
}
func TestEnvOverrides(t *testing.T) {
▾Minternal/handler/handler.go
@@ -18,6 +18,7 @@ import (
"vidarchive/internal/config"
"vidarchive/internal/models"
"vidarchive/internal/service"
"vidarchive/internal/util"
"vidarchive/internal/worker"
)
@@ -50,27 +51,19 @@ func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *serv
}, nil
}
// loadTemplates builds the template set. The preset service is passed in and
// captured by the presetFlags func rather than reached through a package-level
// variable, so the handler owns its dependencies.
// loadTemplates builds the template set.
func loadTemplates(presetSvc *service.PresetService) (*template.Template, error) {
tmpl := template.New("").Funcs(template.FuncMap{
"formatDuration": formatDuration,
"formatFileSize": formatFileSize,
"formatFileSize": util.FormatBytes,
"splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
"baseName": filepath.Base,
"ext": filepath.Ext,
"mimeType": mimeTypeFromFile,
"presetFlags": func(p *models.Preset) string { return presetSvc.EffectiveFlags(p, "", "") },
"urlEncode": template.URLQueryEscaper,
"urlEncodePath": func(p string) string {
parts := strings.Split(strings.Trim(p, "/"), "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
},
"sub": func(a, b int) int { return a - b },
"urlEncodePath": util.URLEncodePath,
"sub": func(a, b int) int { return a - b },
// emptyPreset / newSubscription supply a zero value so the shared create and
// edit form partials can be rendered from the create page too. newSubscription
// carries the create-time defaults (overwrite mode, daily schedule).
@@ -110,27 +103,7 @@ func formatDuration(seconds int) string {
if seconds <= 0 {
return "--:--"
}
m := seconds / 60
s := seconds % 60
h := m / 60
m = m % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
func formatFileSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
return util.FormatClock(seconds)
}
func mimeTypeFromFile(path string) string {
@@ -329,8 +302,7 @@ func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
item, err := h.librarySvc.GetByRelPath(ctx, relPath)
item, err := h.librarySvc.GetByRelPath(relPath)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
@@ -341,11 +313,11 @@ func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.R
selectedFilename = item.MediaFiles[0].Filename
}
meta, err := h.librarySvc.GetMetadata(ctx, relPath, selectedFilename)
meta, err := h.librarySvc.GetMetadata(relPath, selectedFilename)
if err != nil {
log.Printf("libraryDetail %q: metadata unavailable: %v", relPath, err)
}
subtitles, err := h.librarySvc.GetSubtitles(ctx, relPath)
subtitles, err := h.librarySvc.GetSubtitles(relPath)
if err != nil {
log.Printf("libraryDetail %q: subtitles unavailable: %v", relPath, err)
}
@@ -391,7 +363,7 @@ func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Requ
return
}
mediaPath, err := h.librarySvc.GetMediaFile(r.Context(), relPath, filename)
mediaPath, err := h.librarySvc.GetMediaFile(relPath, filename)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
@@ -401,10 +373,9 @@ func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Requ
}
func (h *Handler) serveThumbnail(relPath string, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
filename := r.URL.Query().Get("file")
if thumb, ok := h.librarySvc.ThumbnailForFile(ctx, relPath, filename); ok {
if thumb, ok := h.librarySvc.ThumbnailForFile(relPath, filename); ok {
http.ServeFile(w, r, thumb)
return
}
@@ -412,7 +383,7 @@ func (h *Handler) serveThumbnail(relPath string, w http.ResponseWriter, r *http.
// Fall back to an icon, matched to the requested file's type (or the item's
// primary file when no specific file was requested).
icon := "video-icon.svg"
if item, err := h.librarySvc.GetByRelPath(ctx, relPath); err == nil {
if item, err := h.librarySvc.GetByRelPath(relPath); err == nil {
if isAudioFile(item, filename) {
icon = "audio-icon.svg"
}
▾Minternal/repository/preset.go
@@ -128,10 +128,6 @@ func (r *PresetRepository) Delete(id int64) error {
return err
}
func (r *PresetRepository) ClearDefault() error {
return clearDefault(r.db)
}
func scanPreset(row interface{ Scan(...interface{}) error }) (*models.Preset, error) {
var p models.Preset
var isDefault, extractAudio, embedSubs, embedThumbnail, embedMetadata, writeInfoJSON int
▾Minternal/repository/preset_test.go
@@ -84,9 +84,6 @@ func TestPresetRepositoryDefault(t *testing.T) {
repo := NewPresetRepository(db)
// Clear existing default from migration
repo.ClearDefault()
// Create default preset with unique name
preset := &models.Preset{
Name: "TestDefault",
@@ -107,15 +104,6 @@ func TestPresetRepositoryDefault(t *testing.T) {
if !found.IsDefault {
t.Error("expected preset to be default")
}
// Test ClearDefault
if err := repo.ClearDefault(); err != nil {
t.Fatalf("failed to clear default: %v", err)
}
_, err = repo.GetDefault()
if err == nil {
t.Error("expected no default preset after clearing")
}
}
// Save must keep "default" exclusive within a single transaction: promoting a
@@ -125,9 +113,6 @@ func TestPresetRepositorySaveExclusiveDefault(t *testing.T) {
defer db.Close()
repo := NewPresetRepository(db)
if err := repo.ClearDefault(); err != nil { // drop the migration-seeded default
t.Fatalf("clear default: %v", err)
}
a := &models.Preset{Name: "A", IsDefault: true}
if err := repo.Save(a); err != nil {
▾Minternal/service/download.go
@@ -3,7 +3,6 @@ package service
import (
"bufio"
"bytes"
"cmp"
"context"
"database/sql"
"encoding/json"
@@ -14,7 +13,6 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
@@ -22,12 +20,12 @@ import (
"syscall"
"time"
"github.com/BurntSushi/toml"
"github.com/gabriel-vasile/mimetype"
"vidarchive/internal/config"
"vidarchive/internal/models"
"vidarchive/internal/repository"
"vidarchive/internal/util"
)
type DownloadService struct {
@@ -249,8 +247,6 @@ func (s *DownloadService) ListFormats(url string) ([]*models.FormatInfo, error)
// parent belongs to the worker pool: deriving from it means a shutdown cancels
// the download even if it lands before this call registers its own cancel func.
func (s *DownloadService) ExecuteDownload(parent context.Context, d *models.Download) (bool, error) {
// Atomically claim the download. If it's no longer queued, another worker
// already took it — bail rather than download it twice.
claimed, err := s.repo.MarkStarted(d.ID)
if err != nil {
return false, err
@@ -345,9 +341,9 @@ func (s *DownloadService) ExecuteDownload(parent context.Context, d *models.Down
runErr := s.runYTDLP(ctx, d, args)
// Cancellation is checked before the run error, and both are checked before
// the import: a cancel that lands just after yt-dlp exited 0 leaves runErr nil,
// and the item must not reach the library after the user removed it.
// Cancellation wins over both the run error and the import: a cancel that
// lands just after yt-dlp exited 0 leaves runErr nil, and the item must not
// reach the library after the user removed it.
if ctx.Err() != nil {
return false, s.finalizeCancelled(parent, d.ID)
}
@@ -387,9 +383,8 @@ func (s *DownloadService) ExecuteDownload(parent context.Context, d *models.Down
s.pruneSubscription(ctx, d, sub)
}
// Check cancellation before postErr, not only when postErr is non-nil: a run
// stopped just after yt-dlp exited 0 leaves postErr nil, and without this the
// download falls through and is recorded as "completed".
// Same cancellation check as above: a stop during post-processing must not
// be recorded as "completed".
if ctx.Err() != nil {
return false, s.finalizeCancelled(parent, d.ID)
}
@@ -467,33 +462,38 @@ func (s *DownloadService) runYTDLP(ctx context.Context, d *models.Download, args
// --cookies flag. The returned cleanup removes the temp file and is always safe
// to call, even when no cookies were configured.
func (s *DownloadService) appendCookies(args []string) ([]string, func()) {
cleanup := func() {}
cookies, err := s.settingsSvc.GetCookies()
if err != nil || strings.TrimSpace(cookies) == "" {
return args, cleanup
return args, func() {}
}
// Written into the app's own temp dir rather than the system one, so the
// cookies file lands on the same volume the rest of the run uses.
path, err := s.writeCookiesFile(cookies)
if err != nil {
return args, func() {}
}
return append(args, "--cookies", path), func() { os.Remove(path) }
}
// writeCookiesFile writes cookies to a temp file in the app's own temp dir (the
// same volume the rest of the run uses). On any failure the partial file is
// removed — a truncated cookies file must not be handed to yt-dlp.
func (s *DownloadService) writeCookiesFile(cookies string) (string, error) {
if err := os.MkdirAll(s.cfg.TempDir, 0755); err != nil {
return args, cleanup
return "", err
}
tmpFile, err := os.CreateTemp(s.cfg.TempDir, "cookies-*.txt")
if err != nil {
return args, cleanup
return "", err
}
// A short write would hand yt-dlp a truncated cookies file; on any write/close
// failure, drop the temp file and proceed without cookies rather than silently
// using a broken one.
if _, err := tmpFile.WriteString(cookies); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
return args, cleanup
return "", err
}
if err := tmpFile.Close(); err != nil {
os.Remove(tmpFile.Name())
return args, cleanup
return "", err
}
return append(args, "--cookies", tmpFile.Name()), func() { os.Remove(tmpFile.Name()) }
return tmpFile.Name(), nil
}
func (s *DownloadService) finalizeError(id int64, err error) {
@@ -719,17 +719,7 @@ func (s *DownloadService) importItemDir(ctx context.Context, url, itemDir, baseL
FileDurations: fileDurations,
}
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
return s.librarySvc.writeMetadata(targetDir, metadata)
}
// infoJSON is the subset of yt-dlp's info.json VidArchive reads. ID is the
@@ -846,7 +836,7 @@ func (s *DownloadService) downloadFresh(ctx context.Context, d *models.Download,
// applyMetadata rewrites an existing item's marker (name/description/identity)
// from a fresh info.json without touching its media.
func (s *DownloadService) applyMetadata(existing string, info infoJSON) error {
meta, err := s.librarySvc.readOrCreateMetadata(existing)
meta, err := s.librarySvc.readMetadata(existing)
if err != nil {
return fmt.Errorf("read metadata for %s: %w", existing, err)
}
@@ -961,22 +951,18 @@ func (s *DownloadService) deriveItemName(itemDir string, info infoJSON, mediaFil
return sanitizeDirName(info.Title)
}
// Stat once per file up front: doing it inside the comparator re-stats the
// same files O(n log n) times.
sizes := make(map[string]int64, len(mediaFiles))
var largest os.DirEntry
var maxSize int64
for _, f := range mediaFiles {
if st, err := os.Stat(filepath.Join(itemDir, f.Name())); err == nil {
sizes[f.Name()] = st.Size()
st, err := os.Stat(filepath.Join(itemDir, f.Name()))
if err == nil && (largest == nil || st.Size() > maxSize) {
largest, maxSize = f, st.Size()
}
}
// Sort a copy: the caller iterates mediaFiles again to move and probe the
// files, and a naming helper must not reorder its caller's data.
byName := slices.Clone(mediaFiles)
slices.SortFunc(byName, func(a, b os.DirEntry) int {
return cmp.Compare(sizes[b.Name()], sizes[a.Name()])
})
base := strings.TrimSuffix(byName[0].Name(), filepath.Ext(byName[0].Name()))
if largest == nil {
largest = mediaFiles[0]
}
base := strings.TrimSuffix(largest.Name(), filepath.Ext(largest.Name()))
return sanitizeDirName(base)
}
@@ -1145,28 +1131,14 @@ func (f ytFormat) toFormatInfo() *models.FormatInfo {
}
if f.Filesize != nil && *f.Filesize > 0 {
fi.FileSize = humanizeBytes(*f.Filesize)
fi.FileSize = util.FormatBytes(*f.Filesize)
} else if f.FilesizeApprox != nil && *f.FilesizeApprox > 0 {
fi.FileSize = "~" + humanizeBytes(*f.FilesizeApprox)
fi.FileSize = "~" + util.FormatBytes(*f.FilesizeApprox)
}
return fi
}
// humanizeBytes renders a byte count as a compact human-readable size.
func humanizeBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%dB", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
func sqlNullInt64(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: true}
}
▾Minternal/service/download_test.go
@@ -125,8 +125,8 @@ func TestParseFormatJSON(t *testing.T) {
if formats[1].Resolution != "1920x1080" {
t.Errorf("format[1] resolution = %q, want 1920x1080", formats[1].Resolution)
}
if formats[1].FileSize != "1.0MiB" {
t.Errorf("format[1] filesize = %q, want 1.0MiB", formats[1].FileSize)
if formats[1].FileSize != "1.0 MiB" {
t.Errorf("format[1] filesize = %q, want 1.0 MiB", formats[1].FileSize)
}
// Audio-only format: codec falls back to acodec and channels are populated.
if formats[2].Codec != "mp4a" {
▾Minternal/service/library.go
@@ -1,12 +1,10 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -20,18 +18,23 @@ import (
"github.com/BurntSushi/toml"
"vidarchive/internal/models"
"vidarchive/internal/util"
)
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 mediaExts = map[string]struct{}{
".mp4": {}, ".webm": {}, ".mkv": {}, ".avi": {}, ".mov": {},
".mp3": {}, ".wav": {}, ".flac": {}, ".aac": {}, ".opus": {},
}
var imageExts = map[string]bool{
".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true,
var audioExts = map[string]struct{}{
".mp3": {}, ".wav": {}, ".flac": {}, ".aac": {}, ".opus": {},
}
var imageExts = map[string]struct{}{
".webp": {}, ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".bmp": {},
}
// maxConcurrentThumbnails caps how many ffmpeg extraction processes may run at
@@ -46,20 +49,14 @@ type LibraryService struct {
// (e.g. a pinned build) can be pointed at directly.
ffmpegPath string
ffprobePath string
// thumbLocks maps a media filepath -> *sync.Mutex to serialize extraction
// per file (so two callers never write the same temp file at once). Entries
// are bounded by the number of distinct media files ever requested, not by
// request volume; entries for deleted items are not reclaimed. We
// deliberately do not prune: dropping a lock would let two extractions of
// the same file race on its shared temp path, which is exactly what the lock
// prevents.
// thumbLocks holds a per-media-file mutex serializing extraction so two
// callers never write the same temp file at once. Entries are deliberately
// never pruned: dropping one would reintroduce the race it prevents.
thumbLocks sync.Map
thumbSem chan struct{}
// thumbFailed records media filepaths whose extraction already failed this
// run, so we trust ffmpeg's verdict and don't re-run it on every request. It
// lives only in memory: a restart clears it and retries extraction (and any
// success is then cached on disk as the .thumbnail file).
// run, so we trust ffmpeg's verdict and don't re-run it on every request.
thumbFailed sync.Map
// extraction counters, for observability/tests.
@@ -144,20 +141,25 @@ func (s *LibraryService) ResolveWithinLibrary(relPath string) (string, error) {
return s.resolveItemDir(filepath.Clean(relPath))
}
// libraryRoot returns the symlink-resolved library root.
func (s *LibraryService) libraryRoot() string {
if base, err := filepath.EvalSymlinks(s.libraryDir); err == nil {
return base
}
return filepath.Clean(s.libraryDir)
}
func (s *LibraryService) resolveItemDir(relPath string) (string, error) {
relPath = strings.Trim(relPath, string(filepath.Separator))
if relPath == "" {
return s.libraryDir, nil
base := s.libraryRoot()
if relPath == "" || relPath == "." {
return base, nil
}
itemDir := filepath.Join(s.libraryDir, relPath)
itemDir := filepath.Join(base, 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")
}
@@ -173,6 +175,7 @@ func (s *LibraryService) GetAll(path, sortBy, filter string) ([]*models.LibraryI
dir, err := s.resolveItemDir(path)
if err != nil {
log.Printf("GetAll: invalid path %q: %v", path, err)
return nil, nil, nil
}
entries, err := os.ReadDir(dir)
@@ -239,9 +242,8 @@ func (s *LibraryService) GetAll(path, sortBy, filter string) ([]*models.LibraryI
}
// GetByRelPath returns the item at relPath, reusing a recent cached scan when
// available (see scanCache). The context is accepted for cancellation/threading
// but item caching is keyed on relPath at the service level.
func (s *LibraryService) GetByRelPath(ctx context.Context, relPath string) (*models.LibraryItem, error) {
// available (see scanCache).
func (s *LibraryService) GetByRelPath(relPath string) (*models.LibraryItem, error) {
relPath = strings.Trim(relPath, "/")
if item, ok := s.getCachedScan(relPath); ok {
@@ -265,7 +267,7 @@ func (s *LibraryService) GetByRelPath(ctx context.Context, relPath string) (*mod
}
func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem, error) {
metadata, err := s.readOrCreateMetadata(itemDir)
metadata, err := s.readMetadata(itemDir)
if err != nil {
return nil, err
}
@@ -301,35 +303,18 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
}
if metadata.SourceURL == "" {
// Accept only a non-empty value: a present-but-empty field would otherwise
// leave SourceURL "" while flipping dirty on every scan, rewriting the
// marker forever.
if url, ok := infoString(info, "webpage_url"); ok && url != "" {
metadata.SourceURL = url
dirty = true
} else if url, ok := infoString(info, "url"); ok && url != "" {
metadata.SourceURL = url
dirty = true
}
dirty = backfillString(&metadata.SourceURL, info, "webpage_url", "url") || dirty
}
if metadata.Description == "" {
// Same guard: an empty "description" in info.json must not mark the marker
// dirty on every listing (items whose text lives only in the title).
if desc, ok := infoString(info, "description"); ok && desc != "" {
metadata.Description = desc
dirty = true
}
dirty = backfillString(&metadata.Description, info, "description") || dirty
}
// Backfill the stable identity (yt-dlp's video id) from info.json so
// pre-existing items gain an identity on their next scan. Subscriptions match
// and prune items by this id (see FindByVideoID / PruneToIDSet).
if metadata.VideoID == "" {
if id, ok := infoString(info, "id"); ok && id != "" {
metadata.VideoID = id
dirty = true
}
dirty = backfillString(&metadata.VideoID, info, "id") || dirty
}
if metadata.FileDurations == nil {
@@ -385,7 +370,21 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
return item, nil
}
func (s *LibraryService) readOrCreateMetadata(itemDir string) (models.ItemMetadata, error) {
// backfillString sets *field from the first non-empty string value among
// info's keys, reporting whether it changed anything. An empty value in
// info.json must not count as a change: the marker would be rewritten on every
// scan forever without ever gaining a value.
func backfillString(field *string, info map[string]interface{}, keys ...string) bool {
for _, k := range keys {
if v, ok := infoString(info, k); ok && v != "" {
*field = v
return true
}
}
return false
}
func (s *LibraryService) readMetadata(itemDir string) (models.ItemMetadata, error) {
markerPath := filepath.Join(itemDir, itemMarkerName)
var metadata models.ItemMetadata
data, err := os.ReadFile(markerPath)
@@ -443,21 +442,20 @@ func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, stri
infoJSONFiles = append(infoJSONFiles, path)
continue
}
if imageExts[ext] {
if _, ok := imageExts[ext]; ok {
continue
}
if !mediaExts[ext] {
if _, ok := mediaExts[ext]; !ok {
continue
}
isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
duration := -1
_, isAudio := audioExts[ext]
mediaFiles = append(mediaFiles, models.MediaFile{
Filename: name,
Filepath: path,
IsAudio: isAudio,
Duration: duration,
Duration: -1,
})
}
@@ -501,10 +499,6 @@ func infoDuration(info map[string]interface{}) (int, bool) {
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
@@ -544,8 +538,8 @@ func primaryMediaFile(item *models.LibraryItem) *models.MediaFile {
return best
}
func (s *LibraryService) GetMediaFile(ctx context.Context, relPath, filename string) (string, error) {
item, err := s.GetByRelPath(ctx, relPath)
func (s *LibraryService) GetMediaFile(relPath, filename string) (string, error) {
item, err := s.GetByRelPath(relPath)
if err != nil {
return "", err
}
@@ -562,11 +556,11 @@ func (s *LibraryService) GetMediaFile(ctx context.Context, relPath, filename str
// is available (file not found, audio-only, or extraction failed) so the caller
// can serve an icon. An empty/unmatched filename yields false — every thumbnail
// is keyed to a specific media file.
func (s *LibraryService) ThumbnailForFile(ctx context.Context, relPath, filename string) (string, bool) {
func (s *LibraryService) ThumbnailForFile(relPath, filename string) (string, bool) {
if filename == "" {
return "", false
}
item, err := s.GetByRelPath(ctx, relPath)
item, err := s.GetByRelPath(relPath)
if err != nil {
return "", false
}
@@ -691,153 +685,125 @@ func (s *LibraryService) findImageAttachment(path string) int {
return best
}
// thumbAttempt is one ffmpeg invocation that may produce a thumbnail at out.
type thumbAttempt struct {
label string
out string
args []string
}
func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
ext := filepath.Ext(mf.Filepath)
base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail"
webpPath := base + ".webp"
jpgPath := base + ".jpg"
var attempts []thumbAttempt
// Collect every attempt's error so a genuine failure surfaces all of them
// rather than only the last fallback's stderr.
var attemptErrs []string
record := func(label string, err error) {
attemptErrs = append(attemptErrs, fmt.Sprintf("%s: %v", label, err))
}
tryWrite := func(outputPath string, args []string) (string, error) {
// Keep the final extension on the temp file so ffmpeg can infer the
// output muxer (it cannot for a bare ".tmp" suffix), then atomically
// rename into place.
outExt := filepath.Ext(outputPath)
tmpPath := strings.TrimSuffix(outputPath, outExt) + ".tmp" + outExt
os.Remove(tmpPath)
cmd := exec.Command(s.ffmpegPath, 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
}
// Prefer an embedded image attachment (e.g. yt-dlp's cover.webp/cover.jpg in
// MKV). These are attachment streams, not mappable video streams, so dump the
// raw bytes with -dump_attachment then transcode to a canonical WebP.
// MKV): dump its raw bytes, then transcode to a canonical WebP.
if idx := s.findImageAttachment(mf.Filepath); idx >= 0 {
if raw, err := os.CreateTemp("", "vidarchive-attachment-*"); err == nil {
rawPath := raw.Name()
raw.Close()
if rawPath, err := s.dumpAttachment(mf.Filepath, idx); err != nil {
attemptErrs = append(attemptErrs, fmt.Sprintf("attachment-dump: %v", err))
} else {
defer os.Remove(rawPath)
dumpArgs := []string{
fmt.Sprintf("-dump_attachment:t:%d", idx), rawPath,
"-i", mf.Filepath, "-y", "-t", "0", "-f", "null", "-",
}
if out, err := exec.Command(s.ffmpegPath, dumpArgs...).CombinedOutput(); err != nil {
record("attachment-dump", fmt.Errorf("%v\n%s", err, out))
} else if path, err := tryWrite(webpPath, []string{"-i", rawPath, "-c:v", "libwebp"}); err == nil {
return path, nil
} else {
record("attachment-webp", err)
}
attempts = append(attempts, thumbAttempt{"attachment-webp", webpPath,
[]string{"-i", rawPath, "-c:v", "libwebp"}})
}
}
// Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4.
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
} else {
record("embedded-webp", err)
}
// Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4,
// falling back to jpeg if libwebp or webp encoding fails.
embedded := []string{"-i", mf.Filepath, "-map", "0:v", "-map", "-0:V", "-vframes", "1"}
attempts = append(attempts,
thumbAttempt{"embedded-webp", webpPath, append(embedded, "-c:v", "libwebp")},
thumbAttempt{"embedded-jpg", jpgPath, append(embedded, "-q:v", "2")},
)
// Fallback to jpeg for embedded covers if libwebp or webp encoding failed.
embeddedJpgArgs := []string{
"-i", mf.Filepath,
"-map", "0:v",
"-map", "-0:V",
"-vframes", "1",
"-q:v", "2",
if !mf.IsAudio {
seekTime := "00:00:01"
if mf.Duration > 0 {
seekTime = util.FormatClock(mf.Duration / 2)
}
frame := []string{"-ss", seekTime, "-i", mf.Filepath, "-vframes", "1"}
attempts = append(attempts,
thumbAttempt{"frame-webp", webpPath, append(frame, "-c:v", "libwebp")},
thumbAttempt{"frame-jpg", jpgPath, append(frame, "-q:v", "2")},
)
}
if path, err := tryWrite(jpgPath, embeddedJpgArgs); err == nil {
return path, nil
} else {
record("embedded-jpg", err)
for _, a := range attempts {
path, err := s.tryWriteThumbnail(a.out, a.args)
if err == nil {
return path, nil
}
attemptErrs = append(attemptErrs, fmt.Sprintf("%s: %v", a.label, err))
}
if mf.IsAudio {
return "", fmt.Errorf("audio file has no thumbnail")
}
return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n"))
}
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)
}
// dumpAttachment extracts the raw bytes of attachment stream idx (e.g. the
// cover.jpg/cover.webp yt-dlp embeds into MKV with --embed-thumbnail) into a
// temp file and returns its path. The caller removes the file.
func (s *LibraryService) dumpAttachment(path string, idx int) (string, error) {
raw, err := os.CreateTemp("", "vidarchive-attachment-*")
if err != nil {
return "", err
}
frameArgs := []string{
"-ss", seekTime,
"-i", mf.Filepath,
"-vframes", "1",
"-c:v", "libwebp",
rawPath := raw.Name()
raw.Close()
dumpArgs := []string{
fmt.Sprintf("-dump_attachment:t:%d", idx), rawPath,
"-i", path, "-y", "-t", "0", "-f", "null", "-",
}
if path, err := tryWrite(webpPath, frameArgs); err == nil {
return path, nil
} else {
record("frame-webp", err)
if out, err := exec.Command(s.ffmpegPath, dumpArgs...).CombinedOutput(); err != nil {
os.Remove(rawPath)
return "", fmt.Errorf("%v\n%s", err, out)
}
return rawPath, nil
}
frameJpgArgs := []string{
"-ss", seekTime,
"-i", mf.Filepath,
"-vframes", "1",
"-q:v", "2",
// tryWriteThumbnail runs ffmpeg with args to produce outputPath. The temp file
// keeps the final extension so ffmpeg can infer the output muxer (it cannot for
// a bare ".tmp" suffix), then is atomically renamed into place.
func (s *LibraryService) tryWriteThumbnail(outputPath string, args []string) (string, error) {
outExt := filepath.Ext(outputPath)
tmpPath := strings.TrimSuffix(outputPath, outExt) + ".tmp" + outExt
os.Remove(tmpPath)
cmd := exec.Command(s.ffmpegPath, 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 path, err := tryWrite(jpgPath, frameJpgArgs); err == nil {
return path, nil
} else {
record("frame-jpg", err)
if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
os.Remove(tmpPath)
return "", fmt.Errorf("ffmpeg produced empty output")
}
return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n"))
if err := os.Rename(tmpPath, outputPath); err != nil {
os.Remove(tmpPath)
return "", err
}
return outputPath, nil
}
func (s *LibraryService) Delete(relPath string) error {
// Guard against an empty/root relPath, which would resolve to the library
// root and delete the entire library. This is reachable via a URL-encoded
// slash (e.g. POST /library/item/%2Fdelete), so the check must live here.
if strings.Trim(relPath, "/") == "" {
return fmt.Errorf("refusing to delete library root")
}
itemDir, err := s.resolveItemDir(relPath)
if err != nil {
return err
}
// resolveItemDir maps "" to the library root; double-check we never target it.
if cleanDir, err := filepath.EvalSymlinks(itemDir); err == nil {
if base, err := filepath.EvalSymlinks(s.libraryDir); err == nil && cleanDir == base {
return fmt.Errorf("refusing to delete library root")
}
// resolveItemDir maps ""/"." to the library root and resolves symlinks, so a
// result equal to the root (reachable via a URL-encoded slash, "sub/..", or a
// symlink pointing back at the root) must be refused — deleting it would
// wipe the entire library.
if itemDir == s.libraryRoot() {
return fmt.Errorf("refusing to delete library root")
}
// Evict the cached scan so the deletion is reflected immediately rather than
// lingering until the TTL expires.
@@ -887,7 +853,7 @@ func (s *LibraryService) eachItemDir(baseDir, logLabel string, fn func(itemDir s
if _, err := os.Stat(filepath.Join(itemDir, itemMarkerName)); err != nil {
continue
}
meta, err := s.readOrCreateMetadata(itemDir)
meta, err := s.readMetadata(itemDir)
if err != nil {
log.Printf("%s: skipping %s: %v", logLabel, itemDir, err)
continue
@@ -944,8 +910,8 @@ func (s *LibraryService) GetSubtitlePath(relPath, lang string) (string, error) {
return filepath.Join(dir, lang+".vtt"), nil
}
func (s *LibraryService) GetSubtitles(ctx context.Context, relPath string) ([]models.SubtitleTrack, error) {
item, err := s.GetByRelPath(ctx, relPath)
func (s *LibraryService) GetSubtitles(relPath string) ([]models.SubtitleTrack, error) {
item, err := s.GetByRelPath(relPath)
if err != nil {
return nil, err
}
@@ -962,7 +928,7 @@ func (s *LibraryService) GetSubtitles(ctx context.Context, relPath string) ([]mo
tracks = append(tracks, models.SubtitleTrack{
Lang: lang,
Label: lang,
Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang),
Src: fmt.Sprintf("/media/item/%s/subtitles/%s", util.URLEncodePath(relPath), lang),
})
}
return tracks, nil
@@ -992,7 +958,7 @@ func (s *LibraryService) GetSubtitles(ctx context.Context, relPath string) ([]mo
tracks = append(tracks, models.SubtitleTrack{
Lang: lang,
Label: stream.Label,
Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang),
Src: fmt.Sprintf("/media/item/%s/subtitles/%s", util.URLEncodePath(relPath), lang),
})
}
return tracks, nil
@@ -1074,8 +1040,8 @@ func (s *LibraryService) extractSubtitleToVTT(inputPath, outputPath string, stre
// GetMetadata probes media details for the named file within an item. An empty
// filename (or one that doesn't match) falls back to the item's primary media
// file, so the detail view shows metadata for whichever file is selected.
func (s *LibraryService) GetMetadata(ctx context.Context, relPath, filename string) (*MediaMetadata, error) {
item, err := s.GetByRelPath(ctx, relPath)
func (s *LibraryService) GetMetadata(relPath, filename string) (*MediaMetadata, error) {
item, err := s.GetByRelPath(relPath)
if err != nil {
return nil, err
}
@@ -1171,14 +1137,6 @@ func (s *LibraryService) probeMedia(path string) (*MediaMetadata, error) {
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 ""
@@ -1191,16 +1149,8 @@ func formatBitrate(bitRate string) string {
}
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 {
var num, den float64
if _, err := fmt.Sscanf(rate, "%f/%f", &num, &den); err != nil || den == 0 {
return ""
}
fps := num / den
▾Minternal/service/library_test.go
@@ -1,7 +1,6 @@
package service
import (
"context"
"fmt"
"os"
"os/exec"
@@ -118,7 +117,6 @@ func TestInfoDuration(t *testing.T) {
}{
{"float", map[string]interface{}{"duration": 149.4}, 149, true},
{"float rounds up", map[string]interface{}{"duration": 149.6}, 150, true},
{"int", map[string]interface{}{"duration": 200}, 200, true},
{"string", map[string]interface{}{"duration": "12.0"}, 12, true},
{"bad string", map[string]interface{}{"duration": "abc"}, 0, false},
{"missing", map[string]interface{}{}, 0, false},
@@ -134,20 +132,6 @@ func TestInfoDuration(t *testing.T) {
}
}
func TestUrlEncodePath(t *testing.T) {
tests := []struct{ in, want string }{
{"a/b c", "a/b%20c"},
{"/leading/trailing/", "leading/trailing"},
{"Hatsune Miku [id]", "Hatsune%20Miku%20%5Bid%5D"},
{"plain", "plain"},
}
for _, tc := range tests {
if got := urlEncodePath(tc.in); got != tc.want {
t.Errorf("urlEncodePath(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestParseFPS(t *testing.T) {
tests := []struct{ in, want string }{
{"30/1", "30"},
@@ -367,7 +351,7 @@ func TestScanItemDerivesMetadataFromInfoJSON(t *testing.T) {
"info.json": info,
})
item, err := svc.GetByRelPath(context.Background(), "x")
item, err := svc.GetByRelPath("x")
if err != nil {
t.Fatalf("GetByRelPath: %v", err)
}
@@ -396,7 +380,7 @@ func TestScanItemUsesFileDurations(t *testing.T) {
marker := "name = \"M\"\n\n[file_durations]\n \"a.mp4\" = 30\n \"b.mp4\" = 12\n"
writeItem(t, dir, "multi", marker, map[string]string{"a.mp4": "v", "b.mp4": "v"})
item, err := svc.GetByRelPath(context.Background(), "multi")
item, err := svc.GetByRelPath("multi")
if err != nil {
t.Fatalf("GetByRelPath: %v", err)
}
@@ -425,7 +409,7 @@ func TestScanItemDoesNotRewriteConvergedMarker(t *testing.T) {
// First read may enrich the marker (e.g. cache per-file durations), after
// which it has converged.
if _, err := svc.GetByRelPath(context.Background(), "y"); err != nil {
if _, err := svc.GetByRelPath("y"); err != nil {
t.Fatalf("warm-up read: %v", err)
}
@@ -436,7 +420,7 @@ func TestScanItemDoesNotRewriteConvergedMarker(t *testing.T) {
// Subsequent reads must not rewrite a converged marker.
for i := 0; i < 3; i++ {
if _, err := svc.GetByRelPath(context.Background(), "y"); err != nil {
if _, err := svc.GetByRelPath("y"); err != nil {
t.Fatalf("GetByRelPath: %v", err)
}
}
@@ -466,7 +450,7 @@ func TestScanItemEmptyInfoFieldsDoNotRewriteMarker(t *testing.T) {
markerPath := filepath.Join(dir, "z", itemMarkerName)
for i := 0; i < 3; i++ {
if _, err := svc.GetByRelPath(context.Background(), "z"); err != nil {
if _, err := svc.GetByRelPath("z"); err != nil {
t.Fatalf("GetByRelPath: %v", err)
}
}
@@ -484,11 +468,11 @@ func TestScanCacheReusesWithinTTL(t *testing.T) {
writeItem(t, dir, "x", "name = \"X\"\nsource_url = \"u\"\n", map[string]string{"x.mp4": "v"})
// Within the TTL, repeated lookups return the same cached scan (no rescan).
a, err := svc.GetByRelPath(context.Background(), "x")
a, err := svc.GetByRelPath("x")
if err != nil {
t.Fatalf("first lookup: %v", err)
}
b, err := svc.GetByRelPath(context.Background(), "x")
b, err := svc.GetByRelPath("x")
if err != nil {
t.Fatalf("second lookup: %v", err)
}
@@ -508,8 +492,8 @@ func TestScanCacheReusesWithinTTL(t *testing.T) {
svc2, dir2 := newLibrary(t)
svc2.scanTTL = 0
writeItem(t, dir2, "y", "name = \"Y\"\n", map[string]string{"y.mp4": "v"})
c, _ := svc2.GetByRelPath(context.Background(), "y")
d, _ := svc2.GetByRelPath(context.Background(), "y")
c, _ := svc2.GetByRelPath("y")
d, _ := svc2.GetByRelPath("y")
if c == nil || d == nil || c == d {
t.Error("with TTL disabled each lookup should rescan, not reuse a pointer")
}
@@ -567,7 +551,7 @@ func TestThumbnailPrefersExistingGenerated(t *testing.T) {
"video.thumbnail.webp": "GENERATED",
})
path, ok := svc.ThumbnailForFile(context.Background(), "item", "video.mp4")
path, ok := svc.ThumbnailForFile("item", "video.mp4")
if !ok {
t.Fatal("expected a thumbnail")
}
@@ -582,7 +566,7 @@ func TestThumbnailExtractsOnDemand(t *testing.T) {
itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
path, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
path, ok := svc.ThumbnailForFile("vid", "vid.mp4")
if !ok {
t.Fatal("expected on-demand extraction to succeed")
}
@@ -607,7 +591,7 @@ func TestThumbnailRetriesAfterDeletion(t *testing.T) {
itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
first, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
first, ok := svc.ThumbnailForFile("vid", "vid.mp4")
if !ok {
t.Fatal("first extraction failed")
}
@@ -615,7 +599,7 @@ func TestThumbnailRetriesAfterDeletion(t *testing.T) {
t.Fatal(err)
}
// A failure/absence must not be cached permanently: re-request re-extracts.
second, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
second, ok := svc.ThumbnailForFile("vid", "vid.mp4")
if !ok {
t.Fatal("re-extraction after deletion failed (failure was cached)")
}
@@ -635,7 +619,7 @@ func TestThumbnailConcurrentSingleExtraction(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
if _, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4"); !ok {
if _, ok := svc.ThumbnailForFile("vid", "vid.mp4"); !ok {
t.Error("concurrent Thumbnail failed")
}
}()
@@ -665,12 +649,11 @@ func TestThumbnailForFileIsPerFile(t *testing.T) {
makeTestVideo(t, filepath.Join(itemDir, "a.mp4"))
makeTestVideo(t, filepath.Join(itemDir, "b.mp4"))
ctx := context.Background()
pa, ok := svc.ThumbnailForFile(ctx, "multi", "a.mp4")
pa, ok := svc.ThumbnailForFile("multi", "a.mp4")
if !ok {
t.Fatal("thumbnail for a.mp4 failed")
}
pb, ok := svc.ThumbnailForFile(ctx, "multi", "b.mp4")
pb, ok := svc.ThumbnailForFile("multi", "b.mp4")
if !ok {
t.Fatal("thumbnail for b.mp4 failed")
}
@@ -685,12 +668,12 @@ func TestThumbnailForFileIsPerFile(t *testing.T) {
}
// An unknown file yields no thumbnail (caller falls back to an icon).
if _, ok := svc.ThumbnailForFile(ctx, "multi", "nope.mp4"); ok {
if _, ok := svc.ThumbnailForFile("multi", "nope.mp4"); ok {
t.Error("unknown file should not produce a thumbnail")
}
// Every thumbnail is keyed to a specific file: an empty filename yields none.
if _, ok := svc.ThumbnailForFile(ctx, "multi", ""); ok {
if _, ok := svc.ThumbnailForFile("multi", ""); ok {
t.Error("empty filename should not produce a thumbnail")
}
}
@@ -702,15 +685,14 @@ func TestGetMetadataForSelectedFile(t *testing.T) {
makeTestVideoSize(t, filepath.Join(itemDir, "small.mp4"), "64x64")
makeTestVideoSize(t, filepath.Join(itemDir, "big.mp4"), "128x72")
ctx := context.Background()
m1, err := svc.GetMetadata(ctx, "multi", "small.mp4")
m1, err := svc.GetMetadata("multi", "small.mp4")
if err != nil {
t.Fatalf("GetMetadata small: %v", err)
}
if m1.Resolution != "64x64" {
t.Errorf("small.mp4 resolution = %q, want 64x64", m1.Resolution)
}
m2, err := svc.GetMetadata(ctx, "multi", "big.mp4")
m2, err := svc.GetMetadata("multi", "big.mp4")
if err != nil {
t.Fatalf("GetMetadata big: %v", err)
}
@@ -734,7 +716,7 @@ func TestThumbnailConcurrencyBounded(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
svc.ThumbnailForFile(context.Background(), "many", fmt.Sprintf("c%d.mp4", i))
svc.ThumbnailForFile("many", fmt.Sprintf("c%d.mp4", i))
}()
}
wg.Wait()
@@ -760,7 +742,7 @@ func TestThumbnailUsesEmbeddedAttachment(t *testing.T) {
t.Fatal("findImageAttachment did not find the embedded cover")
}
path, ok := svc.ThumbnailForFile(context.Background(), "att", "v.mkv")
path, ok := svc.ThumbnailForFile("att", "v.mkv")
if !ok {
t.Fatal("thumbnail extraction failed")
}
@@ -777,9 +759,8 @@ func TestThumbnailNegativeCacheSkipsReextraction(t *testing.T) {
writeItem(t, dir, "bad", "name = \"B\"\nduration = -1\n", map[string]string{
"broken.mp4": "not actually a video",
})
ctx := context.Background()
if _, ok := svc.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
if _, ok := svc.ThumbnailForFile("bad", "broken.mp4"); ok {
t.Fatal("expected extraction to fail for a non-video file")
}
attempts1 := atomic.LoadInt32(&svc.extractAttempts)
@@ -789,7 +770,7 @@ func TestThumbnailNegativeCacheSkipsReextraction(t *testing.T) {
// A second request is served from the in-process negative cache: no new
// ffmpeg attempt.
if _, ok := svc.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
if _, ok := svc.ThumbnailForFile("bad", "broken.mp4"); ok {
t.Fatal("expected the cached failure to persist")
}
if attempts2 := atomic.LoadInt32(&svc.extractAttempts); attempts2 != attempts1 {
@@ -798,7 +779,7 @@ func TestThumbnailNegativeCacheSkipsReextraction(t *testing.T) {
// The cache is in-process only: a fresh service (≈ a restart) retries.
fresh := NewLibraryService(dir, "ffmpeg", "ffprobe")
if _, ok := fresh.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
if _, ok := fresh.ThumbnailForFile("bad", "broken.mp4"); ok {
t.Fatal("fresh service still fails (file is unextractable)")
}
if atomic.LoadInt32(&fresh.extractAttempts) == 0 {
@@ -818,7 +799,7 @@ func TestThumbnailAudioOnlyHasNone(t *testing.T) {
t.Fatalf("make audio: %v\n%s", err, out)
}
if path, ok := svc.ThumbnailForFile(context.Background(), "aud", "aud.mp3"); ok {
if path, ok := svc.ThumbnailForFile("aud", "aud.mp3"); ok {
t.Errorf("audio-only item should have no thumbnail, got %q", path)
}
}
▾Minternal/service/settings.go
@@ -1,7 +1,6 @@
package service
import (
"strconv"
"vidarchive/internal/models"
"vidarchive/internal/repository"
)
@@ -38,17 +37,6 @@ func (s *SettingsService) SetAutoRefreshDownloads(enabled bool) error {
return s.repo.Set("auto_refresh_downloads", val)
}
func (s *SettingsService) GetRefreshInterval() (int, error) {
val, err := s.repo.Get("refresh_interval")
if err != nil {
return 5, err
}
if val == "" {
return 5, nil
}
return strconv.Atoi(val)
}
func (s *SettingsService) SetCookies(cookies string) error {
return s.repo.Set("cookies", cookies)
}
▾Minternal/service/settings_test.go
@@ -71,13 +71,4 @@ func TestSettingsService(t *testing.T) {
if settings.AutoRefreshDownloads {
t.Error("expected auto_refresh_downloads to be false")
}
// Test GetRefreshInterval
interval, err := svc.GetRefreshInterval()
if err != nil {
t.Fatalf("failed to get refresh interval: %v", err)
}
if interval != 10 {
t.Errorf("expected refresh interval 10, got %d", interval)
}
}
▾Ainternal/util/util.go
@@ -0,0 +1,50 @@
// Package util holds small formatting helpers shared by the service and
// handler layers.
package util
import (
"fmt"
"net/url"
"strings"
)
// FormatClock renders a duration in whole seconds as H:MM:SS, or M:SS when
// under an hour.
func FormatClock(seconds int) string {
if seconds < 0 {
seconds = 0
}
m := seconds / 60
s := seconds % 60
h := m / 60
m = m % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
// FormatBytes renders a byte count as a compact human-readable size using
// 1024-based (binary) units with the matching iB suffix.
func FormatBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
// URLEncodePath escapes each segment of a slash-separated path, preserving the
// slashes themselves.
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, "/")
}
▾Ainternal/util/util_test.go
@@ -0,0 +1,52 @@
package util
import "testing"
func TestURLEncodePath(t *testing.T) {
tests := []struct{ in, want string }{
{"a/b c", "a/b%20c"},
{"/leading/trailing/", "leading/trailing"},
{"Hatsune Miku [id]", "Hatsune%20Miku%20%5Bid%5D"},
{"plain", "plain"},
}
for _, tc := range tests {
if got := URLEncodePath(tc.in); got != tc.want {
t.Errorf("URLEncodePath(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestFormatClock(t *testing.T) {
tests := []struct {
in int
want string
}{
{0, "0:00"},
{65, "1:05"},
{3599, "59:59"},
{3600, "1:00:00"},
{3723, "1:02:03"},
}
for _, tc := range tests {
if got := FormatClock(tc.in); got != tc.want {
t.Errorf("FormatClock(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestFormatBytes(t *testing.T) {
tests := []struct {
in int64
want string
}{
{512, "512 B"},
{1024, "1.0 KiB"},
{1536, "1.5 KiB"},
{1048576, "1.0 MiB"},
}
for _, tc := range tests {
if got := FormatBytes(tc.in); got != tc.want {
t.Errorf("FormatBytes(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}