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, 0755); 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), 0644); 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), 0644); 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), 0644); 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 "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) != 2 {
165 t.Fatalf("expected 2 media files, got %d: %+v", len(media), media)
166 }
167 var sawAudio, sawVideo 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 case "video.mp4":
176 sawVideo = true
177 if m.IsAudio {
178 t.Error("video.mp4 should not be IsAudio")
179 }
180 default:
181 t.Errorf("unexpected media file %q", m.Filename)
182 }
183 }
184 if !sawAudio || !sawVideo {
185 t.Error("missing expected media files")
186 }
187 if infoPath == "" {
188 t.Error("expected an info.json path")
189 }
190}
191
192func TestListItemFilesSortedAscending(t *testing.T) {
193 svc, dir := newLibrary(t)
194 itemDir := writeItem(t, dir, "item", "", map[string]string{
195 "03 - c.mp4": "c", "01 - a.mp4": "a", "02 - b.mp4": "b",
196 })
197 media, _, err := svc.listItemFiles(itemDir)
198 if err != nil {
199 t.Fatalf("listItemFiles: %v", err)
200 }
201 got := make([]string, len(media))
202 for i, m := range media {
203 got[i] = m.Filename
204 }
205 want := []string{"01 - a.mp4", "02 - b.mp4", "03 - c.mp4"}
206 for i := range want {
207 if got[i] != want[i] {
208 t.Fatalf("media files = %v, want ascending %v", got, want)
209 }
210 }
211}
212
213func TestGetAllSeparatesFoldersAndItems(t *testing.T) {
214 svc, dir := newLibrary(t)
215 // An item (has marker)
216 writeItem(t, dir, "song", "name = \"Song\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
217 // A plain folder (no marker) containing a nested item
218 writeItem(t, dir, "folder/nested", "name = \"Nested\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
219 // The reserved subtitles dir at root must be ignored
220 if err := os.MkdirAll(filepath.Join(dir, subtitlesDirName), 0755); err != nil {
221 t.Fatal(err)
222 }
223
224 items, folders, err := svc.GetAll("", "title", "")
225 if err != nil {
226 t.Fatalf("GetAll: %v", err)
227 }
228 if len(items) != 1 || items[0].Name != "Song" {
229 t.Errorf("expected 1 item 'Song', got %+v", items)
230 }
231 if len(folders) != 1 || folders[0] != "folder" {
232 t.Errorf("expected folder 'folder', got %v", folders)
233 }
234 for _, f := range folders {
235 if f == subtitlesDirName {
236 t.Error("subtitles dir should not be listed as a folder")
237 }
238 }
239}
240
241func TestGetAllFilter(t *testing.T) {
242 svc, dir := newLibrary(t)
243 writeItem(t, dir, "alpha", "name = \"Alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
244 writeItem(t, dir, "beta", "name = \"Beta\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
245
246 items, _, err := svc.GetAll("", "title", "alph")
247 if err != nil {
248 t.Fatalf("GetAll: %v", err)
249 }
250 if len(items) != 1 || items[0].Name != "Alpha" {
251 t.Errorf("filter 'alph' should match only Alpha, got %+v", items)
252 }
253}
254
255func TestGetAllSortByTitle(t *testing.T) {
256 svc, dir := newLibrary(t)
257 writeItem(t, dir, "c", "name = \"Charlie\"\nduration = -1\n", map[string]string{"c.mp4": "v"})
258 writeItem(t, dir, "a", "name = \"alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
259 writeItem(t, dir, "b", "name = \"Bravo\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
260
261 items, _, err := svc.GetAll("", "title", "")
262 if err != nil {
263 t.Fatalf("GetAll: %v", err)
264 }
265 got := []string{items[0].Name, items[1].Name, items[2].Name}
266 want := []string{"alpha", "Bravo", "Charlie"} // case-insensitive
267 for i := range want {
268 if got[i] != want[i] {
269 t.Errorf("sort by title = %v, want %v", got, want)
270 break
271 }
272 }
273}
274
275// --- metadata derivation + marker write behavior ---
276
277func TestScanItemDerivesMetadataFromInfoJSON(t *testing.T) {
278 svc, dir := newLibrary(t)
279 info := `{"title":"Real Title","webpage_url":"https://example.com/v","description":"hello","duration":149.2}`
280 writeItem(t, dir, "x", "duration = -1\n", map[string]string{
281 "x.mp4": "v",
282 "info.json": info,
283 })
284
285 item, err := svc.GetByRelPath("x")
286 if err != nil {
287 t.Fatalf("GetByRelPath: %v", err)
288 }
289 if item.Name != "Real Title" {
290 t.Errorf("Name = %q, want derived from info.json", item.Name)
291 }
292 if item.SourceURL != "https://example.com/v" {
293 t.Errorf("SourceURL = %q", item.SourceURL)
294 }
295 if item.Description != "hello" {
296 t.Errorf("Description = %q", item.Description)
297 }
298 if item.Duration != 149 {
299 t.Errorf("Duration = %d, want 149", item.Duration)
300 }
301
302 // Derived metadata should be persisted to the marker.
303 markerData, _ := os.ReadFile(filepath.Join(dir, "x", itemMarkerName))
304 if !strings.Contains(string(markerData), "Real Title") {
305 t.Errorf("marker not enriched with derived title: %s", markerData)
306 }
307}
308
309func TestScanItemUsesFileDurations(t *testing.T) {
310 svc, dir := newLibrary(t)
311 marker := "name = \"M\"\n\n[file_durations]\n \"a.mp4\" = 30\n \"b.mp4\" = 12\n"
312 writeItem(t, dir, "multi", marker, map[string]string{"a.mp4": "v", "b.mp4": "v"})
313
314 item, err := svc.GetByRelPath("multi")
315 if err != nil {
316 t.Fatalf("GetByRelPath: %v", err)
317 }
318 got := map[string]int{}
319 for _, mf := range item.MediaFiles {
320 got[mf.Filename] = mf.Duration
321 }
322 if got["a.mp4"] != 30 || got["b.mp4"] != 12 {
323 t.Errorf("per-file durations = %v, want a=30 b=12", got)
324 }
325 // The item duration is the sum of known per-file durations (used for sorting);
326 // there is no single "overall" duration any more.
327 if item.Duration != 42 {
328 t.Errorf("item Duration = %d, want 42 (sum of file durations)", item.Duration)
329 }
330}
331
332func TestScanItemDoesNotRewriteConvergedMarker(t *testing.T) {
333 svc, dir := newLibrary(t)
334 // Disable the scan cache so every read actually rescans — this exercises
335 // scanItem's own idempotency rather than just a cache hit.
336 svc.scanTTL = 0
337 marker := "name = \"Done\"\nsource_url = \"u\"\ndescription = \"d\"\n"
338 itemDir := writeItem(t, dir, "y", marker, map[string]string{"y.mp4": "v"})
339 markerPath := filepath.Join(itemDir, itemMarkerName)
340
341 // First read may enrich the marker (e.g. cache per-file durations), after
342 // which it has converged.
343 if _, err := svc.GetByRelPath("y"); err != nil {
344 t.Fatalf("warm-up read: %v", err)
345 }
346
347 before, err := os.Stat(markerPath)
348 if err != nil {
349 t.Fatal(err)
350 }
351
352 // Subsequent reads must not rewrite a converged marker.
353 for i := 0; i < 3; i++ {
354 if _, err := svc.GetByRelPath("y"); err != nil {
355 t.Fatalf("GetByRelPath: %v", err)
356 }
357 }
358
359 after, err := os.Stat(markerPath)
360 if err != nil {
361 t.Fatal(err)
362 }
363 if !before.ModTime().Equal(after.ModTime()) {
364 t.Errorf("converged marker was rewritten on read (mtime changed %v -> %v)", before.ModTime(), after.ModTime())
365 }
366}
367
368// TestScanItemEmptyInfoFieldsDoNotRewriteMarker is a regression test: an
369// info.json with present-but-empty "description"/"webpage_url" must not flip the
370// marker dirty on every scan. infoString returns ("", true) for those fields, so
371// the old code reassigned "" and rewrote the marker forever (e.g. clips whose
372// text lives only in the title). We detect any rewrite via an unknown TOML key,
373// which scanItem's re-encode would drop.
374func TestScanItemEmptyInfoFieldsDoNotRewriteMarker(t *testing.T) {
375 svc, dir := newLibrary(t)
376 svc.scanTTL = 0 // force a real rescan on every read
377
378 info := `{"title":"#hashtags only","description":"","webpage_url":""}`
379 marker := "name = \"#hashtags only\"\nsentinel = \"keep\"\n"
380 writeItem(t, dir, "z", marker, map[string]string{"z.mp4": "v", "info.json": info})
381 markerPath := filepath.Join(dir, "z", itemMarkerName)
382
383 for i := 0; i < 3; i++ {
384 if _, err := svc.GetByRelPath("z"); err != nil {
385 t.Fatalf("GetByRelPath: %v", err)
386 }
387 }
388
389 data, _ := os.ReadFile(markerPath)
390 if !strings.Contains(string(data), "sentinel") {
391 t.Errorf("marker was rewritten on read (sentinel dropped): %s", data)
392 }
393}
394
395// --- scan cache ---
396
397func TestScanCacheReusesWithinTTL(t *testing.T) {
398 svc, dir := newLibrary(t)
399 writeItem(t, dir, "x", "name = \"X\"\nsource_url = \"u\"\n", map[string]string{"x.mp4": "v"})
400
401 // Within the TTL, repeated lookups return the same cached scan (no rescan).
402 a, err := svc.GetByRelPath("x")
403 if err != nil {
404 t.Fatalf("first lookup: %v", err)
405 }
406 b, err := svc.GetByRelPath("x")
407 if err != nil {
408 t.Fatalf("second lookup: %v", err)
409 }
410 if a != b {
411 t.Error("expected the cached scan to be reused (same pointer) within the TTL")
412 }
413
414 // Deleting the item evicts its cached scan immediately.
415 if err := svc.Delete("x"); err != nil {
416 t.Fatalf("Delete: %v", err)
417 }
418 if _, ok := svc.getCachedScan("x"); ok {
419 t.Error("Delete should evict the cached scan")
420 }
421
422 // With caching disabled, each lookup rescans into a distinct item.
423 svc2, dir2 := newLibrary(t)
424 svc2.scanTTL = 0
425 writeItem(t, dir2, "y", "name = \"Y\"\n", map[string]string{"y.mp4": "v"})
426 c, _ := svc2.GetByRelPath("y")
427 d, _ := svc2.GetByRelPath("y")
428 if c == nil || d == nil || c == d {
429 t.Error("with TTL disabled each lookup should rescan, not reuse a pointer")
430 }
431}
432
433// --- path traversal ---
434
435func TestResolveItemDirRejectsTraversal(t *testing.T) {
436 svc, dir := newLibrary(t)
437 if _, err := svc.resolveItemDir("../../etc"); err == nil {
438 t.Error("expected error for traversal path")
439 }
440 // A legitimate nested path resolves fine.
441 got, err := svc.resolveItemDir("sub/item")
442 if err != nil {
443 t.Fatalf("unexpected error: %v", err)
444 }
445 if !strings.HasPrefix(got, dir) {
446 t.Errorf("resolved path %q not under library %q", got, dir)
447 }
448}
449
450// --- delete safety ---
451
452func TestDeleteRejectsLibraryRoot(t *testing.T) {
453 svc, dir := newLibrary(t)
454 writeItem(t, dir, "keep", "name = \"Keep\"\nduration = -1\n", map[string]string{"k.mp4": "v"})
455
456 for _, rel := range []string{"", "/", "//"} {
457 if err := svc.Delete(rel); err == nil {
458 t.Errorf("Delete(%q) should be refused", rel)
459 }
460 }
461
462 // The guard must not have touched the library or its contents.
463 if _, err := os.Stat(filepath.Join(dir, "keep", "k.mp4")); err != nil {
464 t.Fatalf("library content was deleted by a root-delete attempt: %v", err)
465 }
466
467 // A real item still deletes.
468 if err := svc.Delete("keep"); err != nil {
469 t.Fatalf("Delete(real item): %v", err)
470 }
471 if _, err := os.Stat(filepath.Join(dir, "keep")); !os.IsNotExist(err) {
472 t.Errorf("expected item dir removed, stat err = %v", err)
473 }
474}
475
476// --- Thumbnail behavior ---
477