fix(library): on-demand thumbnails, multi-file cards, and metadata fixes

AuthorKonata <konata@posteo.jp>
Date
Commit185024bc08ad9cad37286edd52f6c4ebbf43bfe6
Parent080e030
15 files changed, 1725 insertions(+), 188 deletions(-)
▾MContainerfile
@@ -17,7 +17,6 @@ COPY --from=builder /build/vidarchive /app/vidarchive
COPY --from=builder /build/web /app/web
ENV VIDARCHIVE_DATA_DIR=/data
ENV VIDARCHIVE_DOWNLOAD_DIR=/data/downloads
ENV VIDARCHIVE_PORT=8080
VOLUME ["/data"]
▾Mcmd/vidarchive/main.go
@@ -38,7 +38,7 @@ func main() {
settingsRepo := repository.NewSettingsRepository(db)
presetSvc := service.NewPresetService(presetRepo)
librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.TempDir)
librarySvc := service.NewLibraryService(cfg.LibraryDir)
settingsSvc := service.NewSettingsService(settingsRepo)
downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, cfg)
▾Mcompose.yml
@@ -11,7 +11,6 @@ services:
environment:
- VIDARCHIVE_PORT=8080
- VIDARCHIVE_DATA_DIR=/data
- VIDARCHIVE_DOWNLOAD_DIR=/data/downloads
- VIDARCHIVE_WORKERS=2
- VIDARCHIVE_REFRESH_INTERVAL=5
# Uncomment to run behind HTTPS proxy:
▾Minternal/config/config.go
@@ -22,7 +22,7 @@ type Config struct {
func New() *Config {
dataDir := getEnv("VIDARCHIVE_DATA_DIR", "./data")
return &Config{
Port: getEnvInt("VIDARCHIVE_PORT", 8080),
DataDir: dataDir,
▾Minternal/handler/handler.go
@@ -66,6 +66,12 @@ func loadTemplates() (*template.Template, error) {
return strings.Join(parts, "/")
},
"sub": func(a, b int) int { return a - b },
// isLongText reports whether text spans more than ~2 lines, so the detail
// view can make long descriptions collapsible. Uses rune count (not bytes)
// so CJK text isn't flagged early.
"isLongText": func(s string) bool {
return strings.Count(s, "\n") >= 2 || len([]rune(s)) > 180
},
})
if repoRoot == "" {
@@ -216,10 +222,6 @@ func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
Filter: filter,
},
})
for _, item := range items {
go h.librarySvc.EnsureThumbnails(item.RelPath)
}
}
func normalizeRelPath(r *http.Request) string {
@@ -242,7 +244,8 @@ func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) {
item, err := h.librarySvc.GetByRelPath(relPath)
ctx := r.Context()
item, err := h.librarySvc.GetByRelPath(ctx, relPath)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
@@ -253,8 +256,8 @@ func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.R
selectedFilename = item.MediaFiles[0].Filename
}
meta, _ := h.librarySvc.GetMetadata(relPath)
subtitles, _ := h.librarySvc.GetSubtitles(relPath)
meta, _ := h.librarySvc.GetMetadata(ctx, relPath, selectedFilename)
subtitles, _ := h.librarySvc.GetSubtitles(ctx, relPath)
h.renderWithRequest(w, r, "library_detail", PageData{
Title: item.Name,
@@ -271,8 +274,6 @@ func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.R
Subtitles: subtitles,
},
})
go h.librarySvc.EnsureThumbnails(relPath)
}
func (h *Handler) ServeMediaItem(w http.ResponseWriter, r *http.Request) {
@@ -296,7 +297,7 @@ func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Requ
return
}
mediaPath, err := h.librarySvc.GetMediaFile(relPath, filename)
mediaPath, err := h.librarySvc.GetMediaFile(r.Context(), relPath, filename)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
@@ -306,18 +307,37 @@ func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Requ
}
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 {
ctx := r.Context()
filename := r.URL.Query().Get("file")
if thumb, ok := h.librarySvc.ThumbnailForFile(ctx, relPath, filename); ok {
http.ServeFile(w, r, thumb)
return
}
// 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 isAudioFile(item, filename) {
icon = "audio-icon.svg"
}
http.ServeFile(w, r, filepath.Join(repoRoot, "web", "static", "icons", icon))
return
}
http.ServeFile(w, r, filepath.Join(repoRoot, "web", "static", "icons", icon))
}
http.ServeFile(w, r, thumbs[0])
// isAudioFile reports whether the named file (or, if unnamed, the first media
// file) of an item is audio.
func isAudioFile(item *models.LibraryItem, filename string) bool {
if filename != "" {
for _, mf := range item.MediaFiles {
if mf.Filename == filename {
return mf.IsAudio
}
}
return false
}
return len(item.MediaFiles) > 0 && item.MediaFiles[0].IsAudio
}
func (h *Handler) serveSubtitles(relPath string, w http.ResponseWriter, r *http.Request) {
▾Minternal/models/models.go
@@ -6,23 +6,23 @@ import (
)
type Preset struct {
ID int64
Name string
Description string
IsDefault bool
FormatMode string
Format string
Quality string
CustomFormat string
ExtractAudio bool
AudioFormat string
EmbedSubs bool
SubLangs string
EmbedThumbnail bool
EmbedMetadata bool
WriteInfoJSON bool
CustomFlags string
CreatedAt time.Time
ID int64
Name string
Description string
IsDefault bool
FormatMode string
Format string
Quality string
CustomFormat string
ExtractAudio bool
AudioFormat string
EmbedSubs bool
SubLangs string
EmbedThumbnail bool
EmbedMetadata bool
WriteInfoJSON bool
CustomFlags string
CreatedAt time.Time
}
type LibraryItem struct {
@@ -36,11 +36,10 @@ type LibraryItem struct {
}
type MediaFile struct {
Filename string
Filepath string
IsAudio bool
HasEmbeddedThumbnail bool
Duration int
Filename string
Filepath string
IsAudio bool
Duration int
}
type Download struct {
▾Minternal/server/server.go
@@ -11,6 +11,7 @@ import (
"vidarchive/internal/config"
"vidarchive/internal/handler"
"vidarchive/internal/service"
)
type Server struct {
@@ -40,6 +41,7 @@ func (s *Server) setupRoutes() {
s.router.Use(middleware.Logger)
s.router.Use(middleware.Recoverer)
s.router.Use(s.securityHeaders)
s.router.Use(itemCacheMiddleware)
s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(staticRoot))))
@@ -71,6 +73,14 @@ func (s *Server) setupRoutes() {
s.router.Get("/api/presets/{id}/flags", s.handler.GetPresetFlags)
}
// itemCacheMiddleware attaches a fresh per-request library item cache so a
// single request that looks up the same item multiple times only scans it once.
func itemCacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(service.WithItemCache(r.Context())))
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
▾Minternal/server/server_test.go
@@ -4,8 +4,12 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"vidarchive/internal/config"
@@ -39,7 +43,7 @@ func setupTestServer(t *testing.T) (*Server, *config.Config, func()) {
settingsRepo := repository.NewSettingsRepository(db)
presetSvc := service.NewPresetService(presetRepo)
librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.TempDir)
librarySvc := service.NewLibraryService(cfg.LibraryDir)
settingsSvc := service.NewSettingsService(settingsRepo)
downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, cfg)
workerPool := worker.New(downloadSvc, cfg.Workers)
@@ -154,25 +158,27 @@ func TestPathTraversalBlocked(t *testing.T) {
}
}
func TestExistingImageThumbnail(t *testing.T) {
func TestPerFileExistingThumbnailServed(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
// A pre-existing per-file thumbnail (<stem>.thumbnail.webp) is served for the
// matching ?file= request without re-extraction.
createItem(t, cfg.LibraryDir, "thumb-item", "Thumb Item", map[string]string{
"cover.jpg": "fake image",
"video.mp4": "dummy video",
"video.mp4": "dummy video",
"video.thumbnail.webp": "GENERATED-THUMB",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/media/item/thumb-item/thumbnail", nil)
req := httptest.NewRequest("GET", "/media/item/thumb-item/thumbnail?file=video.mp4", 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))
if body, _ := io.ReadAll(w.Body); string(body) != "GENERATED-THUMB" {
t.Errorf("expected the existing per-file thumbnail, got %q", string(body))
}
}
@@ -198,6 +204,137 @@ func TestAudioThumbnailPlaceholder(t *testing.T) {
}
}
func TestMultiFileCardThumbnailURLsDecodeToFilenames(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
// Filenames with spaces are the case that broke: the template must emit a
// query value that the handler decodes back to the exact filename (the bug
// was double-escaping spaces to %2b, which decodes to '+').
files := map[string]string{
"01 - Color Bars.mp4": "v",
"02 - Test Pattern.mp4": "v",
}
createItem(t, cfg.LibraryDir, "multi", "Multi", files)
req := httptest.NewRequest("GET", "/library", nil)
w := httptest.NewRecorder()
srv.Router().ServeHTTP(w, req)
body, _ := io.ReadAll(w.Body)
re := regexp.MustCompile(`thumbnail\?file=([^"]+)`)
matches := re.FindAllStringSubmatch(string(body), -1)
if len(matches) != len(files) {
t.Fatalf("expected %d per-file thumbnail URLs in the card, got %d", len(files), len(matches))
}
for _, m := range matches {
vals, err := url.ParseQuery("file=" + m[1])
if err != nil {
t.Fatalf("bad query %q: %v", m[1], err)
}
got := vals.Get("file")
if _, ok := files[got]; !ok {
t.Errorf("thumbnail file=%q decodes to %q, which is not a real filename (double-encoding regression)", m[1], got)
}
}
}
func TestListingDoesNotExtractThumbnails(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "novid", "No Thumb", map[string]string{
"video.mp4": "dummy video",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/library", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Rendering the listing must not have created any thumbnail file.
entries, err := os.ReadDir(filepath.Join(cfg.LibraryDir, "novid"))
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.Contains(e.Name(), ".thumbnail.") {
t.Errorf("listing extracted a thumbnail (%q) — should happen on request only", e.Name())
}
}
}
func TestGeneratedThumbnailServedOverIcon(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "gen", "Gen", map[string]string{
"video.mp4": "dummy video",
"video.thumbnail.webp": "WEBPDATA",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/media/item/gen/thumbnail?file=video.mp4", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if body, _ := io.ReadAll(w.Body); string(body) != "WEBPDATA" {
t.Errorf("expected generated thumbnail contents, got %q", string(body))
}
}
func TestThumbnailExtractedOnRequest(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not on PATH")
}
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
itemDir := filepath.Join(cfg.LibraryDir, "realvid")
createItem(t, cfg.LibraryDir, "realvid", "Real", nil)
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=3:size=64x64:rate=5",
"-pix_fmt", "yuv420p", filepath.Join(itemDir, "realvid.mp4"), "-y")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("make test video: %v\n%s", err, out)
}
router := srv.Router()
req := httptest.NewRequest("GET", "/media/item/realvid/thumbnail?file=realvid.mp4", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
t.Errorf("expected image content-type, got %q", ct)
}
body, _ := io.ReadAll(w.Body)
if len(body) == 0 {
t.Error("served thumbnail body was empty")
}
// A real thumbnail file should now exist on disk, with no temp leftovers.
entries, _ := os.ReadDir(itemDir)
var found bool
for _, e := range entries {
if strings.Contains(e.Name(), ".thumbnail.") {
found = true
}
if strings.Contains(e.Name(), ".tmp") {
t.Errorf("leftover temp file %q", e.Name())
}
}
if !found {
t.Error("no thumbnail file written to disk after request")
}
}
func TestLibraryPageIsFast(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
▾Ainternal/service/download_test.go
@@ -0,0 +1,177 @@
package service
import (
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
"vidarchive/internal/config"
"vidarchive/internal/models"
)
func TestSanitizeDirName(t *testing.T) {
tests := []struct{ in, want string }{
{"normal title", "normal title"},
{"a/b:c*d?e\"f<g>h|i", "a-b-c-d-e-f-g-h-i"},
{" trimmed ", "trimmed"},
{"", "untitled"},
{"///", "---"},
}
for _, tc := range tests {
if got := sanitizeDirName(tc.in); got != tc.want {
t.Errorf("sanitizeDirName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestUniqueDir(t *testing.T) {
base := t.TempDir()
svc := &DownloadService{}
first := svc.uniqueDir(base, "item")
if filepath.Base(first) != "item" {
t.Errorf("first uniqueDir = %q, want .../item", first)
}
if err := os.MkdirAll(first, 0755); err != nil {
t.Fatal(err)
}
second := svc.uniqueDir(base, "item")
if filepath.Base(second) != "item-1" {
t.Errorf("second uniqueDir = %q, want .../item-1", second)
}
if err := os.MkdirAll(second, 0755); err != nil {
t.Fatal(err)
}
third := svc.uniqueDir(base, "item")
if filepath.Base(third) != "item-2" {
t.Errorf("third uniqueDir = %q, want .../item-2", third)
}
}
func TestDeriveItemName(t *testing.T) {
svc := &DownloadService{}
itemDir := t.TempDir()
// info.json title wins and is sanitized.
infoPath := filepath.Join(itemDir, "info.json")
if err := os.WriteFile(infoPath, []byte(`{"title":"Cool: Video"}`), 0644); err != nil {
t.Fatal(err)
}
bigName := "big.mp4"
if err := os.WriteFile(filepath.Join(itemDir, bigName), []byte("xxxxxxxxxx"), 0644); err != nil {
t.Fatal(err)
}
media := []os.DirEntry{dirEntry(t, itemDir, bigName)}
if got := svc.deriveItemName(itemDir, infoPath, media); got != "Cool- Video" {
t.Errorf("deriveItemName with info.json = %q, want %q", got, "Cool- Video")
}
// Without info.json, falls back to the largest media file's stem.
small := "small.mp4"
if err := os.WriteFile(filepath.Join(itemDir, small), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
media = []os.DirEntry{dirEntry(t, itemDir, small), dirEntry(t, itemDir, bigName)}
if got := svc.deriveItemName(itemDir, "", media); got != "big" {
t.Errorf("deriveItemName fallback = %q, want %q (largest file stem)", got, "big")
}
}
func dirEntry(t *testing.T, dir, name string) os.DirEntry {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Name() == name {
return e
}
}
t.Fatalf("dir entry %q not found", name)
return nil
}
func TestParseFormatList(t *testing.T) {
output := `[info] Available formats:
ID EXT RESOLUTION FPS
18 mp4 640x360 30
137 mp4 1920x1080 60
140 m4a audio only
`
formats := parseFormatList(output)
if len(formats) != 3 {
t.Fatalf("expected 3 formats, got %d: %+v", len(formats), formats)
}
if formats[0].ID != "18" || formats[0].Ext != "mp4" {
t.Errorf("format[0] = %+v", formats[0])
}
if formats[1].Resolution != "1920x1080" {
t.Errorf("format[1] resolution = %q, want 1920x1080", formats[1].Resolution)
}
}
// TestImportItemDir exercises the full import: media + info.json + subtitles get
// sorted into a named item directory with a marker. Uses ffmpeg to produce real
// media so the mimetype-based classification in importItemDir matches.
func TestImportItemDir(t *testing.T) {
requireFFmpeg(t)
libDir := t.TempDir()
svc := &DownloadService{cfg: &config.Config{LibraryDir: libDir}}
src := t.TempDir()
makeTestVideo(t, filepath.Join(src, "raw.mp4"))
if err := os.WriteFile(filepath.Join(src, "info.json"), []byte(`{"title":"My Clip"}`), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "raw.en.srt"), []byte("1\n00:00:00,000 --> 00:00:01,000\nhi\n"), 0644); err != nil {
t.Fatal(err)
}
if err := svc.importItemDir("https://example.com/v", src, libDir); err != nil {
t.Fatalf("importItemDir: %v", err)
}
targetDir := filepath.Join(libDir, "My Clip")
if _, err := os.Stat(filepath.Join(targetDir, itemMarkerName)); err != nil {
t.Errorf("marker not created: %v", err)
}
if _, err := os.Stat(filepath.Join(targetDir, "info.json")); err != nil {
t.Errorf("info.json not moved: %v", err)
}
if _, err := os.Stat(filepath.Join(targetDir, "raw.mp4")); err != nil {
t.Errorf("media not moved: %v", err)
}
if _, err := os.Stat(filepath.Join(targetDir, subtitlesDirName, "raw.en.srt")); err != nil {
t.Errorf("subtitle not moved into subtitles dir: %v", err)
}
// The marker should record the source URL.
markerData, _ := os.ReadFile(filepath.Join(targetDir, itemMarkerName))
if want := "https://example.com/v"; !strings.Contains(string(markerData), want) {
t.Errorf("marker missing source url %q: %s", want, markerData)
}
}
func TestImportDownloadedItemsRejectsOutputTraversal(t *testing.T) {
libDir := t.TempDir()
svc := &DownloadService{cfg: &config.Config{LibraryDir: libDir}}
// A temp download dir with one item subdir.
tempDir := t.TempDir()
itemDir := filepath.Join(tempDir, "item-00001")
if err := os.MkdirAll(itemDir, 0755); err != nil {
t.Fatal(err)
}
d := &models.Download{
URL: "u",
OutputDir: sql.NullString{String: "../escape", Valid: true},
}
if err := svc.importDownloadedItems(d, tempDir); err == nil {
t.Error("expected path-traversal output dir to be rejected")
}
}
▾Minternal/service/library.go
@@ -1,9 +1,9 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/url"
@@ -14,6 +14,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"github.com/BurntSushi/toml"
@@ -32,14 +33,67 @@ var imageExts = map[string]bool{
".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true,
}
// maxConcurrentThumbnails caps how many ffmpeg extraction processes may run at
// once, so a freshly loaded library page (which fires one thumbnail request per
// visible item) cannot spawn an unbounded ffmpeg storm.
const maxConcurrentThumbnails = 3
type LibraryService struct {
libraryDir string
tempDir string
thumbOnce sync.Map
// 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 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).
thumbFailed sync.Map
// extraction counters, for observability/tests.
extractInFlight int32
extractMaxConcurrent int32
extractAttempts int32
}
func NewLibraryService(libraryDir string) *LibraryService {
return &LibraryService{
libraryDir: libraryDir,
thumbSem: make(chan struct{}, maxConcurrentThumbnails),
}
}
// itemCache memoizes scanned items within the scope of a single request, so
// repeated lookups of the same item (e.g. the detail page calling GetByRelPath,
// GetMetadata and GetSubtitles) don't each rescan the item directory.
type itemCache struct {
mu sync.Mutex
items map[string]*models.LibraryItem
}
type itemCacheKeyType struct{}
var itemCacheKey itemCacheKeyType
// WithItemCache returns a context carrying a fresh per-request item cache.
// Lookups made with the returned context reuse already-scanned items; a context
// without a cache (e.g. context.Background()) always rescans.
func WithItemCache(ctx context.Context) context.Context {
return context.WithValue(ctx, itemCacheKey, &itemCache{items: make(map[string]*models.LibraryItem)})
}
func NewLibraryService(libraryDir, tempDir string) *LibraryService {
return &LibraryService{libraryDir: libraryDir, tempDir: tempDir}
func cacheFromCtx(ctx context.Context) *itemCache {
if ctx == nil {
return nil
}
c, _ := ctx.Value(itemCacheKey).(*itemCache)
return c
}
func (s *LibraryService) resolveItemDir(relPath string) (string, error) {
@@ -132,8 +186,19 @@ func (s *LibraryService) GetAll(path, sortBy, filter string) ([]*models.LibraryI
return items, folders, nil
}
func (s *LibraryService) GetByRelPath(relPath string) (*models.LibraryItem, error) {
func (s *LibraryService) GetByRelPath(ctx context.Context, relPath string) (*models.LibraryItem, error) {
relPath = strings.Trim(relPath, "/")
cache := cacheFromCtx(ctx)
if cache != nil {
cache.mu.Lock()
item, ok := cache.items[relPath]
cache.mu.Unlock()
if ok {
return item, nil
}
}
itemDir, err := s.resolveItemDir(relPath)
if err != nil {
return nil, fmt.Errorf("item not found")
@@ -142,7 +207,17 @@ func (s *LibraryService) GetByRelPath(relPath string) (*models.LibraryItem, erro
if _, err := os.Stat(markerPath); err != nil {
return nil, fmt.Errorf("item not found")
}
return s.scanItem(itemDir, relPath)
item, err := s.scanItem(itemDir, relPath)
if err != nil {
return nil, err
}
if cache != nil {
cache.mu.Lock()
cache.items[relPath] = item
cache.mu.Unlock()
}
return item, nil
}
func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem, error) {
@@ -151,7 +226,7 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
return nil, err
}
mediaFiles, imageFiles, infoJSONPath, err := s.listItemFiles(itemDir)
mediaFiles, infoJSONPath, err := s.listItemFiles(itemDir)
if err != nil {
return nil, err
}
@@ -164,6 +239,10 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
}
}
// dirty tracks whether we derived any new metadata worth persisting, so a
// plain listing or detail view doesn't rewrite the marker file on every read.
dirty := false
if metadata.Name == "" {
if title, ok := infoString(info, "title"); ok && title != "" {
metadata.Name = title
@@ -172,19 +251,23 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
} else {
metadata.Name = filepath.Base(itemDir)
}
dirty = true
}
if metadata.SourceURL == "" {
if url, ok := infoString(info, "webpage_url"); ok {
metadata.SourceURL = url
dirty = true
} else if url, ok := infoString(info, "url"); ok {
metadata.SourceURL = url
dirty = true
}
}
if metadata.Description == "" {
if desc, ok := infoString(info, "description"); ok {
metadata.Description = desc
dirty = true
}
}
@@ -195,6 +278,7 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
if metadata.Duration < 0 {
if duration, ok := infoDuration(info); ok && duration > 0 {
metadata.Duration = duration
dirty = true
}
}
@@ -206,12 +290,16 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
} else if metadata.Duration > 0 {
mf.Duration = metadata.Duration
metadata.FileDurations[mf.Filename] = mf.Duration
dirty = true
}
}
}
if metadata.Duration < 0 {
metadata.Duration = commonDuration(mediaFiles)
if d := commonDuration(mediaFiles); d >= 0 {
metadata.Duration = d
dirty = true
}
}
item := &models.LibraryItem{
@@ -224,9 +312,9 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
MediaFiles: mediaFiles,
}
_ = s.writeMetadata(itemDir, metadata)
_ = imageFiles
if dirty {
_ = s.writeMetadata(itemDir, metadata)
}
return item, nil
}
@@ -246,28 +334,51 @@ func (s *LibraryService) readOrCreateMetadata(itemDir string) (models.ItemMetada
return metadata, nil
}
// hasField reports whether the TOML data declares the given top-level key. It
// matches the key only at the start of a line (after optional whitespace) so a
// value that happens to contain "<key> =" — e.g. a description mentioning
// "duration = 5" — does not produce a false positive.
func hasField(data []byte, key string) bool {
return strings.Contains(string(data), key+" =") || strings.Contains(string(data), key+"=")
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, key) {
continue
}
if strings.HasPrefix(strings.TrimSpace(line[len(key):]), "=") {
return true
}
}
return false
}
func (s *LibraryService) writeMetadata(itemDir string, metadata models.ItemMetadata) error {
markerPath := filepath.Join(itemDir, itemMarkerName)
f, err := os.Create(markerPath)
// Write to a temp file and rename so a crash mid-encode can't leave a
// truncated marker behind.
tmpPath := markerPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return err
}
defer f.Close()
return toml.NewEncoder(f).Encode(metadata)
if err := toml.NewEncoder(f).Encode(metadata); err != nil {
f.Close()
os.Remove(tmpPath)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmpPath)
return err
}
return os.Rename(tmpPath, markerPath)
}
func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, []string, string, error) {
func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, string, error) {
entries, err := os.ReadDir(itemDir)
if err != nil {
return nil, nil, "", err
return nil, "", err
}
var mediaFiles []models.MediaFile
var imageFiles []string
var infoJSONFiles []string
for _, entry := range entries {
@@ -286,7 +397,6 @@ func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, []st
continue
}
if imageExts[ext] {
imageFiles = append(imageFiles, path)
continue
}
if !mediaExts[ext] {
@@ -305,7 +415,7 @@ func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, []st
}
sort.Slice(mediaFiles, func(i, j int) bool {
return mediaFiles[i].Filepath > mediaFiles[j].Filepath
return mediaFiles[i].Filepath < mediaFiles[j].Filepath
})
var infoJSONPath string
@@ -317,23 +427,20 @@ func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, []st
}
}
return mediaFiles, imageFiles, infoJSONPath, nil
return mediaFiles, 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:
// Only accept genuine strings: title/url/description are always strings in
// yt-dlp output, and stringifying an arbitrary JSON value (map, slice) would
// store junk like "map[...]" into the field.
if s, ok := info[key].(string); ok {
return s, true
default:
return fmt.Sprintf("%v", v), true
}
return "", false
}
func infoDuration(info map[string]interface{}) (int, bool) {
@@ -379,8 +486,35 @@ func commonDuration(files []models.MediaFile) int {
return d
}
func (s *LibraryService) GetMediaFile(relPath, filename string) (string, error) {
item, err := s.GetByRelPath(relPath)
// primaryMediaFile picks the representative file for an item: the largest video
// file, or — if there are none — the largest file overall. Returns nil for an
// item with no media files. Used for the item-level thumbnail and as the default
// target for metadata, keeping those two consistent.
func primaryMediaFile(item *models.LibraryItem) *models.MediaFile {
size := func(mf *models.MediaFile) int64 {
if info, err := os.Stat(mf.Filepath); err == nil {
return info.Size()
}
return 0
}
var best *models.MediaFile
for i := range item.MediaFiles {
mf := &item.MediaFiles[i]
switch {
case best == nil:
best = mf
case best.IsAudio && !mf.IsAudio:
// Prefer any video over audio.
best = mf
case best.IsAudio == mf.IsAudio && size(mf) > size(best):
best = mf
}
}
return best
}
func (s *LibraryService) GetMediaFile(ctx context.Context, relPath, filename string) (string, error) {
item, err := s.GetByRelPath(ctx, relPath)
if err != nil {
return "", err
}
@@ -392,70 +526,75 @@ func (s *LibraryService) GetMediaFile(relPath, filename string) (string, error)
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
// ThumbnailForFile returns the thumbnail for a specific media file within an
// item, extracting it on demand if needed. The bool is false when no thumbnail
// 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) {
if filename == "" {
return "", false
}
itemDir := item.DirPath
entries, err := os.ReadDir(itemDir)
item, err := s.GetByRelPath(ctx, relPath)
if err != nil {
return nil, err
return "", false
}
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)
for i := range item.MediaFiles {
if item.MediaFiles[i].Filename != filename {
continue
}
if strings.Contains(name, ".thumbnail.") {
if info, err := entry.Info(); err == nil && info.Size() > 0 {
images = append(images, path)
}
mf := item.MediaFiles[i]
if path, ok := s.findExistingThumbnail(mf.Filepath); ok {
return path, true
}
return s.ensureThumbnailForFile(mf)
}
sort.Strings(images)
return images, nil
}
func (s *LibraryService) HasThumbnail(relPath string) bool {
thumbs, _ := s.ListThumbnails(relPath)
return len(thumbs) > 0
return "", false
}
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)
// ensureThumbnailForFile returns an existing thumbnail for mf or extracts one,
// serializing concurrent extraction of the same file via a per-path mutex. It
// re-checks the disk under the lock so that whichever caller wins the race does
// the work and the rest reuse the result. A prior in-process failure short-
// circuits to avoid re-running ffmpeg on every request (see thumbFailed).
func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) (string, bool) {
actual, _ := s.thumbLocks.LoadOrStore(mf.Filepath, &sync.Mutex{})
lock := actual.(*sync.Mutex)
lock.Lock()
defer lock.Unlock()
if path, ok := s.findExistingThumbnail(mf.Filepath); ok {
return path, true
}
// Trust a prior failure for this run rather than re-running ffmpeg every
// request; a restart clears thumbFailed and retries. Checked after the disk
// so a thumbnail that appears later (e.g. added manually) still wins.
if _, failed := s.thumbFailed.Load(mf.Filepath); failed {
return "", false
}
}
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)
s.thumbSem <- struct{}{}
cur := atomic.AddInt32(&s.extractInFlight, 1)
for {
max := atomic.LoadInt32(&s.extractMaxConcurrent)
if cur <= max || atomic.CompareAndSwapInt32(&s.extractMaxConcurrent, max, cur) {
break
}
})
}
defer func() {
atomic.AddInt32(&s.extractInFlight, -1)
<-s.thumbSem
}()
atomic.AddInt32(&s.extractAttempts, 1)
path, err := s.extractThumbnail(mf)
if err != nil {
log.Printf("thumbnail extraction failed for %s: %v", mf.Filepath, err)
s.thumbFailed.Store(mf.Filepath, struct{}{})
return "", false
}
return path, true
}
func (s *LibraryService) findExistingThumbnail(path string) (string, bool) {
@@ -469,12 +608,77 @@ func (s *LibraryService) findExistingThumbnail(path string) (string, bool) {
return "", false
}
// findImageAttachment returns the ordinal (0-based among attachment streams) of
// the best image attachment in a container — e.g. the cover.jpg/cover.webp that
// yt-dlp embeds into MKV with --embed-thumbnail — or -1 if there is none. Such
// covers are attachment streams, not attached_pic video streams, so they must be
// dumped with -dump_attachment rather than mapped like a normal stream.
func 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 struct {
Streams []struct {
CodecType string `json:"codec_type"`
Tags struct {
Mimetype string `json:"mimetype"`
Filename string `json:"filename"`
} `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &probe); err != nil {
return -1
}
best, bestScore, attachmentIdx := -1, 0, 0
for _, stream := range probe.Streams {
if stream.CodecType != "attachment" {
continue
}
idx := attachmentIdx
attachmentIdx++
if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") {
continue
}
score := 20
switch name := strings.ToLower(stream.Tags.Filename); {
case strings.Contains(name, "cover"):
score = 100
case strings.Contains(name, "thumbnail"), strings.Contains(name, "thumb"):
score = 80
case strings.Contains(name, "poster"):
score = 60
case strings.Contains(name, "art"):
score = 40
}
if score > bestScore {
best, bestScore = idx, score
}
}
return best
}
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"
// 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) {
tmpPath := outputPath + ".tmp"
// 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("ffmpeg", append(args, tmpPath)...)
if output, err := cmd.CombinedOutput(); err != nil {
@@ -492,8 +696,29 @@ func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
return outputPath, nil
}
// First try to extract an embedded cover art stream.
webpPath := base + ".webp"
// 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.
if idx := findImageAttachment(mf.Filepath); idx >= 0 {
if raw, err := os.CreateTemp("", "vidarchive-attachment-*"); err == nil {
rawPath := raw.Name()
raw.Close()
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("ffmpeg", 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)
}
}
}
// Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4.
embeddedArgs := []string{
"-i", mf.Filepath,
"-map", "0:v",
@@ -503,10 +728,11 @@ func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
}
if path, err := tryWrite(webpPath, embeddedArgs); err == nil {
return path, nil
} else {
record("embedded-webp", err)
}
// Fallback to jpeg for embedded covers if libwebp or webp encoding failed.
jpgPath := base + ".jpg"
embeddedJpgArgs := []string{
"-i", mf.Filepath,
"-map", "0:v",
@@ -516,6 +742,8 @@ func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
}
if path, err := tryWrite(jpgPath, embeddedJpgArgs); err == nil {
return path, nil
} else {
record("embedded-jpg", err)
}
if mf.IsAudio {
@@ -544,6 +772,8 @@ func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
}
if path, err := tryWrite(webpPath, frameArgs); err == nil {
return path, nil
} else {
record("frame-webp", err)
}
frameJpgArgs := []string{
@@ -552,14 +782,32 @@ func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
"-vframes", "1",
"-q:v", "2",
}
return tryWrite(jpgPath, frameJpgArgs)
if path, err := tryWrite(jpgPath, frameJpgArgs); err == nil {
return path, nil
} else {
record("frame-jpg", err)
}
return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n"))
}
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")
}
}
return os.RemoveAll(itemDir)
}
@@ -575,8 +823,8 @@ 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)
func (s *LibraryService) GetSubtitles(ctx context.Context, relPath string) ([]models.SubtitleTrack, error) {
item, err := s.GetByRelPath(ctx, relPath)
if err != nil {
return nil, err
}
@@ -702,33 +950,32 @@ func extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
return nil
}
func (s *LibraryService) GetMetadata(relPath string) (*MediaMetadata, error) {
item, err := s.GetByRelPath(relPath)
// 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)
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]
var target *models.MediaFile
if filename != "" {
for i := range item.MediaFiles {
if item.MediaFiles[i].Filename == filename {
target = &item.MediaFiles[i]
break
}
}
}
if largest == nil {
return nil, fmt.Errorf("no video file")
if target == nil {
target = primaryMediaFile(item)
}
if target == nil {
return nil, fmt.Errorf("no media file")
}
return probeMedia(largest.Filepath)
return probeMedia(target.Filepath)
}
func probeMedia(path string) (*MediaMetadata, error) {
@@ -905,18 +1152,3 @@ type ffprobeStream struct {
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
}
▾Ainternal/service/library_test.go
@@ -0,0 +1,812 @@
package service
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"vidarchive/internal/models"
)
// --- test helpers ---
func newLibrary(t *testing.T) (*LibraryService, string) {
t.Helper()
dir := t.TempDir()
return NewLibraryService(dir), dir
}
// writeItem creates an item directory with a marker and the given files
// (filename -> contents). A nil/empty marker body still creates a valid item.
func writeItem(t *testing.T, libraryDir, relPath, markerBody string, files map[string]string) string {
t.Helper()
itemDir := filepath.Join(libraryDir, filepath.FromSlash(relPath))
if err := os.MkdirAll(itemDir, 0755); err != nil {
t.Fatalf("mkdir item: %v", err)
}
if markerBody == "" {
markerBody = "duration = -1\n"
}
if err := os.WriteFile(filepath.Join(itemDir, itemMarkerName), []byte(markerBody), 0644); err != nil {
t.Fatalf("write marker: %v", err)
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(itemDir, name), []byte(content), 0644); err != nil {
t.Fatalf("write file %s: %v", name, err)
}
}
return itemDir
}
func requireFFmpeg(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not on PATH")
}
}
// makeTestVideo writes a tiny real video file ffmpeg can decode a frame from.
func makeTestVideo(t *testing.T, path string) {
t.Helper()
makeTestVideoSize(t, path, "64x64")
}
// makeTestVideoSize is makeTestVideo with an explicit WxH size.
func makeTestVideoSize(t *testing.T, path, size string) {
t.Helper()
// 3s so the default 1s thumbnail seek lands on a real frame.
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=3:size="+size+":rate=5",
"-pix_fmt", "yuv420p", path, "-y")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("make test video: %v\n%s", err, out)
}
}
// makeVideoWithCoverAttachment writes an MKV at outPath with a 64x64 video and
// an embedded "cover.webp" image attachment of the given size — mirroring how
// yt-dlp embeds thumbnails into MKV (a true attachment stream, not an
// attached_pic video stream; note ffmpeg muxes an attached .png as a video
// stream, so webp/jpg must be used to get a real attachment).
func makeVideoWithCoverAttachment(t *testing.T, outPath, coverSize string) {
t.Helper()
scratch := t.TempDir()
base := filepath.Join(scratch, "base.mp4")
makeTestVideoSize(t, base, "64x64")
cover := filepath.Join(scratch, "cover.webp")
if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "color=red:size="+coverSize+":duration=1",
"-frames:v", "1", cover, "-y").CombinedOutput(); err != nil {
t.Fatalf("make cover: %v\n%s", err, out)
}
if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", base, "-attach", cover, "-metadata:s:t:0", "mimetype=image/webp",
"-c", "copy", outPath, "-y").CombinedOutput(); err != nil {
t.Fatalf("attach cover: %v\n%s", err, out)
}
}
// probeImageSize returns the pixel dimensions of an image/video file.
func probeImageSize(t *testing.T, path string) (int, int) {
t.Helper()
out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path).Output()
if err != nil {
t.Fatalf("probe %s: %v", path, err)
}
var w, h int
if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%dx%d", &w, &h); err != nil {
t.Fatalf("parse dimensions %q: %v", out, err)
}
return w, h
}
// --- pure helper tests ---
func TestInfoDuration(t *testing.T) {
tests := []struct {
name string
in map[string]interface{}
want int
ok bool
}{
{"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},
{"nil", nil, 0, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := infoDuration(tc.in)
if ok != tc.ok || got != tc.want {
t.Errorf("infoDuration(%v) = (%d,%v), want (%d,%v)", tc.in, got, ok, tc.want, tc.ok)
}
})
}
}
func TestCommonDuration(t *testing.T) {
mk := func(ds ...int) []models.MediaFile {
var fs []models.MediaFile
for _, d := range ds {
fs = append(fs, models.MediaFile{Duration: d})
}
return fs
}
tests := []struct {
name string
files []models.MediaFile
want int
}{
{"empty", nil, -1},
{"single", mk(100), 100},
{"all equal", mk(100, 100, 100), 100},
{"mismatch", mk(100, 90), -1},
{"unknown first", mk(-1, 100), -1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := commonDuration(tc.files); got != tc.want {
t.Errorf("commonDuration = %d, want %d", got, tc.want)
}
})
}
}
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"},
{"30000/1001", "29.97"},
{"0/0", ""},
{"", ""},
{"garbage", ""},
}
for _, tc := range tests {
if got := parseFPS(tc.in); got != tc.want {
t.Errorf("parseFPS(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestInfoString(t *testing.T) {
if _, ok := infoString(nil, "title"); ok {
t.Error("nil info should return false")
}
if _, ok := infoString(map[string]interface{}{}, "title"); ok {
t.Error("missing key should return false")
}
if v, ok := infoString(map[string]interface{}{"title": "hi"}, "title"); !ok || v != "hi" {
t.Errorf("string value = (%q,%v), want (hi,true)", v, ok)
}
if _, ok := infoString(map[string]interface{}{"title": 5}, "title"); ok {
t.Error("numeric value should return false, not stringify")
}
if _, ok := infoString(map[string]interface{}{"title": map[string]interface{}{"a": 1}}, "title"); ok {
t.Error("map value should return false, not stringify")
}
}
func TestHasField(t *testing.T) {
tests := []struct {
data string
key string
want bool
}{
{"duration = 10\n", "duration", true},
{"duration=10\n", "duration", true},
{" duration = 10\n", "duration", true},
{"name = \"x\"\nduration = 5\n", "duration", true},
{"description = \"mentions duration = 5 inline\"\n", "duration", false},
{"name = \"x\"\n", "duration", false},
{"", "duration", false},
}
for _, tc := range tests {
if got := hasField([]byte(tc.data), tc.key); got != tc.want {
t.Errorf("hasField(%q, %q) = %v, want %v", tc.data, tc.key, got, tc.want)
}
}
}
func TestPrimaryMediaFile(t *testing.T) {
dir := t.TempDir()
write := func(name string, n int) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, make([]byte, n), 0644); err != nil {
t.Fatal(err)
}
return p
}
// Largest video wins over a larger audio file.
mixed := &models.LibraryItem{MediaFiles: []models.MediaFile{
{Filename: "small.mp4", Filepath: write("small.mp4", 10)},
{Filename: "big.mp4", Filepath: write("big.mp4", 100)},
{Filename: "huge.mp3", Filepath: write("huge.mp3", 1000), IsAudio: true},
}}
if got := primaryMediaFile(mixed); got == nil || got.Filename != "big.mp4" {
t.Errorf("expected largest video big.mp4, got %v", got)
}
// Audio-only item falls back to the largest audio file.
audio := &models.LibraryItem{MediaFiles: []models.MediaFile{
{Filename: "a.mp3", Filepath: write("a.mp3", 5), IsAudio: true},
{Filename: "b.mp3", Filepath: write("b.mp3", 50), IsAudio: true},
}}
if got := primaryMediaFile(audio); got == nil || got.Filename != "b.mp3" {
t.Errorf("expected largest audio b.mp3, got %v", got)
}
if got := primaryMediaFile(&models.LibraryItem{}); got != nil {
t.Errorf("empty item should yield nil, got %v", got)
}
}
func TestFormatBitrate(t *testing.T) {
tests := []struct{ in, want string }{
{"128000", "128"},
{"", ""},
{"notanumber", ""},
}
for _, tc := range tests {
if got := formatBitrate(tc.in); got != tc.want {
t.Errorf("formatBitrate(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// --- listItemFiles / GetAll ---
func TestListItemFilesClassification(t *testing.T) {
svc, dir := newLibrary(t)
itemDir := writeItem(t, dir, "item", "", map[string]string{
"video.mp4": "v",
"audio.mp3": "a",
"cover.jpg": "img",
"thumb.webp": "img",
"notes.txt": "ignored",
"info.json": `{"title":"x"}`,
"video.info.json": `{"title":"x"}`,
})
media, infoPath, err := svc.listItemFiles(itemDir)
if err != nil {
t.Fatalf("listItemFiles: %v", err)
}
if len(media) != 2 {
t.Fatalf("expected 2 media files, got %d: %+v", len(media), media)
}
var sawAudio, sawVideo bool
for _, m := range media {
switch m.Filename {
case "audio.mp3":
sawAudio = true
if !m.IsAudio {
t.Error("audio.mp3 should be IsAudio")
}
case "video.mp4":
sawVideo = true
if m.IsAudio {
t.Error("video.mp4 should not be IsAudio")
}
default:
t.Errorf("unexpected media file %q", m.Filename)
}
}
if !sawAudio || !sawVideo {
t.Error("missing expected media files")
}
if infoPath == "" {
t.Error("expected an info.json path")
}
}
func TestListItemFilesSortedAscending(t *testing.T) {
svc, dir := newLibrary(t)
itemDir := writeItem(t, dir, "item", "", map[string]string{
"03 - c.mp4": "c", "01 - a.mp4": "a", "02 - b.mp4": "b",
})
media, _, err := svc.listItemFiles(itemDir)
if err != nil {
t.Fatalf("listItemFiles: %v", err)
}
got := make([]string, len(media))
for i, m := range media {
got[i] = m.Filename
}
want := []string{"01 - a.mp4", "02 - b.mp4", "03 - c.mp4"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("media files = %v, want ascending %v", got, want)
}
}
}
func TestGetAllSeparatesFoldersAndItems(t *testing.T) {
svc, dir := newLibrary(t)
// An item (has marker)
writeItem(t, dir, "song", "name = \"Song\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
// A plain folder (no marker) containing a nested item
writeItem(t, dir, "folder/nested", "name = \"Nested\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
// The reserved subtitles dir at root must be ignored
if err := os.MkdirAll(filepath.Join(dir, subtitlesDirName), 0755); err != nil {
t.Fatal(err)
}
items, folders, err := svc.GetAll("", "title", "")
if err != nil {
t.Fatalf("GetAll: %v", err)
}
if len(items) != 1 || items[0].Name != "Song" {
t.Errorf("expected 1 item 'Song', got %+v", items)
}
if len(folders) != 1 || folders[0] != "folder" {
t.Errorf("expected folder 'folder', got %v", folders)
}
for _, f := range folders {
if f == subtitlesDirName {
t.Error("subtitles dir should not be listed as a folder")
}
}
}
func TestGetAllFilter(t *testing.T) {
svc, dir := newLibrary(t)
writeItem(t, dir, "alpha", "name = \"Alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
writeItem(t, dir, "beta", "name = \"Beta\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
items, _, err := svc.GetAll("", "title", "alph")
if err != nil {
t.Fatalf("GetAll: %v", err)
}
if len(items) != 1 || items[0].Name != "Alpha" {
t.Errorf("filter 'alph' should match only Alpha, got %+v", items)
}
}
func TestGetAllSortByTitle(t *testing.T) {
svc, dir := newLibrary(t)
writeItem(t, dir, "c", "name = \"Charlie\"\nduration = -1\n", map[string]string{"c.mp4": "v"})
writeItem(t, dir, "a", "name = \"alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
writeItem(t, dir, "b", "name = \"Bravo\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
items, _, err := svc.GetAll("", "title", "")
if err != nil {
t.Fatalf("GetAll: %v", err)
}
got := []string{items[0].Name, items[1].Name, items[2].Name}
want := []string{"alpha", "Bravo", "Charlie"} // case-insensitive
for i := range want {
if got[i] != want[i] {
t.Errorf("sort by title = %v, want %v", got, want)
break
}
}
}
// --- metadata derivation + marker write behavior ---
func TestScanItemDerivesMetadataFromInfoJSON(t *testing.T) {
svc, dir := newLibrary(t)
info := `{"title":"Real Title","webpage_url":"https://example.com/v","description":"hello","duration":149.2}`
writeItem(t, dir, "x", "duration = -1\n", map[string]string{
"x.mp4": "v",
"info.json": info,
})
item, err := svc.GetByRelPath(context.Background(), "x")
if err != nil {
t.Fatalf("GetByRelPath: %v", err)
}
if item.Name != "Real Title" {
t.Errorf("Name = %q, want derived from info.json", item.Name)
}
if item.SourceURL != "https://example.com/v" {
t.Errorf("SourceURL = %q", item.SourceURL)
}
if item.Description != "hello" {
t.Errorf("Description = %q", item.Description)
}
if item.Duration != 149 {
t.Errorf("Duration = %d, want 149", item.Duration)
}
// Derived metadata should be persisted to the marker.
markerData, _ := os.ReadFile(filepath.Join(dir, "x", itemMarkerName))
if !strings.Contains(string(markerData), "Real Title") {
t.Errorf("marker not enriched with derived title: %s", markerData)
}
}
func TestScanItemDoesNotRewriteConvergedMarker(t *testing.T) {
svc, dir := newLibrary(t)
marker := "name = \"Done\"\nsource_url = \"u\"\nduration = 10\ndescription = \"d\"\n"
itemDir := writeItem(t, dir, "y", marker, map[string]string{"y.mp4": "v"})
markerPath := filepath.Join(itemDir, itemMarkerName)
// 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 {
t.Fatalf("warm-up read: %v", err)
}
before, err := os.Stat(markerPath)
if err != nil {
t.Fatal(err)
}
// Subsequent reads must not rewrite a converged marker.
for i := 0; i < 3; i++ {
if _, err := svc.GetByRelPath(context.Background(), "y"); err != nil {
t.Fatalf("GetByRelPath: %v", err)
}
}
after, err := os.Stat(markerPath)
if err != nil {
t.Fatal(err)
}
if !before.ModTime().Equal(after.ModTime()) {
t.Errorf("converged marker was rewritten on read (mtime changed %v -> %v)", before.ModTime(), after.ModTime())
}
}
// --- per-request item cache ---
func TestItemCacheDedupesScans(t *testing.T) {
svc, dir := newLibrary(t)
writeItem(t, dir, "x", "name = \"X\"\nsource_url = \"u\"\nduration = 5\n", map[string]string{"x.mp4": "v"})
// Within one cached context, repeated lookups return the same scanned item.
ctx := WithItemCache(context.Background())
a, err := svc.GetByRelPath(ctx, "x")
if err != nil {
t.Fatalf("first lookup: %v", err)
}
b, err := svc.GetByRelPath(ctx, "x")
if err != nil {
t.Fatalf("second lookup: %v", err)
}
if a != b {
t.Error("cached context should return the same item pointer (no rescan)")
}
// A context without a cache rescans, yielding a distinct item.
c, err := svc.GetByRelPath(context.Background(), "x")
if err != nil {
t.Fatalf("uncached lookup: %v", err)
}
if c == a {
t.Error("uncached context should rescan, not reuse the cached pointer")
}
}
// --- path traversal ---
func TestResolveItemDirRejectsTraversal(t *testing.T) {
svc, dir := newLibrary(t)
if _, err := svc.resolveItemDir("../../etc"); err == nil {
t.Error("expected error for traversal path")
}
// A legitimate nested path resolves fine.
got, err := svc.resolveItemDir("sub/item")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(got, dir) {
t.Errorf("resolved path %q not under library %q", got, dir)
}
}
// --- delete safety ---
func TestDeleteRejectsLibraryRoot(t *testing.T) {
svc, dir := newLibrary(t)
writeItem(t, dir, "keep", "name = \"Keep\"\nduration = -1\n", map[string]string{"k.mp4": "v"})
for _, rel := range []string{"", "/", "//"} {
if err := svc.Delete(rel); err == nil {
t.Errorf("Delete(%q) should be refused", rel)
}
}
// The guard must not have touched the library or its contents.
if _, err := os.Stat(filepath.Join(dir, "keep", "k.mp4")); err != nil {
t.Fatalf("library content was deleted by a root-delete attempt: %v", err)
}
// A real item still deletes.
if err := svc.Delete("keep"); err != nil {
t.Fatalf("Delete(real item): %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "keep")); !os.IsNotExist(err) {
t.Errorf("expected item dir removed, stat err = %v", err)
}
}
// --- Thumbnail behavior ---
func TestThumbnailPrefersExistingGenerated(t *testing.T) {
svc, dir := newLibrary(t)
writeItem(t, dir, "item", "name = \"I\"\nduration = -1\n", map[string]string{
"video.mp4": "v",
"video.thumbnail.webp": "GENERATED",
})
path, ok := svc.ThumbnailForFile(context.Background(), "item", "video.mp4")
if !ok {
t.Fatal("expected a thumbnail")
}
if !strings.HasSuffix(path, "video.thumbnail.webp") {
t.Errorf("expected generated thumbnail, got %q", path)
}
}
func TestThumbnailExtractsOnDemand(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(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")
if !ok {
t.Fatal("expected on-demand extraction to succeed")
}
if info, err := os.Stat(path); err != nil || info.Size() == 0 {
t.Fatalf("thumbnail file missing/empty: %v", err)
}
if !strings.Contains(filepath.Base(path), ".thumbnail.") {
t.Errorf("unexpected thumbnail name %q", path)
}
// No leftover temp files from the atomic-write path.
entries, _ := os.ReadDir(itemDir)
for _, e := range entries {
if strings.Contains(e.Name(), ".tmp") {
t.Errorf("leftover temp file %q", e.Name())
}
}
}
func TestThumbnailRetriesAfterDeletion(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(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")
if !ok {
t.Fatal("first extraction failed")
}
if err := os.Remove(first); err != nil {
t.Fatal(err)
}
// A failure/absence must not be cached permanently: re-request re-extracts.
second, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
if !ok {
t.Fatal("re-extraction after deletion failed (failure was cached)")
}
if info, err := os.Stat(second); err != nil || info.Size() == 0 {
t.Fatalf("re-extracted thumbnail missing/empty: %v", err)
}
}
func TestThumbnailConcurrentSingleExtraction(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(t)
itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4"); !ok {
t.Error("concurrent Thumbnail failed")
}
}()
}
wg.Wait()
// Exactly one generated thumbnail, no temp leftovers despite the race.
entries, _ := os.ReadDir(itemDir)
var thumbs int
for _, e := range entries {
if strings.Contains(e.Name(), ".thumbnail.") {
thumbs++
}
if strings.Contains(e.Name(), ".tmp") {
t.Errorf("leftover temp file %q", e.Name())
}
}
if thumbs != 1 {
t.Errorf("expected exactly 1 generated thumbnail, got %d", thumbs)
}
}
func TestThumbnailForFileIsPerFile(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(t)
itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil)
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")
if !ok {
t.Fatal("thumbnail for a.mp4 failed")
}
pb, ok := svc.ThumbnailForFile(ctx, "multi", "b.mp4")
if !ok {
t.Fatal("thumbnail for b.mp4 failed")
}
if pa == pb {
t.Errorf("expected distinct per-file thumbnails, both = %q", pa)
}
if !strings.Contains(filepath.Base(pa), "a.thumbnail.") {
t.Errorf("a.mp4 thumbnail name = %q", filepath.Base(pa))
}
if !strings.Contains(filepath.Base(pb), "b.thumbnail.") {
t.Errorf("b.mp4 thumbnail name = %q", filepath.Base(pb))
}
// An unknown file yields no thumbnail (caller falls back to an icon).
if _, ok := svc.ThumbnailForFile(ctx, "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 {
t.Error("empty filename should not produce a thumbnail")
}
}
func TestGetMetadataForSelectedFile(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(t)
itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil)
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")
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")
if err != nil {
t.Fatalf("GetMetadata big: %v", err)
}
if m2.Resolution != "128x72" {
t.Errorf("big.mp4 resolution = %q, want 128x72", m2.Resolution)
}
}
func TestThumbnailConcurrencyBounded(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(t)
itemDir := writeItem(t, dir, "many", "name = \"M\"\nduration = -1\n", nil)
const n = 8
for i := 0; i < n; i++ {
makeTestVideo(t, filepath.Join(itemDir, fmt.Sprintf("c%d.mp4", i)))
}
var wg sync.WaitGroup
for i := 0; i < n; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
svc.ThumbnailForFile(context.Background(), "many", fmt.Sprintf("c%d.mp4", i))
}()
}
wg.Wait()
max := atomic.LoadInt32(&svc.extractMaxConcurrent)
if max > maxConcurrentThumbnails {
t.Errorf("peak concurrent extractions %d exceeded cap %d", max, maxConcurrentThumbnails)
}
if max < 1 {
t.Error("expected at least one extraction to run")
}
}
func TestThumbnailUsesEmbeddedAttachment(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(t)
itemDir := writeItem(t, dir, "att", "name = \"A\"\nduration = -1\n", nil)
mkv := filepath.Join(itemDir, "v.mkv")
// 100x100 cover so it's distinguishable from a 64x64 video frame.
makeVideoWithCoverAttachment(t, mkv, "100x100")
if idx := findImageAttachment(mkv); idx < 0 {
t.Fatal("findImageAttachment did not find the embedded cover")
}
path, ok := svc.ThumbnailForFile(context.Background(), "att", "v.mkv")
if !ok {
t.Fatal("thumbnail extraction failed")
}
// The embedded cover (100x100) must be used in preference to a video frame
// (which would be 64x64) — this is the regression the refactor introduced.
if w, h := probeImageSize(t, path); w != 100 || h != 100 {
t.Errorf("thumbnail is %dx%d, expected 100x100 from the embedded cover (got a video frame instead)", w, h)
}
}
func TestThumbnailNegativeCacheSkipsReextraction(t *testing.T) {
svc, dir := newLibrary(t)
// A file ffmpeg cannot extract a thumbnail from: every attempt fails.
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 {
t.Fatal("expected extraction to fail for a non-video file")
}
attempts1 := atomic.LoadInt32(&svc.extractAttempts)
if attempts1 == 0 {
t.Fatal("expected at least one extraction attempt")
}
// A second request is served from the in-process negative cache: no new
// ffmpeg attempt.
if _, ok := svc.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
t.Fatal("expected the cached failure to persist")
}
if attempts2 := atomic.LoadInt32(&svc.extractAttempts); attempts2 != attempts1 {
t.Errorf("negative cache should prevent re-extraction; attempts %d -> %d", attempts1, attempts2)
}
// The cache is in-process only: a fresh service (≈ a restart) retries.
fresh := NewLibraryService(dir)
if _, ok := fresh.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
t.Fatal("fresh service still fails (file is unextractable)")
}
if atomic.LoadInt32(&fresh.extractAttempts) == 0 {
t.Error("a fresh service should retry extraction, not inherit the negative cache")
}
}
func TestThumbnailAudioOnlyHasNone(t *testing.T) {
requireFFmpeg(t)
svc, dir := newLibrary(t)
itemDir := writeItem(t, dir, "aud", "name = \"A\"\nduration = -1\n", nil)
// A real audio file with no cover art.
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
filepath.Join(itemDir, "aud.mp3"), "-y")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("make audio: %v\n%s", err, out)
}
if path, ok := svc.ThumbnailForFile(context.Background(), "aud", "aud.mp3"); ok {
t.Errorf("audio-only item should have no thumbnail, got %q", path)
}
}
▾Minternal/worker/pool.go
@@ -38,7 +38,7 @@ func (p *Pool) Start() {
for i := 0; i < p.workers; i++ {
go p.worker(i)
}
// Queue checker - polls DB for queued downloads
go p.queueChecker()
}
▾Mweb/static/style.css
@@ -258,7 +258,9 @@ button:disabled {
.media-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
/* contain: show the whole thumbnail (letterboxed) and never overflow the
16/9 box, including portrait/vertical sources. */
object-fit: contain;
}
.media-thumbnail img[src$=".svg"] {
@@ -268,6 +270,70 @@ button:disabled {
object-fit: contain;
}
/* Multi-file items: tile each media file's thumbnail in a grid. */
.media-thumbnail.count-2,
.media-thumbnail.count-3,
.media-thumbnail.count-many {
display: grid;
gap: 2px;
}
.media-thumbnail.count-2 {
grid-template-columns: 1fr 1fr;
}
.media-thumbnail.count-3,
.media-thumbnail.count-many {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
/* For 3 files, let the first thumbnail span the full top row. */
.media-thumbnail.count-3 img:first-of-type {
grid-column: 1 / -1;
}
.media-thumbnail.count-2 img,
.media-thumbnail.count-3 img,
.media-thumbnail.count-many img {
width: 100%;
height: 100%;
object-fit: contain;
min-width: 0;
min-height: 0;
}
/* "+N" overlay on the last tile when there are more than 4 files. */
.media-thumbnail .thumb-more {
position: absolute;
right: 0;
bottom: 0;
width: 50%;
height: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 1.1rem;
font-weight: 600;
pointer-events: none;
}
/* File-count badge in the top-left for multi-file items. */
.media-thumbnail .thumb-count {
position: absolute;
top: 0.4rem;
left: 0.4rem;
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 0.15rem 0.4rem;
border-radius: var(--pico-border-radius);
font-size: 0.7rem;
font-weight: 500;
line-height: 1;
}
.folder-name,
.media-info {
padding: 0.75rem;
@@ -334,6 +400,69 @@ button:disabled {
margin: 0;
}
/* Description: preserve the source line breaks; collapse long ones to ~2 lines
with a pure-CSS (no-JS, CSP-safe) Show more/less toggle. */
.detail-meta dd.description {
margin: 0;
}
.description .desc-text {
white-space: pre-wrap;
word-break: normal;
overflow-wrap: anywhere;
line-height: 1.5;
}
/* The checkbox is the toggle state; it's driven by the label, never shown. */
.description.collapsible .desc-toggle {
display: none;
}
/* Collapsed: clamp to ~2 lines (max-height works with pre-wrap; line-clamp
does not), with a fade so the cut reads as intentional. */
.description.collapsible .desc-text {
position: relative;
max-height: 3em;
overflow: hidden;
}
.description.collapsible .desc-text::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1.5em;
background: linear-gradient(to bottom, transparent, var(--pico-background-color));
pointer-events: none;
}
.description.collapsible .desc-toggle:checked ~ .desc-text {
max-height: none;
}
.description.collapsible .desc-toggle:checked ~ .desc-text::after {
display: none;
}
.description.collapsible .desc-toggle-label {
display: inline-block;
margin-top: 0.4rem;
color: var(--pico-primary);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.description.collapsible .desc-toggle-label::after {
content: "Show more \25be";
}
.description.collapsible .desc-toggle:checked ~ .desc-toggle-label::after {
content: "Show less \25b4";
}
/* Queue */
.queue-list {
display: flex;
▾Mweb/templates/library.html
@@ -43,9 +43,27 @@
{{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">
<img src="/media/item/{{urlEncodePath .RelPath}}/thumbnail" alt="" loading="lazy">
{{$item := .}}
{{$count := len .MediaFiles}}
<a href="/library/item/{{urlEncodePath .RelPath}}" class="media-item {{if gt $count 0}}{{if (index .MediaFiles 0).IsAudio}}audio{{else}}video{{end}}{{end}}">
<div class="media-thumbnail {{if le $count 1}}count-1{{else if eq $count 2}}count-2{{else if eq $count 3}}count-3{{else}}count-many{{end}}">
{{if le $count 1}}
{{if gt $count 0}}
<img src="/media/item/{{urlEncodePath .RelPath}}/thumbnail?file={{(index .MediaFiles 0).Filename}}" alt="" loading="lazy">
{{else}}
<img src="/static/icons/video-icon.svg" alt="" loading="lazy">
{{end}}
{{else}}
{{range $i, $mf := .MediaFiles}}
{{if lt $i 4}}
<img src="/media/item/{{urlEncodePath $item.RelPath}}/thumbnail?file={{$mf.Filename}}" alt="" loading="lazy">
{{end}}
{{end}}
{{if gt $count 4}}
<span class="thumb-more">+{{sub $count 4}}</span>
{{end}}
<span class="thumb-count">{{$count}} files</span>
{{end}}
{{if ge .Duration 0}}
<span class="media-duration">{{formatDuration .Duration}}</span>
{{end}}
▾Mweb/templates/library_detail.html
@@ -47,23 +47,28 @@
<div class="detail-meta">
<dl>
<dt>Files</dt>
{{range .Data.Item.MediaFiles}}
<dd>{{.Filename}}</dd>
{{end}}
{{if .Data.Item.SourceURL}}
<dt>Source URL</dt>
<dd><a href="{{.Data.Item.SourceURL}}" target="_blank">{{.Data.Item.SourceURL}}</a></dd>
{{end}}
{{if .Data.Item.Description}}
{{$longDesc := isLongText .Data.Item.Description}}
<dt>Description</dt>
<dd class="description{{if $longDesc}} collapsible{{end}}">
{{if $longDesc}}<input type="checkbox" id="desc-toggle" class="desc-toggle">{{end}}
<div class="desc-text">{{.Data.Item.Description}}</div>
{{if $longDesc}}<label for="desc-toggle" class="desc-toggle-label"></label>{{end}}
</dd>
{{end}}
</dl>
<details>
<summary role="button" class="small secondary">More Details</summary>
<dl>
{{if .Data.Item.Description}}
<dt>Description</dt>
<dd>{{.Data.Item.Description}}</dd>
{{if .Data.SelectedFilename}}
<dt>File</dt>
<dd>{{.Data.SelectedFilename}}</dd>
{{end}}
{{if .Data.Metadata}}