download_test.go
⎇
Raw
1package service
2
3import (
4 "database/sql"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "vidarchive/internal/config"
11 "vidarchive/internal/models"
12)
13
14func TestSanitizeDirName(t *testing.T) {
15 tests := []struct{ in, want string }{
16 {"normal title", "normal title"},
17 {"a/b:c*d?e\"f<g>h|i", "a-b-c-d-e-f-g-h-i"},
18 {" trimmed ", "trimmed"},
19 {"", "untitled"},
20 {"///", "---"},
21 }
22 for _, tc := range tests {
23 if got := sanitizeDirName(tc.in); got != tc.want {
24 t.Errorf("sanitizeDirName(%q) = %q, want %q", tc.in, got, tc.want)
25 }
26 }
27}
28
29func TestUniqueDir(t *testing.T) {
30 base := t.TempDir()
31 svc := &DownloadService{}
32
33 first := svc.uniqueDir(base, "item")
34 if filepath.Base(first) != "item" {
35 t.Errorf("first uniqueDir = %q, want .../item", first)
36 }
37 if err := os.MkdirAll(first, 0755); err != nil {
38 t.Fatal(err)
39 }
40 second := svc.uniqueDir(base, "item")
41 if filepath.Base(second) != "item-1" {
42 t.Errorf("second uniqueDir = %q, want .../item-1", second)
43 }
44 if err := os.MkdirAll(second, 0755); err != nil {
45 t.Fatal(err)
46 }
47 third := svc.uniqueDir(base, "item")
48 if filepath.Base(third) != "item-2" {
49 t.Errorf("third uniqueDir = %q, want .../item-2", third)
50 }
51}
52
53func TestDeriveItemName(t *testing.T) {
54 svc := &DownloadService{}
55 itemDir := t.TempDir()
56
57 // info.json title wins and is sanitized.
58 infoPath := filepath.Join(itemDir, "info.json")
59 if err := os.WriteFile(infoPath, []byte(`{"title":"Cool: Video"}`), 0644); err != nil {
60 t.Fatal(err)
61 }
62 bigName := "big.mp4"
63 if err := os.WriteFile(filepath.Join(itemDir, bigName), []byte("xxxxxxxxxx"), 0644); err != nil {
64 t.Fatal(err)
65 }
66 media := []os.DirEntry{dirEntry(t, itemDir, bigName)}
67 if got := svc.deriveItemName(itemDir, infoPath, media); got != "Cool- Video" {
68 t.Errorf("deriveItemName with info.json = %q, want %q", got, "Cool- Video")
69 }
70
71 // Without info.json, falls back to the largest media file's stem.
72 small := "small.mp4"
73 if err := os.WriteFile(filepath.Join(itemDir, small), []byte("x"), 0644); err != nil {
74 t.Fatal(err)
75 }
76 media = []os.DirEntry{dirEntry(t, itemDir, small), dirEntry(t, itemDir, bigName)}
77 if got := svc.deriveItemName(itemDir, "", media); got != "big" {
78 t.Errorf("deriveItemName fallback = %q, want %q (largest file stem)", got, "big")
79 }
80}
81
82func dirEntry(t *testing.T, dir, name string) os.DirEntry {
83 t.Helper()
84 entries, err := os.ReadDir(dir)
85 if err != nil {
86 t.Fatal(err)
87 }
88 for _, e := range entries {
89 if e.Name() == name {
90 return e
91 }
92 }
93 t.Fatalf("dir entry %q not found", name)
94 return nil
95}
96
97func TestParseFormatJSON(t *testing.T) {
98 data := []byte(`{
99 "id": "vid",
100 "formats": [
101 {"format_id": "18", "ext": "mp4", "resolution": "640x360", "fps": 30, "vcodec": "avc1", "acodec": "mp4a", "format_note": "360p"},
102 {"format_id": "137", "ext": "mp4", "width": 1920, "height": 1080, "fps": 60, "vcodec": "avc1", "acodec": "none", "filesize": 1048576, "format_note": "1080p"},
103 {"format_id": "233", "ext": "m4a", "resolution": "audio only", "vcodec": "none", "acodec": "mp4a", "audio_channels": 2, "format_note": "audio"}
104 ]
105 }`)
106 formats, err := parseFormatJSON(data)
107 if err != nil {
108 t.Fatalf("parseFormatJSON: %v", err)
109 }
110 if len(formats) != 3 {
111 t.Fatalf("expected 3 formats, got %d: %+v", len(formats), formats)
112 }
113 if formats[0].ID != "18" || formats[0].Ext != "mp4" || formats[0].Resolution != "640x360" {
114 t.Errorf("format[0] = %+v", formats[0])
115 }
116 if formats[0].FPS != "30" {
117 t.Errorf("format[0] fps = %q, want 30", formats[0].FPS)
118 }
119 // Resolution is derived from width/height when no resolution string is present.
120 if formats[1].Resolution != "1920x1080" {
121 t.Errorf("format[1] resolution = %q, want 1920x1080", formats[1].Resolution)
122 }
123 if formats[1].FileSize != "1.0MiB" {
124 t.Errorf("format[1] filesize = %q, want 1.0MiB", formats[1].FileSize)
125 }
126 // Audio-only format: codec falls back to acodec and channels are populated.
127 if formats[2].Codec != "mp4a" {
128 t.Errorf("format[2] codec = %q, want mp4a", formats[2].Codec)
129 }
130 if formats[2].Channels != "2" {
131 t.Errorf("format[2] channels = %q, want 2", formats[2].Channels)
132 }
133}
134
135func TestParseFormatJSONPlaylistFallback(t *testing.T) {
136 // A playlist dump exposes formats under the first entry, not at the top level.
137 data := []byte(`{"_type":"playlist","entries":[{"id":"a","formats":[{"format_id":"18","ext":"mp4"}]}]}`)
138 formats, err := parseFormatJSON(data)
139 if err != nil {
140 t.Fatalf("parseFormatJSON: %v", err)
141 }
142 if len(formats) != 1 || formats[0].ID != "18" {
143 t.Fatalf("expected 1 format from entry fallback, got %+v", formats)
144 }
145}
146
147// TestImportItemDir exercises the full import: media + info.json + subtitles get
148// sorted into a named item directory with a marker. Uses ffmpeg to produce real
149// media so the mimetype-based classification in importItemDir matches.
150func TestImportItemDir(t *testing.T) {
151 requireFFmpeg(t)
152
153 libDir := t.TempDir()
154 svc := &DownloadService{cfg: &config.Config{LibraryDir: libDir, FFprobePath: "ffprobe"}}
155
156 src := t.TempDir()
157 makeTestVideo(t, filepath.Join(src, "raw.mp4"))
158 if err := os.WriteFile(filepath.Join(src, "info.json"), []byte(`{"title":"My Clip"}`), 0644); err != nil {
159 t.Fatal(err)
160 }
161 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 {
162 t.Fatal(err)
163 }
164
165 if err := svc.importItemDir("https://example.com/v", src, libDir, "", "-f bestvideo+bestaudio"); err != nil {
166 t.Fatalf("importItemDir: %v", err)
167 }
168
169 targetDir := filepath.Join(libDir, "My Clip")
170 if _, err := os.Stat(filepath.Join(targetDir, itemMarkerName)); err != nil {
171 t.Errorf("marker not created: %v", err)
172 }
173 if _, err := os.Stat(filepath.Join(targetDir, "info.json")); err != nil {
174 t.Errorf("info.json not moved: %v", err)
175 }
176 if _, err := os.Stat(filepath.Join(targetDir, "raw.mp4")); err != nil {
177 t.Errorf("media not moved: %v", err)
178 }
179 if _, err := os.Stat(filepath.Join(targetDir, subtitlesDirName, "raw.en.srt")); err != nil {
180 t.Errorf("subtitle not moved into subtitles dir: %v", err)
181 }
182
183 // The marker should record the source URL and a probed per-file duration
184 // (the test video is 3s), so the library never has to probe at serve time.
185 markerData, _ := os.ReadFile(filepath.Join(targetDir, itemMarkerName))
186 if want := "https://example.com/v"; !strings.Contains(string(markerData), want) {
187 t.Errorf("marker missing source url %q: %s", want, markerData)
188 }
189 if !strings.Contains(string(markerData), "file_durations") || !strings.Contains(string(markerData), "raw.mp4") {
190 t.Errorf("marker missing probed per-file duration: %s", markerData)
191 }
192 if want := "-f bestvideo+bestaudio"; !strings.Contains(string(markerData), want) {
193 t.Errorf("marker missing yt-dlp flags %q: %s", want, markerData)
194 }
195}
196
197func TestCheckReservedFlags(t *testing.T) {
198 cases := []struct {
199 name string
200 flags string
201 subscription bool
202 wantErr bool
203 }{
204 {"empty", "", false, false},
205 {"harmless", "--no-playlist --write-thumbnail", false, false},
206 {"output short", "-o foo.mp4", false, true},
207 {"output long", "--output foo.mp4", false, true},
208 {"output equals form", "--output=foo.mp4", false, true},
209 {"paths short", "-P /tmp", false, true},
210 {"cookies", "--cookies x.txt", false, true},
211 {"cookies inverse", "--no-cookies", false, true},
212 // Subscription-only reserved flags pass for normal downloads...
213 {"skip-download non-sub", "--skip-download", false, false},
214 {"write-info-json non-sub", "--write-info-json", false, false},
215 // ...but are rejected for subscription runs (and their inverses).
216 {"skip-download sub", "--skip-download", true, true},
217 {"no-skip-download sub", "--no-skip-download", true, true},
218 {"write-info-json sub", "--write-info-json", true, true},
219 {"no-write-info-json sub", "--no-write-info-json", true, true},
220 {"download-archive sub", "--download-archive a.txt", true, true},
221 // Base reserved flags still apply to subscriptions.
222 {"output sub", "-o x", true, true},
223 }
224 for _, tc := range cases {
225 t.Run(tc.name, func(t *testing.T) {
226 err := checkReservedFlags(tc.flags, tc.subscription)
227 if tc.wantErr != (err != nil) {
228 t.Errorf("checkReservedFlags(%q, %v) error = %v, wantErr %v", tc.flags, tc.subscription, err, tc.wantErr)
229 }
230 })
231 }
232}
233
234func TestImportDownloadedItemsRejectsOutputTraversal(t *testing.T) {
235 libDir := t.TempDir()
236 svc := &DownloadService{cfg: &config.Config{LibraryDir: libDir}}
237
238 // A temp download dir with one item subdir.
239 tempDir := t.TempDir()
240 itemDir := filepath.Join(tempDir, "item-00001")
241 if err := os.MkdirAll(itemDir, 0755); err != nil {
242 t.Fatal(err)
243 }
244
245 d := &models.Download{
246 URL: "u",
247 OutputDir: sql.NullString{String: "../escape", Valid: true},
248 }
249 if _, err := svc.importDownloadedItems(d, tempDir, "", ""); err == nil {
250 t.Error("expected path-traversal output dir to be rejected")
251 }
252}
253