package service import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "strings" "syscall" "testing" "time" "unicode/utf8" "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\"fh|i", "a-b-c-d-e-f-g-h-i"}, {" trimmed ", "trimmed"}, {"", "untitled"}, {"///", "---"}, // A dot-only name would resolve to the target directory or its parent, so // a title like this must not become a directory name. {".", "untitled"}, {"..", "untitled"}, {" .. ", "untitled"}, {"...", "untitled"}, {".hidden", ".hidden"}, } 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{} mustUnique := func(name string) string { t.Helper() dir, err := svc.uniqueDir(base, name) if err != nil { t.Fatalf("uniqueDir(%q): %v", name, err) } return dir } first := mustUnique("item") if filepath.Base(first) != "item" { t.Errorf("first uniqueDir = %q, want .../item", first) } if err := os.MkdirAll(first, 0o755); err != nil { t.Fatal(err) } second := mustUnique("item") if filepath.Base(second) != "item-1" { t.Errorf("second uniqueDir = %q, want .../item-1", second) } if err := os.MkdirAll(second, 0o755); err != nil { t.Fatal(err) } third := mustUnique("item") if filepath.Base(third) != "item-2" { t.Errorf("third uniqueDir = %q, want .../item-2", third) } } // A name the filesystem rejects makes every os.Stat fail with something other // than ENOENT. uniqueDir must report that instead of looping forever looking // for a free suffix. func TestUniqueDirRejectsUnusableName(t *testing.T) { svc := &DownloadService{} done := make(chan struct{}) go func() { defer close(done) if _, err := svc.uniqueDir(t.TempDir(), strings.Repeat("a", 300)); err == nil { t.Error("uniqueDir accepted an over-long name, want an error") } }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("uniqueDir did not return: it is looping on a non-ENOENT stat error") } } // Titles come from remote metadata, where nothing bounds their length. func TestSanitizeDirNameBoundsLength(t *testing.T) { for _, in := range []string{strings.Repeat("a", 500), strings.Repeat("ミク", 300)} { got := sanitizeDirName(in) if len(got) > maxDirNameBytes { t.Errorf("sanitizeDirName(%d bytes) = %d bytes, want <= %d", len(in), len(got), maxDirNameBytes) } if !utf8.ValidString(got) { t.Errorf("sanitizeDirName(%d bytes) split a rune: %q", len(in), got) } } } 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"}`), 0o644); err != nil { t.Fatal(err) } bigName := "big.mp4" if err := os.WriteFile(filepath.Join(itemDir, bigName), []byte("xxxxxxxxxx"), 0o644); err != nil { t.Fatal(err) } media := []os.DirEntry{dirEntry(t, itemDir, bigName)} if got := svc.deriveItemName(itemDir, readInfoJSON(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"), 0o644); err != nil { t.Fatal(err) } media = []os.DirEntry{dirEntry(t, itemDir, small), dirEntry(t, itemDir, bigName)} if got := svc.deriveItemName(itemDir, infoJSON{}, 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 } // 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, FFprobePath: "ffprobe"}} src := t.TempDir() makeTestVideo(t, filepath.Join(src, "raw.mp4")) if err := os.WriteFile(filepath.Join(src, "info.json"), []byte(`{"title":"My Clip"}`), 0o644); 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"), 0o644); err != nil { t.Fatal(err) } if err := svc.importItemDir(context.Background(), "https://example.com/v", src, libDir, "", "-f bestvideo+bestaudio"); 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 and a probed per-file duration // (the test video is 3s), so the library never has to probe at serve time. 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) } if !strings.Contains(string(markerData), "file_durations") || !strings.Contains(string(markerData), "raw.mp4") { t.Errorf("marker missing probed per-file duration: %s", markerData) } if want := "-f bestvideo+bestaudio"; !strings.Contains(string(markerData), want) { t.Errorf("marker missing yt-dlp flags %q: %s", want, markerData) } } func TestImportDownloadedItemsRejectsOutputTraversal(t *testing.T) { libDir := t.TempDir() svc := &DownloadService{ cfg: &config.Config{LibraryDir: libDir}, librarySvc: NewLibraryService(libDir, "ffmpeg", "ffprobe"), } // A temp download dir with one item subdir. tempDir := t.TempDir() itemDir := filepath.Join(tempDir, "item-00001") if err := os.MkdirAll(itemDir, 0o755); err != nil { t.Fatal(err) } d := &models.Download{ URL: "u", OutputDir: sql.NullString{String: "../escape", Valid: true}, } if _, err := svc.importDownloadedItems(context.Background(), d, tempDir, "", ""); err == nil { t.Error("expected path-traversal output dir to be rejected") } } // A download deleted while its items are being imported must stop, not keep // filling the library with items whose queue row no longer exists. func TestImportDownloadedItemsStopsOnCancel(t *testing.T) { requireFFmpeg(t) libDir := t.TempDir() svc := &DownloadService{ cfg: &config.Config{LibraryDir: libDir, FFprobePath: "ffprobe"}, librarySvc: NewLibraryService(libDir, "ffmpeg", "ffprobe"), } tempDir := t.TempDir() for _, name := range []string{"item-00001", "item-00002"} { itemDir := filepath.Join(tempDir, name) if err := os.MkdirAll(itemDir, 0o755); err != nil { t.Fatal(err) } makeTestVideo(t, filepath.Join(itemDir, "raw.mp4")) } ctx, cancel := context.WithCancel(context.Background()) cancel() imported, err := svc.importDownloadedItems(ctx, &models.Download{URL: "u"}, tempDir, "", "") if !errors.Is(err, context.Canceled) { t.Errorf("error = %v, want context.Canceled", err) } if imported != 0 { t.Errorf("imported = %d, want 0", imported) } entries, err := os.ReadDir(libDir) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Errorf("library got %d entries, want 0", len(entries)) } } // A cancel part-way through a playlist must stop at an item boundary: the items // already imported stay, and no half-written directory is left behind. func TestImportDownloadedItemsStopsBetweenItems(t *testing.T) { requireFFmpeg(t) libDir := t.TempDir() svc := &DownloadService{ cfg: &config.Config{LibraryDir: libDir, FFprobePath: "ffprobe"}, librarySvc: NewLibraryService(libDir, "ffmpeg", "ffprobe"), } tempDir := t.TempDir() for i, name := range []string{"item-00001", "item-00002", "item-00003"} { itemDir := filepath.Join(tempDir, name) if err := os.MkdirAll(itemDir, 0o755); err != nil { t.Fatal(err) } makeTestVideo(t, filepath.Join(itemDir, "raw.mp4")) info := fmt.Sprintf(`{"id":"v%d","title":"Clip %d"}`, i+1, i+1) if err := os.WriteFile(filepath.Join(itemDir, "clip.info.json"), []byte(info), 0o644); err != nil { t.Fatal(err) } } // Cancel as soon as the first item lands, so the second boundary check trips. ctx, cancel := context.WithCancel(context.Background()) stop := make(chan struct{}) go func() { defer close(stop) for { if _, err := os.Stat(filepath.Join(libDir, "Clip 1", itemMarkerName)); err == nil { cancel() return } time.Sleep(2 * time.Millisecond) } }() imported, err := svc.importDownloadedItems(ctx, &models.Download{URL: "u"}, tempDir, "", "") <-stop cancel() if !errors.Is(err, context.Canceled) { t.Fatalf("error = %v, want context.Canceled", err) } if imported < 1 || imported > 2 { t.Errorf("imported = %d, want the cancel to land after 1 or 2 items", imported) } // Whatever was imported must be complete: an item dir without a marker means // the import stopped half-way through writing it. entries, err := os.ReadDir(libDir) if err != nil { t.Fatal(err) } if len(entries) != imported { t.Errorf("library has %d dirs but reported %d imported", len(entries), imported) } for _, entry := range entries { marker := filepath.Join(libDir, entry.Name(), itemMarkerName) if _, err := os.Stat(marker); err != nil { t.Errorf("item %q left without a marker: %v", entry.Name(), err) } } } // Overwrite mode replaces the existing copy of a video in place instead of // adding a second directory for it. func TestImportItemDirOverwriteReplacesExisting(t *testing.T) { requireFFmpeg(t) libDir := t.TempDir() svc := &DownloadService{ cfg: &config.Config{LibraryDir: libDir, FFprobePath: "ffprobe"}, librarySvc: NewLibraryService(libDir, "ffmpeg", "ffprobe"), } // First import establishes the item. first := t.TempDir() makeTestVideo(t, filepath.Join(first, "raw.mp4")) if err := os.WriteFile(filepath.Join(first, "clip.info.json"), []byte(`{"id":"vid1","title":"Old Title"}`), 0o644); err != nil { t.Fatal(err) } if err := svc.importItemDir(context.Background(), "https://example.com/v", first, libDir, "overwrite", ""); err != nil { t.Fatalf("first import: %v", err) } if err := os.WriteFile(filepath.Join(libDir, "Old Title", "stale.txt"), []byte("gone"), 0o644); err != nil { t.Fatal(err) } // Second import of the same video id, now retitled upstream. second := t.TempDir() makeTestVideo(t, filepath.Join(second, "raw.mp4")) if err := os.WriteFile(filepath.Join(second, "clip.info.json"), []byte(`{"id":"vid1","title":"New Title"}`), 0o644); err != nil { t.Fatal(err) } if err := svc.importItemDir(context.Background(), "https://example.com/v", second, libDir, "overwrite", ""); err != nil { t.Fatalf("second import: %v", err) } entries, err := os.ReadDir(libDir) if err != nil { t.Fatal(err) } if len(entries) != 1 || entries[0].Name() != "New Title" { var names []string for _, e := range entries { names = append(names, e.Name()) } t.Fatalf("library = %v, want exactly [New Title]", names) } if _, err := os.Stat(filepath.Join(libDir, "New Title", "stale.txt")); err == nil { t.Error("the replaced item kept a file from the old copy") } } // Without overwrite mode the same video is imported again beside the first, so // the guard above is really the thing preventing duplicates. func TestImportItemDirWithoutOverwriteKeepsBoth(t *testing.T) { requireFFmpeg(t) libDir := t.TempDir() svc := &DownloadService{ cfg: &config.Config{LibraryDir: libDir, FFprobePath: "ffprobe"}, librarySvc: NewLibraryService(libDir, "ffmpeg", "ffprobe"), } for i := 0; i < 2; i++ { src := t.TempDir() makeTestVideo(t, filepath.Join(src, "raw.mp4")) if err := os.WriteFile(filepath.Join(src, "clip.info.json"), []byte(`{"id":"vid1","title":"Same"}`), 0o644); err != nil { t.Fatal(err) } if err := svc.importItemDir(context.Background(), "https://example.com/v", src, libDir, "", ""); err != nil { t.Fatalf("import %d: %v", i, err) } } entries, err := os.ReadDir(libDir) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Errorf("library has %d dirs, want 2 (Same and Same-1)", len(entries)) } } // moveFile falls back to copy+delete when the source and destination are on // different filesystems, which is the normal case for a temp dir on tmpfs and a // library on disk. /dev/shm is a second tmpfs mount, so a rename across it // really does return EXDEV. func TestMoveFileCrossDeviceFallback(t *testing.T) { dstDir, err := os.MkdirTemp("/dev/shm", "vidarchive-move-") if err != nil { t.Skipf("no second filesystem available for a cross-device move: %v", err) } defer os.RemoveAll(dstDir) srcDir := t.TempDir() if sameDevice(t, srcDir, dstDir) { t.Skip("temp dir and /dev/shm are on the same filesystem") } src := filepath.Join(srcDir, "clip.mp4") content := []byte("not really a video, but the bytes must survive") if err := os.WriteFile(src, content, 0o640); err != nil { t.Fatal(err) } // Confirm the fallback is actually what gets exercised. dst := filepath.Join(dstDir, "clip.mp4") if err := os.Rename(src, dst); !errors.Is(err, syscall.EXDEV) { t.Skipf("rename across the two dirs did not report EXDEV: %v", err) } if err := moveFile(src, dst); err != nil { t.Fatalf("moveFile: %v", err) } got, err := os.ReadFile(dst) if err != nil { t.Fatalf("read destination: %v", err) } if string(got) != string(content) { t.Errorf("destination content = %q, want %q", got, content) } info, err := os.Stat(dst) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o640 { t.Errorf("destination mode = %v, want 0640", info.Mode().Perm()) } if _, err := os.Stat(src); !os.IsNotExist(err) { t.Error("source still exists after a successful move") } } // A move that cannot even read the source must not leave a destination behind. func TestMoveFileMissingSourceLeavesNoDestination(t *testing.T) { dir := t.TempDir() dst := filepath.Join(dir, "dst.mp4") if err := moveFile(filepath.Join(dir, "missing.mp4"), dst); err == nil { t.Fatal("expected an error moving a file that does not exist") } if _, err := os.Stat(dst); !os.IsNotExist(err) { t.Error("destination was created for a failed move") } } func sameDevice(t *testing.T, a, b string) bool { t.Helper() var sa, sb syscall.Stat_t if err := syscall.Stat(a, &sa); err != nil { t.Fatal(err) } if err := syscall.Stat(b, &sb); err != nil { t.Fatal(err) } return sa.Dev == sb.Dev }