library_test.go
⎇
Raw
1package service
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "vidarchive/internal/models"
11)
12
13// --- test helpers ---
14
15func newLibrary(t *testing.T) (*LibraryService, string) {
16 t.Helper()
17 dir := t.TempDir()
18 return NewLibraryService(dir, "ffmpeg", "ffprobe"), dir
19}
20
21// writeItem creates an item directory with a marker and the given files
22// (filename -> contents). A nil/empty marker body still creates a valid item.
23func writeItem(t *testing.T, libraryDir, relPath, markerBody string, files map[string]string) string {
24 t.Helper()
25 itemDir := filepath.Join(libraryDir, filepath.FromSlash(relPath))
26 if err := os.MkdirAll(itemDir, 0o755); err != nil {
27 t.Fatalf("mkdir item: %v", err)
28 }
29 if markerBody == "" {
30 markerBody = "duration = -1\n"
31 }
32 if err := os.WriteFile(filepath.Join(itemDir, itemMarkerName), []byte(markerBody), 0o644); err != nil {
33 t.Fatalf("write marker: %v", err)
34 }
35 for name, content := range files {
36 if err := os.WriteFile(filepath.Join(itemDir, name), []byte(content), 0o644); err != nil {
37 t.Fatalf("write file %s: %v", name, err)
38 }
39 }
40 return itemDir
41}
42
43func requireFFmpeg(t *testing.T) {
44 t.Helper()
45 if _, err := exec.LookPath("ffmpeg"); err != nil {
46 t.Skip("ffmpeg not on PATH")
47 }
48}
49
50// makeTestVideo writes a tiny real video file ffmpeg can decode a frame from.
51func makeTestVideo(t *testing.T, path string) {
52 t.Helper()
53 makeTestVideoSize(t, path, "64x64")
54}
55
56// makeTestVideoSize is makeTestVideo with an explicit WxH size.
57func makeTestVideoSize(t *testing.T, path, size string) {
58 t.Helper()
59 // 3s so the default 1s thumbnail seek lands on a real frame.
60 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
61 "-f", "lavfi", "-i", "testsrc=duration=3:size="+size+":rate=5",
62 "-pix_fmt", "yuv420p", path, "-y")
63 if out, err := cmd.CombinedOutput(); err != nil {
64 t.Fatalf("make test video: %v\n%s", err, out)
65 }
66}
67
68// --- pure helper tests ---
69
70func TestInfoDuration(t *testing.T) {
71 tests := []struct {
72 name string
73 in map[string]interface{}
74 want int
75 ok bool
76 }{
77 {"float", map[string]interface{}{"duration": 149.4}, 149, true},
78 {"float rounds up", map[string]interface{}{"duration": 149.6}, 150, true},
79 {"string", map[string]interface{}{"duration": "12.0"}, 12, true},
80 {"bad string", map[string]interface{}{"duration": "abc"}, 0, false},
81 {"missing", map[string]interface{}{}, 0, false},
82 {"nil", nil, 0, false},
83 }
84 for _, tc := range tests {
85 t.Run(tc.name, func(t *testing.T) {
86 got, ok := infoDuration(tc.in)
87 if ok != tc.ok || got != tc.want {
88 t.Errorf("infoDuration(%v) = (%d,%v), want (%d,%v)", tc.in, got, ok, tc.want, tc.ok)
89 }
90 })
91 }
92}
93
94func TestInfoString(t *testing.T) {
95 if _, ok := infoString(nil, "title"); ok {
96 t.Error("nil info should return false")
97 }
98 if _, ok := infoString(map[string]interface{}{}, "title"); ok {
99 t.Error("missing key should return false")
100 }
101 if v, ok := infoString(map[string]interface{}{"title": "hi"}, "title"); !ok || v != "hi" {
102 t.Errorf("string value = (%q,%v), want (hi,true)", v, ok)
103 }
104 if _, ok := infoString(map[string]interface{}{"title": 5}, "title"); ok {
105 t.Error("numeric value should return false, not stringify")
106 }
107 if _, ok := infoString(map[string]interface{}{"title": map[string]interface{}{"a": 1}}, "title"); ok {
108 t.Error("map value should return false, not stringify")
109 }
110}
111
112func TestPrimaryMediaFile(t *testing.T) {
113 dir := t.TempDir()
114 write := func(name string, n int) string {
115 p := filepath.Join(dir, name)
116 if err := os.WriteFile(p, make([]byte, n), 0o644); err != nil {
117 t.Fatal(err)
118 }
119 return p
120 }
121
122 // Largest video wins over a larger audio file.
123 mixed := &models.LibraryItem{MediaFiles: []models.MediaFile{
124 {Filename: "small.mp4", Filepath: write("small.mp4", 10)},
125 {Filename: "big.mp4", Filepath: write("big.mp4", 100)},
126 {Filename: "huge.mp3", Filepath: write("huge.mp3", 1000), IsAudio: true},
127 }}
128 if got := primaryMediaFile(mixed); got == nil || got.Filename != "big.mp4" {
129 t.Errorf("expected largest video big.mp4, got %v", got)
130 }
131
132 // Audio-only item falls back to the largest audio file.
133 audio := &models.LibraryItem{MediaFiles: []models.MediaFile{
134 {Filename: "a.mp3", Filepath: write("a.mp3", 5), IsAudio: true},
135 {Filename: "b.mp3", Filepath: write("b.mp3", 50), IsAudio: true},
136 }}
137 if got := primaryMediaFile(audio); got == nil || got.Filename != "b.mp3" {
138 t.Errorf("expected largest audio b.mp3, got %v", got)
139 }
140
141 if got := primaryMediaFile(&models.LibraryItem{}); got != nil {
142 t.Errorf("empty item should yield nil, got %v", got)
143 }
144}
145
146// --- listItemFiles / GetAll ---
147
148func TestListItemFilesClassification(t *testing.T) {
149 svc, dir := newLibrary(t)
150 itemDir := writeItem(t, dir, "item", "", map[string]string{
151 "video.mp4": "v",
152 "audio.mp3": "a",
153 "audio.m4a": "a",
154 "cover.jpg": "img",
155 "thumb.webp": "img",
156 "notes.txt": "ignored",
157 "info.json": `{"title":"x"}`,
158 "video.info.json": `{"title":"x"}`,
159 })
160
161 media, infoPath, err := svc.listItemFiles(itemDir)
162 if err != nil {
163 t.Fatalf("listItemFiles: %v", err)
164 }
165 if len(media) != 3 {
166 t.Fatalf("expected 3 media files, got %d: %+v", len(media), media)
167 }
168 var sawAudio, sawVideo, sawM4A bool
169 for _, m := range media {
170 switch m.Filename {
171 case "audio.mp3":
172 sawAudio = true
173 if !m.IsAudio {
174 t.Error("audio.mp3 should be IsAudio")
175 }
176 // yt-dlp writes .m4a for extracted audio, so the scanner must list it —
177 // otherwise an imported item never appears in the library.
178 case "audio.m4a":
179 sawM4A = true
180 if !m.IsAudio {
181 t.Error("audio.m4a should be IsAudio")
182 }
183 case "video.mp4":
184 sawVideo = true
185 if m.IsAudio {
186 t.Error("video.mp4 should not be IsAudio")
187 }
188 default:
189 t.Errorf("unexpected media file %q", m.Filename)
190 }
191 }
192 if !sawAudio || !sawVideo || !sawM4A {
193 t.Error("missing expected media files")
194 }
195 if infoPath == "" {
196 t.Error("expected an info.json path")
197 }
198}
199
200func TestListItemFilesSortedAscending(t *testing.T) {
201 svc, dir := newLibrary(t)
202 itemDir := writeItem(t, dir, "item", "", map[string]string{
203 "03 - c.mp4": "c", "01 - a.mp4": "a", "02 - b.mp4": "b",
204 })
205 media, _, err := svc.listItemFiles(itemDir)
206 if err != nil {
207 t.Fatalf("listItemFiles: %v", err)
208 }
209 got := make([]string, len(media))
210 for i, m := range media {
211 got[i] = m.Filename
212 }
213 want := []string{"01 - a.mp4", "02 - b.mp4", "03 - c.mp4"}
214 for i := range want {
215 if got[i] != want[i] {
216 t.Fatalf("media files = %v, want ascending %v", got, want)
217 }
218 }
219}
220
221func TestGetAllSeparatesFoldersAndItems(t *testing.T) {
222 svc, dir := newLibrary(t)
223 // An item (has marker)
224 writeItem(t, dir, "song", "name = \"Song\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
225 // A plain folder (no marker) containing a nested item
226 writeItem(t, dir, "folder/nested", "name = \"Nested\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
227 // The reserved subtitles dir at root must be ignored
228 if err := os.MkdirAll(filepath.Join(dir, subtitlesDirName), 0o755); err != nil {
229 t.Fatal(err)
230 }
231
232 items, folders, err := svc.GetAll("", "title", "")
233 if err != nil {
234 t.Fatalf("GetAll: %v", err)
235 }
236 if len(items) != 1 || items[0].Name != "Song" {
237 t.Errorf("expected 1 item 'Song', got %+v", items)
238 }
239 if len(folders) != 1 || folders[0] != "folder" {
240 t.Errorf("expected folder 'folder', got %v", folders)
241 }
242 for _, f := range folders {
243 if f == subtitlesDirName {
244 t.Error("subtitles dir should not be listed as a folder")
245 }
246 }
247}
248
249func TestGetAllFilter(t *testing.T) {
250 svc, dir := newLibrary(t)
251 writeItem(t, dir, "alpha", "name = \"Alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
252 writeItem(t, dir, "beta", "name = \"Beta\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
253
254 items, _, err := svc.GetAll("", "title", "alph")
255 if err != nil {
256 t.Fatalf("GetAll: %v", err)
257 }
258 if len(items) != 1 || items[0].Name != "Alpha" {
259 t.Errorf("filter 'alph' should match only Alpha, got %+v", items)
260 }
261}
262
263func TestGetAllSortByTitle(t *testing.T) {
264 svc, dir := newLibrary(t)
265 writeItem(t, dir, "c", "name = \"Charlie\"\nduration = -1\n", map[string]string{"c.mp4": "v"})
266 writeItem(t, dir, "a", "name = \"alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
267 writeItem(t, dir, "b", "name = \"Bravo\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
268
269 items, _, err := svc.GetAll("", "title", "")
270 if err != nil {
271 t.Fatalf("GetAll: %v", err)
272 }
273 got := []string{items[0].Name, items[1].Name, items[2].Name}
274 want := []string{"alpha", "Bravo", "Charlie"} // case-insensitive
275 for i := range want {
276 if got[i] != want[i] {
277 t.Errorf("sort by title = %v, want %v", got, want)
278 break
279 }
280 }
281}
282
283// --- metadata derivation + marker write behavior ---
284
285func TestScanItemDerivesMetadataFromInfoJSON(t *testing.T) {
286 svc, dir := newLibrary(t)
287 info := `{"title":"Real Title","webpage_url":"https://example.com/v","description":"hello","duration":149.2}`
288 writeItem(t, dir, "x", "duration = -1\n", map[string]string{
289 "x.mp4": "v",
290 "info.json": info,
291 })
292
293 item, err := svc.GetByRelPath("x")
294 if err != nil {
295 t.Fatalf("GetByRelPath: %v", err)
296 }
297 if item.Name != "Real Title" {
298 t.Errorf("Name = %q, want derived from info.json", item.Name)
299 }
300 if item.SourceURL != "https://example.com/v" {
301 t.Errorf("SourceURL = %q", item.SourceURL)
302 }
303 if item.Description != "hello" {
304 t.Errorf("Description = %q", item.Description)
305 }
306 if item.Duration != 149 {
307 t.Errorf("Duration = %d, want 149", item.Duration)
308 }
309
310 // Derived metadata should be persisted to the marker.
311 markerData, _ := os.ReadFile(filepath.Join(dir, "x", itemMarkerName))
312 if !strings.Contains(string(markerData), "Real Title") {
313 t.Errorf("marker not enriched with derived title: %s", markerData)
314 }
315}
316
317func TestScanItemUsesFileDurations(t *testing.T) {
318 svc, dir := newLibrary(t)
319 marker := "name = \"M\"\n\n[file_durations]\n \"a.mp4\" = 30\n \"b.mp4\" = 12\n"
320 writeItem(t, dir, "multi", marker, map[string]string{"a.mp4": "v", "b.mp4": "v"})
321
322 item, err := svc.GetByRelPath("multi")
323 if err != nil {
324 t.Fatalf("GetByRelPath: %v", err)
325 }
326 got := map[string]int{}
327 for _, mf := range item.MediaFiles {
328 got[mf.Filename] = mf.Duration
329 }
330 if got["a.mp4"] != 30 || got["b.mp4"] != 12 {
331 t.Errorf("per-file durations = %v, want a=30 b=12", got)
332 }
333 // The item duration is the sum of known per-file durations (used for sorting);
334 // there is no single "overall" duration any more.
335 if item.Duration != 42 {
336 t.Errorf("item Duration = %d, want 42 (sum of file durations)", item.Duration)
337 }
338}
339
340func TestScanItemDoesNotRewriteConvergedMarker(t *testing.T) {
341 svc, dir := newLibrary(t)
342 // Disable the scan cache so every read actually rescans — this exercises
343 // scanItem's own idempotency rather than just a cache hit.
344 svc.scanTTL = 0
345 marker := "name = \"Done\"\nsource_url = \"u\"\ndescription = \"d\"\n"
346 itemDir := writeItem(t, dir, "y", marker, map[string]string{"y.mp4": "v"})
347 markerPath := filepath.Join(itemDir, itemMarkerName)
348
349 // First read may enrich the marker (e.g. cache per-file durations), after
350 // which it has converged.
351 if _, err := svc.GetByRelPath("y"); err != nil {
352 t.Fatalf("warm-up read: %v", err)
353 }
354
355 before, err := os.Stat(markerPath)
356 if err != nil {
357 t.Fatal(err)
358 }
359
360 // Subsequent reads must not rewrite a converged marker.
361 for i := 0; i < 3; i++ {
362 if _, err := svc.GetByRelPath("y"); err != nil {
363 t.Fatalf("GetByRelPath: %v", err)
364 }
365 }
366
367 after, err := os.Stat(markerPath)
368 if err != nil {
369 t.Fatal(err)
370 }
371 if !before.ModTime().Equal(after.ModTime()) {
372 t.Errorf("converged marker was rewritten on read (mtime changed %v -> %v)", before.ModTime(), after.ModTime())
373 }
374}
375
376// TestScanItemEmptyInfoFieldsDoNotRewriteMarker is a regression test: an
377// info.json with present-but-empty "description"/"webpage_url" must not flip the
378// marker dirty on every scan. infoString returns ("", true) for those fields, so
379// the old code reassigned "" and rewrote the marker forever (e.g. clips whose
380// text lives only in the title). We detect any rewrite via an unknown TOML key,
381// which scanItem's re-encode would drop.
382func TestScanItemEmptyInfoFieldsDoNotRewriteMarker(t *testing.T) {
383 svc, dir := newLibrary(t)
384 svc.scanTTL = 0 // force a real rescan on every read
385
386 info := `{"title":"#hashtags only","description":"","webpage_url":""}`
387 marker := "name = \"#hashtags only\"\nsentinel = \"keep\"\n"
388 writeItem(t, dir, "z", marker, map[string]string{"z.mp4": "v", "info.json": info})
389 markerPath := filepath.Join(dir, "z", itemMarkerName)
390
391 for i := 0; i < 3; i++ {
392 if _, err := svc.GetByRelPath("z"); err != nil {
393 t.Fatalf("GetByRelPath: %v", err)
394 }
395 }
396
397 data, _ := os.ReadFile(markerPath)
398 if !strings.Contains(string(data), "sentinel") {
399 t.Errorf("marker was rewritten on read (sentinel dropped): %s", data)
400 }
401}
402
403// --- scan cache ---
404
405func TestScanCacheReusesWithinTTL(t *testing.T) {
406 svc, dir := newLibrary(t)
407 writeItem(t, dir, "x", "name = \"X\"\nsource_url = \"u\"\n", map[string]string{"x.mp4": "v"})
408
409 // Within the TTL, repeated lookups return the same cached scan (no rescan).
410 a, err := svc.GetByRelPath("x")
411 if err != nil {
412 t.Fatalf("first lookup: %v", err)
413 }
414 b, err := svc.GetByRelPath("x")
415 if err != nil {
416 t.Fatalf("second lookup: %v", err)
417 }
418 if a != b {
419 t.Error("expected the cached scan to be reused (same pointer) within the TTL")
420 }
421
422 // Deleting the item evicts its cached scan immediately.
423 if err := svc.Delete("x"); err != nil {
424 t.Fatalf("Delete: %v", err)
425 }
426 if _, ok := svc.getCachedScan("x"); ok {
427 t.Error("Delete should evict the cached scan")
428 }
429
430 // With caching disabled, each lookup rescans into a distinct item.
431 svc2, dir2 := newLibrary(t)
432 svc2.scanTTL = 0
433 writeItem(t, dir2, "y", "name = \"Y\"\n", map[string]string{"y.mp4": "v"})
434 c, _ := svc2.GetByRelPath("y")
435 d, _ := svc2.GetByRelPath("y")
436 if c == nil || d == nil || c == d {
437 t.Error("with TTL disabled each lookup should rescan, not reuse a pointer")
438 }
439}
440
441// --- path traversal ---
442
443func TestResolveItemDirRejectsTraversal(t *testing.T) {
444 svc, dir := newLibrary(t)
445 if _, err := svc.resolveItemDir("../../etc"); err == nil {
446 t.Error("expected error for traversal path")
447 }
448 // A legitimate nested path resolves fine.
449 got, err := svc.resolveItemDir("sub/item")
450 if err != nil {
451 t.Fatalf("unexpected error: %v", err)
452 }
453 if !strings.HasPrefix(got, dir) {
454 t.Errorf("resolved path %q not under library %q", got, dir)
455 }
456}
457
458// --- delete safety ---
459
460func TestDeleteRejectsLibraryRoot(t *testing.T) {
461 svc, dir := newLibrary(t)
462 writeItem(t, dir, "keep", "name = \"Keep\"\nduration = -1\n", map[string]string{"k.mp4": "v"})
463
464 for _, rel := range []string{"", "/", "//"} {
465 if err := svc.Delete(rel); err == nil {
466 t.Errorf("Delete(%q) should be refused", rel)
467 }
468 }
469
470 // The guard must not have touched the library or its contents.
471 if _, err := os.Stat(filepath.Join(dir, "keep", "k.mp4")); err != nil {
472 t.Fatalf("library content was deleted by a root-delete attempt: %v", err)
473 }
474
475 // A real item still deletes.
476 if err := svc.Delete("keep"); err != nil {
477 t.Fatalf("Delete(real item): %v", err)
478 }
479 if _, err := os.Stat(filepath.Join(dir, "keep")); !os.IsNotExist(err) {
480 t.Errorf("expected item dir removed, stat err = %v", err)
481 }
482}
483
484// --- Thumbnail behavior ---
485