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