library_test.go
⎇
Raw
1package service
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "testing"
13
14 "vidarchive/internal/models"
15)
16
17// --- test helpers ---
18
19func newLibrary(t *testing.T) (*LibraryService, string) {
20 t.Helper()
21 dir := t.TempDir()
22 return NewLibraryService(dir, "ffmpeg", "ffprobe"), dir
23}
24
25// writeItem creates an item directory with a marker and the given files
26// (filename -> contents). A nil/empty marker body still creates a valid item.
27func writeItem(t *testing.T, libraryDir, relPath, markerBody string, files map[string]string) string {
28 t.Helper()
29 itemDir := filepath.Join(libraryDir, filepath.FromSlash(relPath))
30 if err := os.MkdirAll(itemDir, 0755); err != nil {
31 t.Fatalf("mkdir item: %v", err)
32 }
33 if markerBody == "" {
34 markerBody = "duration = -1\n"
35 }
36 if err := os.WriteFile(filepath.Join(itemDir, itemMarkerName), []byte(markerBody), 0644); err != nil {
37 t.Fatalf("write marker: %v", err)
38 }
39 for name, content := range files {
40 if err := os.WriteFile(filepath.Join(itemDir, name), []byte(content), 0644); err != nil {
41 t.Fatalf("write file %s: %v", name, err)
42 }
43 }
44 return itemDir
45}
46
47func requireFFmpeg(t *testing.T) {
48 t.Helper()
49 if _, err := exec.LookPath("ffmpeg"); err != nil {
50 t.Skip("ffmpeg not on PATH")
51 }
52}
53
54// makeTestVideo writes a tiny real video file ffmpeg can decode a frame from.
55func makeTestVideo(t *testing.T, path string) {
56 t.Helper()
57 makeTestVideoSize(t, path, "64x64")
58}
59
60// makeTestVideoSize is makeTestVideo with an explicit WxH size.
61func makeTestVideoSize(t *testing.T, path, size string) {
62 t.Helper()
63 // 3s so the default 1s thumbnail seek lands on a real frame.
64 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
65 "-f", "lavfi", "-i", "testsrc=duration=3:size="+size+":rate=5",
66 "-pix_fmt", "yuv420p", path, "-y")
67 if out, err := cmd.CombinedOutput(); err != nil {
68 t.Fatalf("make test video: %v\n%s", err, out)
69 }
70}
71
72// makeVideoWithCoverAttachment writes an MKV at outPath with a 64x64 video and
73// an embedded "cover.webp" image attachment of the given size — mirroring how
74// yt-dlp embeds thumbnails into MKV (a true attachment stream, not an
75// attached_pic video stream; note ffmpeg muxes an attached .png as a video
76// stream, so webp/jpg must be used to get a real attachment).
77func makeVideoWithCoverAttachment(t *testing.T, outPath, coverSize string) {
78 t.Helper()
79 scratch := t.TempDir()
80 base := filepath.Join(scratch, "base.mp4")
81 makeTestVideoSize(t, base, "64x64")
82 cover := filepath.Join(scratch, "cover.webp")
83 if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
84 "-f", "lavfi", "-i", "color=red:size="+coverSize+":duration=1",
85 "-frames:v", "1", cover, "-y").CombinedOutput(); err != nil {
86 t.Fatalf("make cover: %v\n%s", err, out)
87 }
88 if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
89 "-i", base, "-attach", cover, "-metadata:s:t:0", "mimetype=image/webp",
90 "-c", "copy", outPath, "-y").CombinedOutput(); err != nil {
91 t.Fatalf("attach cover: %v\n%s", err, out)
92 }
93}
94
95// probeImageSize returns the pixel dimensions of an image/video file.
96func probeImageSize(t *testing.T, path string) (int, int) {
97 t.Helper()
98 out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
99 "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path).Output()
100 if err != nil {
101 t.Fatalf("probe %s: %v", path, err)
102 }
103 var w, h int
104 if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%dx%d", &w, &h); err != nil {
105 t.Fatalf("parse dimensions %q: %v", out, err)
106 }
107 return w, h
108}
109
110// --- pure helper tests ---
111
112func TestInfoDuration(t *testing.T) {
113 tests := []struct {
114 name string
115 in map[string]interface{}
116 want int
117 ok bool
118 }{
119 {"float", map[string]interface{}{"duration": 149.4}, 149, true},
120 {"float rounds up", map[string]interface{}{"duration": 149.6}, 150, true},
121 {"string", map[string]interface{}{"duration": "12.0"}, 12, true},
122 {"bad string", map[string]interface{}{"duration": "abc"}, 0, false},
123 {"missing", map[string]interface{}{}, 0, false},
124 {"nil", nil, 0, false},
125 }
126 for _, tc := range tests {
127 t.Run(tc.name, func(t *testing.T) {
128 got, ok := infoDuration(tc.in)
129 if ok != tc.ok || got != tc.want {
130 t.Errorf("infoDuration(%v) = (%d,%v), want (%d,%v)", tc.in, got, ok, tc.want, tc.ok)
131 }
132 })
133 }
134}
135
136func TestParseFPS(t *testing.T) {
137 tests := []struct{ in, want string }{
138 {"30/1", "30"},
139 {"30000/1001", "29.97"},
140 {"0/0", ""},
141 {"", ""},
142 {"garbage", ""},
143 }
144 for _, tc := range tests {
145 if got := parseFPS(tc.in); got != tc.want {
146 t.Errorf("parseFPS(%q) = %q, want %q", tc.in, got, tc.want)
147 }
148 }
149}
150
151func TestInfoString(t *testing.T) {
152 if _, ok := infoString(nil, "title"); ok {
153 t.Error("nil info should return false")
154 }
155 if _, ok := infoString(map[string]interface{}{}, "title"); ok {
156 t.Error("missing key should return false")
157 }
158 if v, ok := infoString(map[string]interface{}{"title": "hi"}, "title"); !ok || v != "hi" {
159 t.Errorf("string value = (%q,%v), want (hi,true)", v, ok)
160 }
161 if _, ok := infoString(map[string]interface{}{"title": 5}, "title"); ok {
162 t.Error("numeric value should return false, not stringify")
163 }
164 if _, ok := infoString(map[string]interface{}{"title": map[string]interface{}{"a": 1}}, "title"); ok {
165 t.Error("map value should return false, not stringify")
166 }
167}
168
169func TestPrimaryMediaFile(t *testing.T) {
170 dir := t.TempDir()
171 write := func(name string, n int) string {
172 p := filepath.Join(dir, name)
173 if err := os.WriteFile(p, make([]byte, n), 0644); err != nil {
174 t.Fatal(err)
175 }
176 return p
177 }
178
179 // Largest video wins over a larger audio file.
180 mixed := &models.LibraryItem{MediaFiles: []models.MediaFile{
181 {Filename: "small.mp4", Filepath: write("small.mp4", 10)},
182 {Filename: "big.mp4", Filepath: write("big.mp4", 100)},
183 {Filename: "huge.mp3", Filepath: write("huge.mp3", 1000), IsAudio: true},
184 }}
185 if got := primaryMediaFile(mixed); got == nil || got.Filename != "big.mp4" {
186 t.Errorf("expected largest video big.mp4, got %v", got)
187 }
188
189 // Audio-only item falls back to the largest audio file.
190 audio := &models.LibraryItem{MediaFiles: []models.MediaFile{
191 {Filename: "a.mp3", Filepath: write("a.mp3", 5), IsAudio: true},
192 {Filename: "b.mp3", Filepath: write("b.mp3", 50), IsAudio: true},
193 }}
194 if got := primaryMediaFile(audio); got == nil || got.Filename != "b.mp3" {
195 t.Errorf("expected largest audio b.mp3, got %v", got)
196 }
197
198 if got := primaryMediaFile(&models.LibraryItem{}); got != nil {
199 t.Errorf("empty item should yield nil, got %v", got)
200 }
201}
202
203func TestFormatBitrate(t *testing.T) {
204 tests := []struct{ in, want string }{
205 {"128000", "128"},
206 {"", ""},
207 {"notanumber", ""},
208 }
209 for _, tc := range tests {
210 if got := formatBitrate(tc.in); got != tc.want {
211 t.Errorf("formatBitrate(%q) = %q, want %q", tc.in, got, tc.want)
212 }
213 }
214}
215
216func TestGetEngagement(t *testing.T) {
217 svc, dir := newLibrary(t)
218 info := `{"comments":[{"author":"Alice","text":"hello <world>","timestamp":1700000000,"like_count":3,"author_is_uploader":true},{"channel":"Bob","text":"second","time_text":"yesterday"}],"heatmap":[{"start_time":0,"end_time":10,"value":0.5},{"start_time":10,"end_time":20,"value":1.0}]}`
219 writeItem(t, dir, "engagement", `name = "Engagement"`, map[string]string{
220 "video.mp4": "dummy",
221 "info.json": info,
222 })
223
224 comments, heatmap, err := svc.GetEngagement("engagement")
225 if err != nil {
226 t.Fatalf("GetEngagement: %v", err)
227 }
228 if len(comments) != 2 || comments[0].Author != "Alice" || comments[0].LikeCount != 3 || !comments[0].AuthorIsUploader {
229 t.Fatalf("unexpected comments: %+v", comments)
230 }
231 if comments[0].TimeText != "2023-11-14 22:13" {
232 t.Errorf("timestamp fallback = %q, want UTC time", comments[0].TimeText)
233 }
234 if comments[1].Author != "Bob" || comments[1].TimeText != "yesterday" {
235 t.Fatalf("fallback comment fields not parsed: %+v", comments[1])
236 }
237 if len(heatmap) != 2 || heatmap[0].Width != 50 || heatmap[1].Height != 100 {
238 t.Fatalf("unexpected heatmap: %+v", heatmap)
239 }
240}
241
242func TestGetEngagementFiltersAndSortsHeatmap(t *testing.T) {
243 svc, dir := newLibrary(t)
244 writeItem(t, dir, "heatmap-edge", `name = "Heatmap edge"`, map[string]string{
245 "video.mp4": "dummy",
246 "info.json": `{"heatmap":[{"start_time":10,"end_time":20,"value":0.5},{"start_time":2,"end_time":4,"value":1},{"start_time":5,"end_time":4,"value":1},{"start_time":20,"end_time":21,"value":-1}]}`,
247 })
248 _, heatmap, err := svc.GetEngagement("heatmap-edge")
249 if err != nil {
250 t.Fatalf("GetEngagement: %v", err)
251 }
252 if len(heatmap) != 2 {
253 t.Fatalf("valid heatmap segment count = %d, want 2: %+v", len(heatmap), heatmap)
254 }
255 if heatmap[0].StartTime != 2 || heatmap[1].StartTime != 10 {
256 t.Errorf("heatmap order = %v, want ascending start time", heatmap)
257 }
258 for _, segment := range heatmap {
259 if segment.Width <= 0 || segment.Height < 8 || segment.Height > 100 {
260 t.Errorf("invalid normalized heatmap segment: %+v", segment)
261 }
262 }
263}
264
265func TestOrderCommentsNestsRepliesAndCapsDepth(t *testing.T) {
266 comments := []models.Comment{
267 {ID: "root", Parent: "root", Author: "root"},
268 {ID: "reply", Parent: "root", Author: "reply"},
269 {ID: "deep-1", Parent: "reply", Author: "deep-1"},
270 {ID: "deep-2", Parent: "deep-1", Author: "deep-2"},
271 {ID: "deep-3", Parent: "deep-2", Author: "deep-3"},
272 {ID: "deep-4", Parent: "deep-3", Author: "deep-4"},
273 {ID: "deep-5", Parent: "deep-4", Author: "deep-5"},
274 }
275
276 ordered := orderComments(comments)
277 if len(ordered) != len(comments) {
278 t.Fatalf("orderComments dropped comments: %d of %d", len(ordered), len(comments))
279 }
280 for i, want := range []string{"root", "reply", "deep-1", "deep-2", "deep-3", "deep-4", "deep-5"} {
281 if ordered[i].ID != want {
282 t.Errorf("comment %d = %q, want %q", i, ordered[i].ID, want)
283 }
284 }
285 if ordered[1].ReplyTo != "root" || ordered[len(ordered)-1].ReplyTo != "deep-4" {
286 t.Errorf("reply targets = %q, %q; want root and deep-4", ordered[1].ReplyTo, ordered[len(ordered)-1].ReplyTo)
287 }
288 if ordered[len(ordered)-1].Depth != 4 {
289 t.Errorf("deep reply depth = %d, want capped depth 4", ordered[len(ordered)-1].Depth)
290 }
291}
292
293func TestOrderCommentsHandlesMissingParentsCyclesAndSiblings(t *testing.T) {
294 comments := []models.Comment{
295 {ID: "root", Parent: "root", Author: "root"},
296 {ID: "second", Parent: " root ", Author: "second"},
297 {ID: "first", Parent: "root", Author: "first"},
298 {ID: "orphan", Parent: "missing", Author: "orphan"},
299 {ID: "cycle-a", Parent: "cycle-b", Author: "a"},
300 {ID: "cycle-b", Parent: "cycle-a", Author: "b"},
301 }
302
303 ordered := orderComments(comments)
304 if len(ordered) != len(comments) {
305 t.Fatalf("orderComments dropped malformed-tree comments: %d of %d", len(ordered), len(comments))
306 }
307 var ids []string
308 for _, comment := range ordered {
309 ids = append(ids, comment.ID)
310 }
311 if !reflect.DeepEqual(ids[:3], []string{"root", "second", "first"}) {
312 t.Errorf("sibling/root order = %v, want root then source-order siblings", ids)
313 }
314 for _, comment := range ordered {
315 if comment.ID == "orphan" && comment.Depth != 0 {
316 t.Errorf("orphan depth = %d, want top-level", comment.Depth)
317 }
318 }
319}
320
321func TestGetEngagementMalformedSidecarReturnsError(t *testing.T) {
322 svc, dir := newLibrary(t)
323 writeItem(t, dir, "bad-engagement", `name = "Bad engagement"`, map[string]string{
324 "video.mp4": "dummy",
325 "info.json": `{"comments":[`,
326 })
327 if _, _, err := svc.GetEngagement("bad-engagement"); err == nil {
328 t.Fatal("expected malformed engagement JSON to return an error")
329 }
330}
331
332func TestGetEngagementMissingSidecarIsEmpty(t *testing.T) {
333 svc, dir := newLibrary(t)
334 writeItem(t, dir, "no-engagement", `name = "No engagement"`, map[string]string{"video.mp4": "dummy"})
335 comments, heatmap, err := svc.GetEngagement("no-engagement")
336 if err != nil {
337 t.Fatalf("GetEngagement: %v", err)
338 }
339 if len(comments) != 0 || len(heatmap) != 0 {
340 t.Fatalf("expected empty engagement, got comments=%v heatmap=%v", comments, heatmap)
341 }
342}
343
344// --- listItemFiles / GetAll ---
345
346func TestListItemFilesClassification(t *testing.T) {
347 svc, dir := newLibrary(t)
348 itemDir := writeItem(t, dir, "item", "", map[string]string{
349 "video.mp4": "v",
350 "audio.mp3": "a",
351 "cover.jpg": "img",
352 "thumb.webp": "img",
353 "notes.txt": "ignored",
354 "info.json": `{"title":"x"}`,
355 "video.info.json": `{"title":"x"}`,
356 })
357
358 media, infoPath, err := svc.listItemFiles(itemDir)
359 if err != nil {
360 t.Fatalf("listItemFiles: %v", err)
361 }
362 if len(media) != 2 {
363 t.Fatalf("expected 2 media files, got %d: %+v", len(media), media)
364 }
365 var sawAudio, sawVideo bool
366 for _, m := range media {
367 switch m.Filename {
368 case "audio.mp3":
369 sawAudio = true
370 if !m.IsAudio {
371 t.Error("audio.mp3 should be IsAudio")
372 }
373 case "video.mp4":
374 sawVideo = true
375 if m.IsAudio {
376 t.Error("video.mp4 should not be IsAudio")
377 }
378 default:
379 t.Errorf("unexpected media file %q", m.Filename)
380 }
381 }
382 if !sawAudio || !sawVideo {
383 t.Error("missing expected media files")
384 }
385 if infoPath == "" {
386 t.Error("expected an info.json path")
387 }
388}
389
390func TestListItemFilesSortedAscending(t *testing.T) {
391 svc, dir := newLibrary(t)
392 itemDir := writeItem(t, dir, "item", "", map[string]string{
393 "03 - c.mp4": "c", "01 - a.mp4": "a", "02 - b.mp4": "b",
394 })
395 media, _, err := svc.listItemFiles(itemDir)
396 if err != nil {
397 t.Fatalf("listItemFiles: %v", err)
398 }
399 got := make([]string, len(media))
400 for i, m := range media {
401 got[i] = m.Filename
402 }
403 want := []string{"01 - a.mp4", "02 - b.mp4", "03 - c.mp4"}
404 for i := range want {
405 if got[i] != want[i] {
406 t.Fatalf("media files = %v, want ascending %v", got, want)
407 }
408 }
409}
410
411func TestGetAllSeparatesFoldersAndItems(t *testing.T) {
412 svc, dir := newLibrary(t)
413 // An item (has marker)
414 writeItem(t, dir, "song", "name = \"Song\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
415 // A plain folder (no marker) containing a nested item
416 writeItem(t, dir, "folder/nested", "name = \"Nested\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
417 // The reserved subtitles dir at root must be ignored
418 if err := os.MkdirAll(filepath.Join(dir, subtitlesDirName), 0755); err != nil {
419 t.Fatal(err)
420 }
421
422 items, folders, err := svc.GetAll("", "title", "")
423 if err != nil {
424 t.Fatalf("GetAll: %v", err)
425 }
426 if len(items) != 1 || items[0].Name != "Song" {
427 t.Errorf("expected 1 item 'Song', got %+v", items)
428 }
429 if len(folders) != 1 || folders[0] != "folder" {
430 t.Errorf("expected folder 'folder', got %v", folders)
431 }
432 for _, f := range folders {
433 if f == subtitlesDirName {
434 t.Error("subtitles dir should not be listed as a folder")
435 }
436 }
437}
438
439func TestGetAllFilter(t *testing.T) {
440 svc, dir := newLibrary(t)
441 writeItem(t, dir, "alpha", "name = \"Alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
442 writeItem(t, dir, "beta", "name = \"Beta\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
443
444 items, _, err := svc.GetAll("", "title", "alph")
445 if err != nil {
446 t.Fatalf("GetAll: %v", err)
447 }
448 if len(items) != 1 || items[0].Name != "Alpha" {
449 t.Errorf("filter 'alph' should match only Alpha, got %+v", items)
450 }
451}
452
453func TestGetAllSortByTitle(t *testing.T) {
454 svc, dir := newLibrary(t)
455 writeItem(t, dir, "c", "name = \"Charlie\"\nduration = -1\n", map[string]string{"c.mp4": "v"})
456 writeItem(t, dir, "a", "name = \"alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
457 writeItem(t, dir, "b", "name = \"Bravo\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
458
459 items, _, err := svc.GetAll("", "title", "")
460 if err != nil {
461 t.Fatalf("GetAll: %v", err)
462 }
463 got := []string{items[0].Name, items[1].Name, items[2].Name}
464 want := []string{"alpha", "Bravo", "Charlie"} // case-insensitive
465 for i := range want {
466 if got[i] != want[i] {
467 t.Errorf("sort by title = %v, want %v", got, want)
468 break
469 }
470 }
471}
472
473// --- metadata derivation + marker write behavior ---
474
475func TestScanItemDerivesMetadataFromInfoJSON(t *testing.T) {
476 svc, dir := newLibrary(t)
477 info := `{"title":"Real Title","webpage_url":"https://example.com/v","description":"hello","duration":149.2}`
478 writeItem(t, dir, "x", "duration = -1\n", map[string]string{
479 "x.mp4": "v",
480 "info.json": info,
481 })
482
483 item, err := svc.GetByRelPath("x")
484 if err != nil {
485 t.Fatalf("GetByRelPath: %v", err)
486 }
487 if item.Name != "Real Title" {
488 t.Errorf("Name = %q, want derived from info.json", item.Name)
489 }
490 if item.SourceURL != "https://example.com/v" {
491 t.Errorf("SourceURL = %q", item.SourceURL)
492 }
493 if item.Description != "hello" {
494 t.Errorf("Description = %q", item.Description)
495 }
496 if item.Duration != 149 {
497 t.Errorf("Duration = %d, want 149", item.Duration)
498 }
499
500 // Derived metadata should be persisted to the marker.
501 markerData, _ := os.ReadFile(filepath.Join(dir, "x", itemMarkerName))
502 if !strings.Contains(string(markerData), "Real Title") {
503 t.Errorf("marker not enriched with derived title: %s", markerData)
504 }
505}
506
507func TestScanItemUsesFileDurations(t *testing.T) {
508 svc, dir := newLibrary(t)
509 marker := "name = \"M\"\n\n[file_durations]\n \"a.mp4\" = 30\n \"b.mp4\" = 12\n"
510 writeItem(t, dir, "multi", marker, map[string]string{"a.mp4": "v", "b.mp4": "v"})
511
512 item, err := svc.GetByRelPath("multi")
513 if err != nil {
514 t.Fatalf("GetByRelPath: %v", err)
515 }
516 got := map[string]int{}
517 for _, mf := range item.MediaFiles {
518 got[mf.Filename] = mf.Duration
519 }
520 if got["a.mp4"] != 30 || got["b.mp4"] != 12 {
521 t.Errorf("per-file durations = %v, want a=30 b=12", got)
522 }
523 // The item duration is the sum of known per-file durations (used for sorting);
524 // there is no single "overall" duration any more.
525 if item.Duration != 42 {
526 t.Errorf("item Duration = %d, want 42 (sum of file durations)", item.Duration)
527 }
528}
529
530func TestScanItemDoesNotRewriteConvergedMarker(t *testing.T) {
531 svc, dir := newLibrary(t)
532 // Disable the scan cache so every read actually rescans — this exercises
533 // scanItem's own idempotency rather than just a cache hit.
534 svc.scanTTL = 0
535 marker := "name = \"Done\"\nsource_url = \"u\"\ndescription = \"d\"\n"
536 itemDir := writeItem(t, dir, "y", marker, map[string]string{"y.mp4": "v"})
537 markerPath := filepath.Join(itemDir, itemMarkerName)
538
539 // First read may enrich the marker (e.g. cache per-file durations), after
540 // which it has converged.
541 if _, err := svc.GetByRelPath("y"); err != nil {
542 t.Fatalf("warm-up read: %v", err)
543 }
544
545 before, err := os.Stat(markerPath)
546 if err != nil {
547 t.Fatal(err)
548 }
549
550 // Subsequent reads must not rewrite a converged marker.
551 for i := 0; i < 3; i++ {
552 if _, err := svc.GetByRelPath("y"); err != nil {
553 t.Fatalf("GetByRelPath: %v", err)
554 }
555 }
556
557 after, err := os.Stat(markerPath)
558 if err != nil {
559 t.Fatal(err)
560 }
561 if !before.ModTime().Equal(after.ModTime()) {
562 t.Errorf("converged marker was rewritten on read (mtime changed %v -> %v)", before.ModTime(), after.ModTime())
563 }
564}
565
566// TestScanItemEmptyInfoFieldsDoNotRewriteMarker is a regression test: an
567// info.json with present-but-empty "description"/"webpage_url" must not flip the
568// marker dirty on every scan. infoString returns ("", true) for those fields, so
569// the old code reassigned "" and rewrote the marker forever (e.g. clips whose
570// text lives only in the title). We detect any rewrite via an unknown TOML key,
571// which scanItem's re-encode would drop.
572func TestScanItemEmptyInfoFieldsDoNotRewriteMarker(t *testing.T) {
573 svc, dir := newLibrary(t)
574 svc.scanTTL = 0 // force a real rescan on every read
575
576 info := `{"title":"#hashtags only","description":"","webpage_url":""}`
577 marker := "name = \"#hashtags only\"\nsentinel = \"keep\"\n"
578 writeItem(t, dir, "z", marker, map[string]string{"z.mp4": "v", "info.json": info})
579 markerPath := filepath.Join(dir, "z", itemMarkerName)
580
581 for i := 0; i < 3; i++ {
582 if _, err := svc.GetByRelPath("z"); err != nil {
583 t.Fatalf("GetByRelPath: %v", err)
584 }
585 }
586
587 data, _ := os.ReadFile(markerPath)
588 if !strings.Contains(string(data), "sentinel") {
589 t.Errorf("marker was rewritten on read (sentinel dropped): %s", data)
590 }
591}
592
593// --- scan cache ---
594
595func TestScanCacheReusesWithinTTL(t *testing.T) {
596 svc, dir := newLibrary(t)
597 writeItem(t, dir, "x", "name = \"X\"\nsource_url = \"u\"\n", map[string]string{"x.mp4": "v"})
598
599 // Within the TTL, repeated lookups return the same cached scan (no rescan).
600 a, err := svc.GetByRelPath("x")
601 if err != nil {
602 t.Fatalf("first lookup: %v", err)
603 }
604 b, err := svc.GetByRelPath("x")
605 if err != nil {
606 t.Fatalf("second lookup: %v", err)
607 }
608 if a != b {
609 t.Error("expected the cached scan to be reused (same pointer) within the TTL")
610 }
611
612 // Deleting the item evicts its cached scan immediately.
613 if err := svc.Delete("x"); err != nil {
614 t.Fatalf("Delete: %v", err)
615 }
616 if _, ok := svc.getCachedScan("x"); ok {
617 t.Error("Delete should evict the cached scan")
618 }
619
620 // With caching disabled, each lookup rescans into a distinct item.
621 svc2, dir2 := newLibrary(t)
622 svc2.scanTTL = 0
623 writeItem(t, dir2, "y", "name = \"Y\"\n", map[string]string{"y.mp4": "v"})
624 c, _ := svc2.GetByRelPath("y")
625 d, _ := svc2.GetByRelPath("y")
626 if c == nil || d == nil || c == d {
627 t.Error("with TTL disabled each lookup should rescan, not reuse a pointer")
628 }
629}
630
631// --- path traversal ---
632
633func TestResolveItemDirRejectsTraversal(t *testing.T) {
634 svc, dir := newLibrary(t)
635 if _, err := svc.resolveItemDir("../../etc"); err == nil {
636 t.Error("expected error for traversal path")
637 }
638 // A legitimate nested path resolves fine.
639 got, err := svc.resolveItemDir("sub/item")
640 if err != nil {
641 t.Fatalf("unexpected error: %v", err)
642 }
643 if !strings.HasPrefix(got, dir) {
644 t.Errorf("resolved path %q not under library %q", got, dir)
645 }
646}
647
648// --- delete safety ---
649
650func TestDeleteRejectsLibraryRoot(t *testing.T) {
651 svc, dir := newLibrary(t)
652 writeItem(t, dir, "keep", "name = \"Keep\"\nduration = -1\n", map[string]string{"k.mp4": "v"})
653
654 for _, rel := range []string{"", "/", "//"} {
655 if err := svc.Delete(rel); err == nil {
656 t.Errorf("Delete(%q) should be refused", rel)
657 }
658 }
659
660 // The guard must not have touched the library or its contents.
661 if _, err := os.Stat(filepath.Join(dir, "keep", "k.mp4")); err != nil {
662 t.Fatalf("library content was deleted by a root-delete attempt: %v", err)
663 }
664
665 // A real item still deletes.
666 if err := svc.Delete("keep"); err != nil {
667 t.Fatalf("Delete(real item): %v", err)
668 }
669 if _, err := os.Stat(filepath.Join(dir, "keep")); !os.IsNotExist(err) {
670 t.Errorf("expected item dir removed, stat err = %v", err)
671 }
672}
673
674// --- Thumbnail behavior ---
675
676func TestThumbnailPrefersExistingGenerated(t *testing.T) {
677 svc, dir := newLibrary(t)
678 writeItem(t, dir, "item", "name = \"I\"\nduration = -1\n", map[string]string{
679 "video.mp4": "v",
680 "video.thumbnail.webp": "GENERATED",
681 })
682
683 path, ok := svc.ThumbnailForFile("item", "video.mp4")
684 if !ok {
685 t.Fatal("expected a thumbnail")
686 }
687 if !strings.HasSuffix(path, "video.thumbnail.webp") {
688 t.Errorf("expected generated thumbnail, got %q", path)
689 }
690}
691
692func TestThumbnailExtractsOnDemand(t *testing.T) {
693 requireFFmpeg(t)
694 svc, dir := newLibrary(t)
695 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
696 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
697
698 path, ok := svc.ThumbnailForFile("vid", "vid.mp4")
699 if !ok {
700 t.Fatal("expected on-demand extraction to succeed")
701 }
702 if info, err := os.Stat(path); err != nil || info.Size() == 0 {
703 t.Fatalf("thumbnail file missing/empty: %v", err)
704 }
705 if !strings.Contains(filepath.Base(path), ".thumbnail.") {
706 t.Errorf("unexpected thumbnail name %q", path)
707 }
708 // No leftover temp files from the atomic-write path.
709 entries, _ := os.ReadDir(itemDir)
710 for _, e := range entries {
711 if strings.Contains(e.Name(), ".tmp") {
712 t.Errorf("leftover temp file %q", e.Name())
713 }
714 }
715}
716
717func TestThumbnailRetriesAfterDeletion(t *testing.T) {
718 requireFFmpeg(t)
719 svc, dir := newLibrary(t)
720 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
721 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
722
723 first, ok := svc.ThumbnailForFile("vid", "vid.mp4")
724 if !ok {
725 t.Fatal("first extraction failed")
726 }
727 if err := os.Remove(first); err != nil {
728 t.Fatal(err)
729 }
730 // A failure/absence must not be cached permanently: re-request re-extracts.
731 second, ok := svc.ThumbnailForFile("vid", "vid.mp4")
732 if !ok {
733 t.Fatal("re-extraction after deletion failed (failure was cached)")
734 }
735 if info, err := os.Stat(second); err != nil || info.Size() == 0 {
736 t.Fatalf("re-extracted thumbnail missing/empty: %v", err)
737 }
738}
739
740func TestThumbnailConcurrentSingleExtraction(t *testing.T) {
741 requireFFmpeg(t)
742 svc, dir := newLibrary(t)
743 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
744 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
745
746 var wg sync.WaitGroup
747 for i := 0; i < 8; i++ {
748 wg.Add(1)
749 go func() {
750 defer wg.Done()
751 if _, ok := svc.ThumbnailForFile("vid", "vid.mp4"); !ok {
752 t.Error("concurrent Thumbnail failed")
753 }
754 }()
755 }
756 wg.Wait()
757
758 // Exactly one generated thumbnail, no temp leftovers despite the race.
759 entries, _ := os.ReadDir(itemDir)
760 var thumbs int
761 for _, e := range entries {
762 if strings.Contains(e.Name(), ".thumbnail.") {
763 thumbs++
764 }
765 if strings.Contains(e.Name(), ".tmp") {
766 t.Errorf("leftover temp file %q", e.Name())
767 }
768 }
769 if thumbs != 1 {
770 t.Errorf("expected exactly 1 generated thumbnail, got %d", thumbs)
771 }
772}
773
774func TestThumbnailForFileIsPerFile(t *testing.T) {
775 requireFFmpeg(t)
776 svc, dir := newLibrary(t)
777 itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil)
778 makeTestVideo(t, filepath.Join(itemDir, "a.mp4"))
779 makeTestVideo(t, filepath.Join(itemDir, "b.mp4"))
780
781 pa, ok := svc.ThumbnailForFile("multi", "a.mp4")
782 if !ok {
783 t.Fatal("thumbnail for a.mp4 failed")
784 }
785 pb, ok := svc.ThumbnailForFile("multi", "b.mp4")
786 if !ok {
787 t.Fatal("thumbnail for b.mp4 failed")
788 }
789 if pa == pb {
790 t.Errorf("expected distinct per-file thumbnails, both = %q", pa)
791 }
792 if !strings.Contains(filepath.Base(pa), "a.thumbnail.") {
793 t.Errorf("a.mp4 thumbnail name = %q", filepath.Base(pa))
794 }
795 if !strings.Contains(filepath.Base(pb), "b.thumbnail.") {
796 t.Errorf("b.mp4 thumbnail name = %q", filepath.Base(pb))
797 }
798
799 // An unknown file yields no thumbnail (caller falls back to an icon).
800 if _, ok := svc.ThumbnailForFile("multi", "nope.mp4"); ok {
801 t.Error("unknown file should not produce a thumbnail")
802 }
803
804 // Every thumbnail is keyed to a specific file: an empty filename yields none.
805 if _, ok := svc.ThumbnailForFile("multi", ""); ok {
806 t.Error("empty filename should not produce a thumbnail")
807 }
808}
809
810func TestGetMetadataForSelectedFile(t *testing.T) {
811 requireFFmpeg(t)
812 svc, dir := newLibrary(t)
813 itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil)
814 makeTestVideoSize(t, filepath.Join(itemDir, "small.mp4"), "64x64")
815 makeTestVideoSize(t, filepath.Join(itemDir, "big.mp4"), "128x72")
816
817 m1, err := svc.GetMetadata("multi", "small.mp4")
818 if err != nil {
819 t.Fatalf("GetMetadata small: %v", err)
820 }
821 if m1.Resolution != "64x64" {
822 t.Errorf("small.mp4 resolution = %q, want 64x64", m1.Resolution)
823 }
824 m2, err := svc.GetMetadata("multi", "big.mp4")
825 if err != nil {
826 t.Fatalf("GetMetadata big: %v", err)
827 }
828 if m2.Resolution != "128x72" {
829 t.Errorf("big.mp4 resolution = %q, want 128x72", m2.Resolution)
830 }
831}
832
833func TestThumbnailConcurrencyBounded(t *testing.T) {
834 requireFFmpeg(t)
835 svc, dir := newLibrary(t)
836 itemDir := writeItem(t, dir, "many", "name = \"M\"\nduration = -1\n", nil)
837 const n = 8
838 for i := 0; i < n; i++ {
839 makeTestVideo(t, filepath.Join(itemDir, fmt.Sprintf("c%d.mp4", i)))
840 }
841
842 var wg sync.WaitGroup
843 for i := 0; i < n; i++ {
844 i := i
845 wg.Add(1)
846 go func() {
847 defer wg.Done()
848 svc.ThumbnailForFile("many", fmt.Sprintf("c%d.mp4", i))
849 }()
850 }
851 wg.Wait()
852
853 max := atomic.LoadInt32(&svc.extractMaxConcurrent)
854 if max > maxConcurrentThumbnails {
855 t.Errorf("peak concurrent extractions %d exceeded cap %d", max, maxConcurrentThumbnails)
856 }
857 if max < 1 {
858 t.Error("expected at least one extraction to run")
859 }
860}
861
862func TestThumbnailUsesEmbeddedAttachment(t *testing.T) {
863 requireFFmpeg(t)
864 svc, dir := newLibrary(t)
865 itemDir := writeItem(t, dir, "att", "name = \"A\"\nduration = -1\n", nil)
866 mkv := filepath.Join(itemDir, "v.mkv")
867 // 100x100 cover so it's distinguishable from a 64x64 video frame.
868 makeVideoWithCoverAttachment(t, mkv, "100x100")
869
870 if idx := svc.findImageAttachment(mkv); idx < 0 {
871 t.Fatal("findImageAttachment did not find the embedded cover")
872 }
873
874 path, ok := svc.ThumbnailForFile("att", "v.mkv")
875 if !ok {
876 t.Fatal("thumbnail extraction failed")
877 }
878 // The embedded cover (100x100) must be used in preference to a video frame
879 // (which would be 64x64) — this is the regression the refactor introduced.
880 if w, h := probeImageSize(t, path); w != 100 || h != 100 {
881 t.Errorf("thumbnail is %dx%d, expected 100x100 from the embedded cover (got a video frame instead)", w, h)
882 }
883}
884
885func TestThumbnailNegativeCacheSkipsReextraction(t *testing.T) {
886 svc, dir := newLibrary(t)
887 // A file ffmpeg cannot extract a thumbnail from: every attempt fails.
888 writeItem(t, dir, "bad", "name = \"B\"\nduration = -1\n", map[string]string{
889 "broken.mp4": "not actually a video",
890 })
891
892 if _, ok := svc.ThumbnailForFile("bad", "broken.mp4"); ok {
893 t.Fatal("expected extraction to fail for a non-video file")
894 }
895 attempts1 := atomic.LoadInt32(&svc.extractAttempts)
896 if attempts1 == 0 {
897 t.Fatal("expected at least one extraction attempt")
898 }
899
900 // A second request is served from the in-process negative cache: no new
901 // ffmpeg attempt.
902 if _, ok := svc.ThumbnailForFile("bad", "broken.mp4"); ok {
903 t.Fatal("expected the cached failure to persist")
904 }
905 if attempts2 := atomic.LoadInt32(&svc.extractAttempts); attempts2 != attempts1 {
906 t.Errorf("negative cache should prevent re-extraction; attempts %d -> %d", attempts1, attempts2)
907 }
908
909 // The cache is in-process only: a fresh service (≈ a restart) retries.
910 fresh := NewLibraryService(dir, "ffmpeg", "ffprobe")
911 if _, ok := fresh.ThumbnailForFile("bad", "broken.mp4"); ok {
912 t.Fatal("fresh service still fails (file is unextractable)")
913 }
914 if atomic.LoadInt32(&fresh.extractAttempts) == 0 {
915 t.Error("a fresh service should retry extraction, not inherit the negative cache")
916 }
917}
918
919func TestThumbnailAudioOnlyHasNone(t *testing.T) {
920 requireFFmpeg(t)
921 svc, dir := newLibrary(t)
922 itemDir := writeItem(t, dir, "aud", "name = \"A\"\nduration = -1\n", nil)
923 // A real audio file with no cover art.
924 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
925 "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
926 filepath.Join(itemDir, "aud.mp3"), "-y")
927 if out, err := cmd.CombinedOutput(); err != nil {
928 t.Fatalf("make audio: %v\n%s", err, out)
929 }
930
931 if path, ok := svc.ThumbnailForFile("aud", "aud.mp3"); ok {
932 t.Errorf("audio-only item should have no thumbnail, got %q", path)
933 }
934}
935