package service import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "strings" "syscall" "testing" "time" "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"}, {"///", "---"}, } 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{} first := svc.uniqueDir(base, "item") if filepath.Base(first) != "item" { t.Errorf("first uniqueDir = %q, want .../item", first) } if err := os.MkdirAll(first, 0755); err != nil { t.Fatal(err) } second := svc.uniqueDir(base, "item") if filepath.Base(second) != "item-1" { t.Errorf("second uniqueDir = %q, want .../item-1", second) } if err := os.MkdirAll(second, 0755); err != nil { t.Fatal(err) } third := svc.uniqueDir(base, "item") if filepath.Base(third) != "item-2" { t.Errorf("third uniqueDir = %q, want .../item-2", third) } } 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"}`), 0644); err != nil { t.Fatal(err) } bigName := "big.mp4" if err := os.WriteFile(filepath.Join(itemDir, bigName), []byte("xxxxxxxxxx"), 0644); 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"), 0644); 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 } func TestParseFormatJSON(t *testing.T) { data := []byte(`{ "id": "vid", "formats": [ {"format_id": "18", "ext": "mp4", "resolution": "640x360", "fps": 30, "vcodec": "avc1", "acodec": "mp4a", "format_note": "360p"}, {"format_id": "137", "ext": "mp4", "width": 1920, "height": 1080, "fps": 60, "vcodec": "avc1", "acodec": "none", "filesize": 1048576, "format_note": "1080p"}, {"format_id": "233", "ext": "m4a", "resolution": "audio only", "vcodec": "none", "acodec": "mp4a", "audio_channels": 2, "format_note": "audio"} ] }`) formats, err := parseFormatJSON(data) if err != nil { t.Fatalf("parseFormatJSON: %v", err) } if len(formats) != 3 { t.Fatalf("expected 3 formats, got %d: %+v", len(formats), formats) } if formats[0].ID != "18" || formats[0].Ext != "mp4" || formats[0].Resolution != "640x360" { t.Errorf("format[0] = %+v", formats[0]) } if formats[0].FPS != "30" { t.Errorf("format[0] fps = %q, want 30", formats[0].FPS) } // Resolution is derived from width/height when no resolution string is present. if formats[1].Resolution != "1920x1080" { t.Errorf("format[1] resolution = %q, want 1920x1080", formats[1].Resolution) } if formats[1].FileSize != "1.0MiB" { t.Errorf("format[1] filesize = %q, want 1.0MiB", formats[1].FileSize) } // Audio-only format: codec falls back to acodec and channels are populated. if formats[2].Codec != "mp4a" { t.Errorf("format[2] codec = %q, want mp4a", formats[2].Codec) } if formats[2].Channels != "2" { t.Errorf("format[2] channels = %q, want 2", formats[2].Channels) } } func TestParseFormatJSONPlaylistFallback(t *testing.T) { // A playlist dump exposes formats under the first entry, not at the top level. data := []byte(`{"_type":"playlist","entries":[{"id":"a","formats":[{"format_id":"18","ext":"mp4"}]}]}`) formats, err := parseFormatJSON(data) if err != nil { t.Fatalf("parseFormatJSON: %v", err) } if len(formats) != 1 || formats[0].ID != "18" { t.Fatalf("expected 1 format from entry fallback, got %+v", formats) } } // 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"}`), 0644); 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"), 0644); 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 TestCheckReservedFlags(t *testing.T) { cases := []struct { name string flags string subscription bool wantErr bool }{ {"empty", "", false, false}, {"harmless", "--no-playlist --write-thumbnail", false, false}, {"output short", "-o foo.mp4", false, true}, {"output long", "--output foo.mp4", false, true}, {"output equals form", "--output=foo.mp4", false, true}, {"paths short", "-P /tmp", false, true}, {"cookies", "--cookies x.txt", false, true}, {"cookies inverse", "--no-cookies", false, true}, // Subscription-only reserved flags pass for normal downloads... {"skip-download non-sub", "--skip-download", false, false}, {"write-info-json non-sub", "--write-info-json", false, false}, // ...but are rejected for subscription runs (and their inverses). {"skip-download sub", "--skip-download", true, true}, {"no-skip-download sub", "--no-skip-download", true, true}, {"write-info-json sub", "--write-info-json", true, true}, {"no-write-info-json sub", "--no-write-info-json", true, true}, {"download-archive sub", "--download-archive a.txt", true, true}, // The "--flag=value" spelling names the same option as "--flag value", // for the subscription-only table as well as the base one. {"download-archive equals form sub", "--download-archive=a.txt", true, true}, // Base reserved flags still apply to subscriptions. {"output sub", "-o x", true, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := checkReservedFlags(tc.flags, tc.subscription) if tc.wantErr != (err != nil) { t.Errorf("checkReservedFlags(%q, %v) error = %v, wantErr %v", tc.flags, tc.subscription, err, tc.wantErr) } }) } } 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, 0755); 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, 0755); 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, 0755); 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), 0644); 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"}`), 0644); 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"), 0644); 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"}`), 0644); 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"}`), 0644); 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, 0640); 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() != 0640 { 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 }