package service import ( "database/sql" "os" "path/filepath" "strings" "testing" "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, 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, "", 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("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}, // 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}} // 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(d, tempDir, "", ""); err == nil { t.Error("expected path-traversal output dir to be rejected") } }