1import { A, useSearchParams } from "@solidjs/router";
2import Clock from "lucide-solid/icons/clock";
3import ListVideo from "lucide-solid/icons/list-video";
4import Tv from "lucide-solid/icons/tv";
5import Library from "lucide-solid/icons/library";
6import Code from "lucide-solid/icons/code";
7import Pause from "lucide-solid/icons/pause";
8import Play from "lucide-solid/icons/play";
9import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js";
10import { parseResponse } from "hono/client";
11import type { Channels, ConfigFile, Ingest, JobStatus, LibraryEntry, Rules, Show as IndieShow, Video } from "../types/youtube.ts";
12import { api, reason, unconnected } from "../api.ts";
13import { Ago } from "../components/Ago.tsx";
14import { showConfirmDialog } from "../components/Dialog.tsx";
15import { ListPage } from "../components/ListPage.tsx";
16import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
17import { Meter } from "../components/Meter.tsx";
18import { type StatusKind, StatusLabel } from "../components/Status.tsx";
19import { TabBar } from "../components/TabBar.tsx";
20import { toast } from "../components/Toast.tsx";
21import { ago, count, date, datetime, duration, plural, until } from "../format.ts";
22import "./YouTube.css";
23
24const JOB: Record<JobStatus, [StatusKind, string]> = {
25 queued: ["stopped", "queued"],
26 resolving: ["working", "looking up"],
27 downloading: ["working", "downloading"],
28 retrying: ["working", "retrying"],
29 waiting: ["degraded", "waiting"],
30 done: ["healthy", "done"],
31 error: ["down", "failed"],
32};
33
34export const TABS = [["", "queue", ListVideo], ["history", "history", Clock], ["channels", "channels", Tv], ["library", "library", Library], ["config", "YAML", Code]] as const;
35
36function RawConfigs(props: { files: ConfigFile[]; reload: () => unknown }) {
37 const [selected, setSelected] = createSignal(props.files[0]?.name ?? "");
38 const [draft, setDraft] = createSignal<string | null>(null);
39 const [saving, setSaving] = createSignal(false);
40 const file = () => props.files.find((item) => item.name === selected());
41 const save = async (event: SubmitEvent) => {
42 event.preventDefault();
43 const current = file();
44 if (!current || draft() === null) return;
45 setSaving(true);
46 try {
47 await parseResponse(api.youtube.configs[":name"].$put({ param: { name: current.name },
48 json: { original: current.body, body: draft()! } }));
49 await props.reload();
50 setDraft(null);
51 toast(`${current.name} saved`);
52 } catch (failure) {
53 toast(`Couldn't save ${current.name}. ${reason(failure)}`);
54 } finally {
55 setSaving(false);
56 }
57 };
58 return <form class="yt-config edge" onSubmit={save}>
59 <div class="yt-config-head">
60 <label>file <select value={selected()} onChange={(event) => { setSelected(event.currentTarget.value); setDraft(null); }}>
61 <For each={props.files}>{(item) => <option value={item.name}>{item.name}</option>}</For>
62 </select></label>
63 <button class="button" disabled={saving() || draft() === null || draft() === file()?.body} aria-busy={saving()} type="submit">save YAML</button>
64 </div>
65 <Show when={file()} fallback={<div class="empty">No YAML files in the YouTube config folder.</div>}>
66 {(current) => <textarea aria-label={`${current().name} contents`} spellcheck={false}
67 value={draft() ?? current().body} onInput={(event) => setDraft(event.currentTarget.value)} />}
68 </Show>
69 </form>;
70}
71
72function LibraryRow(props: { entry: LibraryEntry; onSaved: () => void }) {
73 const [title, setTitle] = createSignal(props.entry.title);
74 const [season, setSeason] = createSignal(props.entry.season ?? 1);
75 const [episode, setEpisode] = createSignal(props.entry.episode ?? 1);
76 const [busy, setBusy] = createSignal(false);
77 const save = async (event: SubmitEvent) => {
78 event.preventDefault();
79 setBusy(true);
80 try {
81 await parseResponse(api.youtube.library.rename.$post({ json: {
82 path: props.entry.path, title: title(),
83 season: props.entry.type === "indie" ? season() : null,
84 episode: props.entry.type === "indie" ? episode() : null,
85 } }));
86 props.onSaved();
87 } catch (failure) {
88 toast(`Couldn't rename ${props.entry.title}. ${reason(failure)}`);
89 } finally {
90 setBusy(false);
91 }
92 };
93 return <tr><td class="yt-meta">
94 <div>{props.entry.context}</div>
95 <Show when={props.entry.link}><a href={props.entry.link} target="_blank" rel="noopener">watch</a></Show>
96 </td><td><form onSubmit={save} class="yt-library-edit">
97 <input aria-label="Video title" value={title()} onInput={(event) => setTitle(event.currentTarget.value)} />
98 <Show when={props.entry.type === "indie"}>
99 <input aria-label="Season" type="number" min="1" value={season()} onInput={(event) => setSeason(Number(event.currentTarget.value))} />
100 <input aria-label="Episode" type="number" min="1" value={episode()} onInput={(event) => setEpisode(Number(event.currentTarget.value))} />
101 </Show>
102 <button disabled={busy()} aria-busy={busy()}>save</button>
103 </form></td></tr>;
104}
105
106const KEYWORDS = [
107 ["title_include_keywords", "title has"], ["title_exclude_keywords", "title lacks"],
108 ["description_include_keywords", "description has"], ["description_exclude_keywords", "description lacks"],
109] as const;
110
111const EVERYTHING = "19700101";
112
113function rules(r: Rules) {
114 const out = KEYWORDS.flatMap(([key, label]) => r[key]?.length ? [`${label} ${r[key].join(", ")}`] : []);
115 const after = r.download_after;
116 if (after) out.unshift(after === EVERYTHING ? "whole backlog" : `since ${date(local(isoDay(after)))}`);
117 return out;
118}
119
120const isoDay = (ymd: string) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6)}`;
121/** Seconds at local midnight of a YYYY-MM-DD day; a bare date would parse as UTC and land on the day before. */
122const local = (day: string) => Date.parse(`${day}T00:00`) / 1000;
123
124function length(seconds: number) {
125 const h = Math.floor(seconds / 3600);
126 const m = Math.floor(seconds / 60) % 60;
127 const s = String(seconds % 60).padStart(2, "0");
128 return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
129}
130
131function published(day: string) {
132 const days = Math.floor((Date.now() / 1000 - local(day)) / 86_400);
133 return days < 1 ? "today" : days < 2 ? "yesterday" : days < 7 ? `${days}d ago` : date(local(day));
134}
135
136const Linked = (props: { name: string; url?: string; tip?: string }) => (
137 <Show when={props.url} fallback={props.name}>
138 {(url) => <a href={url()} target="_blank" rel="noreferrer" data-tip={props.tip}>{props.name}</a>}
139 </Show>
140);
141
142const pad = (n: number) => String(n).padStart(2, "0");
143const rows = () => [...document.querySelectorAll<HTMLFormElement>(".yt-item:not(.gone)")];
144
145function Row(props: { video: Video; channel?: string; shows: IndieShow[]; onChange: () => unknown }) {
146 const [dest, setDest] = createSignal<string>();
147 const [newShow, setNewShow] = createSignal("");
148 const [title, setTitle] = createSignal<string>();
149 const [season, setSeason] = createSignal<number>();
150 const [episode, setEpisode] = createSignal<number>();
151 const [busy, setBusy] = createSignal<"ingest" | "skip">();
152 const [error, setError] = createSignal("");
153 const [gone, setGone] = createSignal(false);
154 const [active, setActive] = createSignal(false);
155 let form!: HTMLFormElement;
156
157 // A show named like the channel is where its uploads usually go.
158 const destValue = () => dest() ?? (props.shows.some((s) => s.name === props.video.channel) ? `show:${props.video.channel}` : "independent");
159 const show = () => destValue().startsWith("show:") ? destValue().slice(5) : destValue() === "new" ? newShow().trim() : null;
160 const seasons = () => props.shows.find((s) => s.name === show())?.seasons.toSorted((a, b) => a.number - b.number) ?? [];
161 const nextSeason = () => (seasons().at(-1)?.number ?? 0) + 1;
162 const seasonValue = () => season() ?? seasons().at(-1)?.number ?? 1;
163 const episodeValue = () => episode() ?? (seasons().find((s) => s.number === seasonValue())?.episodes ?? 0) + 1;
164
165 const act = async (kind: "ingest" | "skip", request: () => Promise<unknown>) => {
166 if (busy()) return;
167 setBusy(kind);
168 setError("");
169 try {
170 await request();
171 if (form.contains(document.activeElement)) {
172 const others = rows().filter((row) => row !== form);
173 const at = rows().indexOf(form);
174 (others[at] ?? others.at(-1))?.focus();
175 }
176 setGone(true);
177 props.onChange();
178 } catch (failure) {
179 setError(reason(failure));
180 } finally {
181 setBusy();
182 }
183 };
184
185 const ingest = (event: SubmitEvent) => {
186 event.preventDefault();
187 if (show() === "") return form.querySelector<HTMLInputElement>("[name=show]")?.focus();
188 if (!(episodeValue() >= 1)) return form.querySelector<HTMLInputElement>("[name=episode]")?.focus();
189 const choice: Ingest = show() === null
190 ? { dest: destValue() as "independent" | "music" }
191 : { dest: "indie", show: show()!, season: seasonValue(), episode: episodeValue(), title: title()?.trim() ?? "" };
192 act("ingest", () => parseResponse(api.youtube.pending[":key"].ingest.$post({ param: { key: props.video.key }, json: choice })));
193 };
194
195 const skip = () => {
196 const { title, link } = props.video;
197 act("skip", async () => {
198 await parseResponse(api.youtube.pending[":key"].skip.$post({ param: { key: props.video.key } }));
199 toast(`Skipped ${title}`, {
200 label: "undo",
201 run: async () => {
202 await parseResponse(api.youtube.pending.$post({ json: { urls: [link] } }));
203 props.onChange();
204 },
205 });
206 });
207 };
208
209 let hue = 0;
210 for (const char of props.video.link) hue = (hue * 31 + char.charCodeAt(0)) % 360;
211
212 return (
213 <form ref={form} class="yt-item" classList={{ gone: gone() }} tabindex="-1" aria-label={props.video.title} onSubmit={ingest}
214 onFocusIn={() => setActive(true)}
215 onFocusOut={(event) => setActive(form.contains(event.relatedTarget as Node | null))}
216 onKeyDown={(event) => {
217 const field = event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement;
218 if (event.metaKey || event.ctrlKey || event.altKey) return;
219 if (event.key === "Enter" && (event.target === form || event.target instanceof HTMLSelectElement)) {
220 event.preventDefault();
221 form.requestSubmit();
222 } else if (event.key === "s" && !field) {
223 event.preventDefault();
224 skip();
225 }
226 }}>
227 <a class="yt-thumb" href={props.video.link} target="_blank" rel="noreferrer" aria-label="Watch on YouTube"
228 data-tip="watch on YouTube"
229 style={{ "--hue": hue, "background-image": props.video.thumb ? `url(${props.video.thumb})` : undefined }}>
230 <Show when={!props.video.thumb}><Play size={22} /></Show>
231 <Show when={props.video.duration}>{(seconds) => <span class="yt-duration">{length(seconds())}</span>}</Show>
232 </a>
233 <div class="yt-body">
234 <Show when={show() !== null} fallback={<div class="yt-title" classList={{ mono: props.video.resolving }}>{props.video.title}</div>}>
235 <input class="yt-title" aria-label="Episode title" data-tip="episode title" autocomplete="off" spellcheck={false}
236 placeholder={props.video.resolving ? "episode title" : undefined}
237 value={title() ?? (props.video.resolving ? "" : props.video.title)}
238 onInput={(event) => setTitle(event.currentTarget.value)} />
239 </Show>
240 <div class="yt-meta">
241 <Show when={!props.video.resolving} fallback="looking up…">
242 <Linked name={props.video.channel} url={props.channel} />,{" "}
243 <span data-tip={date(local(props.video.published))}>{published(props.video.published)}</span>
244 </Show>
245 </div>
246 <div class="yt-fields">
247 <select class="search yt-dest" aria-label="Destination" onChange={(event) => {
248 setDest(event.currentTarget.value);
249 setSeason();
250 setEpisode();
251 }}>
252 <option value="independent" selected={destValue() === "independent"}>independent</option>
253 <option value="music" selected={destValue() === "music"}>music</option>
254 <optgroup label="shows">
255 <For each={props.shows}>
256 {(s) => <option value={`show:${s.name}`} selected={destValue() === `show:${s.name}`}>{s.name}</option>}
257 </For>
258 <option value="new" selected={destValue() === "new"}>new show…</option>
259 </optgroup>
260 </select>
261 <Show when={show() !== null}>
262 <Show when={destValue() === "new"}>
263 <input class="search yt-show" name="show" placeholder="show name" aria-label="Show name" autocomplete="off"
264 value={newShow()} onInput={(event) => setNewShow(event.currentTarget.value)} />
265 </Show>
266 <Show when={seasons().length}>
267 <select class="search yt-season" aria-label="Season" onChange={(event) => {
268 setSeason(Number(event.currentTarget.value));
269 setEpisode();
270 }}>
271 <For each={seasons()}>
272 {(s) => <option value={s.number} selected={s.number === seasonValue()}>season {s.number}</option>}
273 </For>
274 <option value={nextSeason()} selected={nextSeason() === seasonValue()}>new season {nextSeason()}</option>
275 </select>
276 </Show>
277 <label class="yt-episode" data-tip="episode">
278 <span aria-hidden="true">E</span>
279 <input class="search" name="episode" type="number" min="1" aria-label="Episode" value={episodeValue()}
280 onInput={(event) => setEpisode(event.currentTarget.valueAsNumber)} />
281 </label>
282 </Show>
283 <div class="yt-actions">
284 <button type="button" class="button" disabled={!!busy()} aria-busy={busy() === "skip"} onClick={skip}>
285 skip<Show when={active()}><kbd aria-hidden="true">s</kbd></Show>
286 </button>
287 <button class="button" classList={{ primary: active() }} disabled={!!busy()} aria-busy={busy() === "ingest"}
288 data-tip={show() ? `${show()} S${pad(seasonValue())}E${pad(episodeValue())}` : undefined}>
289 ingest<Show when={active()}><kbd aria-hidden="true">enter</kbd></Show>
290 </button>
291 </div>
292 </div>
293 <Show when={error()}><div class="error" role="alert">{error()}</div></Show>
294 </div>
295 </form>
296 );
297}
298
299const queueSkeleton = () => (
300 <div class="edge yt-queue">
301 <For each={[0, 1, 2, 3]}>
302 {() => (
303 <div class="yt-item">
304 <span class="skeleton yt-thumb" />
305 <div class="yt-body">
306 <span class="skeleton" style={{ width: "60%", height: "14px" }} />
307 <span class="skeleton" style={{ width: "30%", height: "12px" }} />
308 <span class="skeleton" style={{ width: "160px", height: "26px" }} />
309 </div>
310 </div>
311 )}
312 </For>
313 </div>
314);
315
316const handle = (url: string) => url.replace(/^https?:\/\/(www\.|m\.)?youtube\.com\//, "");
317
318/** Opens the editor for `list[index]`, or for a new channel without an index. */
319function editChannel(all: Channels, list: keyof Channels, save: (next: Channels) => Promise<unknown>, index?: number) {
320 const current = index === undefined ? undefined : all[list][index];
321 const rules: Rules = (list === "archive" && index !== undefined && all.archive[index]?.rules) || {};
322 const taken = all[list].filter((_, i) => i !== index).map((c) => c.name);
323 showConfirmDialog({
324 title: current ? `Edit ${current.name}` : list === "notify" ? "Add review channel" : "Add auto-download channel",
325 description: list === "notify" ? "New uploads land in the queue." : "ytdl-sub downloads every upload that passes the rules.",
326 confirmLabel: current ? "save" : "add",
327 body: () => {
328 const [name, setName] = createSignal(current?.name ?? "");
329 const [backlog, setBacklog] = createSignal(!rules.download_after ? "" : rules.download_after === EVERYTHING ? "all" : "since");
330 const problem = () => (taken.includes(name().trim()) ? `${name().trim()} is already on this list. Pick another name` : "");
331 return (
332 <>
333 <label class="field">
334 name
335 <input name="name" class="search" required autofocus autocomplete="off" spellcheck={false} value={name()}
336 aria-invalid={!!problem()} aria-describedby="yt-channel-problem"
337 ref={(input) => createEffect(() => input.setCustomValidity(problem()))}
338 onInput={(event) => setName(event.currentTarget.value)} />
339 <Show when={problem()}><span id="yt-channel-problem" class="error">{problem()}</span></Show>
340 </label>
341 <label class="field">
342 channel link
343 <input name="url" class="search" type="url" required autocomplete="off" spellcheck={false}
344 placeholder="https://www.youtube.com/@handle…" value={current?.url ?? ""} />
345 </label>
346 <Show when={list === "archive"}>
347 <div class="field">
348 <label for="yt-backlog">backlog</label>
349 <div class="yt-backlog">
350 <select id="yt-backlog" name="backlog" class="search" onChange={(event) => setBacklog(event.currentTarget.value)}>
351 <option value="" selected={backlog() === ""}>preset default</option>
352 <option value="all" selected={backlog() === "all"}>whole backlog</option>
353 <option value="since" selected={backlog() === "since"}>since a date</option>
354 </select>
355 <Show when={backlog() === "since"}>
356 <input name="after" class="search" type="date" required aria-label="Backlog start"
357 value={rules.download_after && rules.download_after !== EVERYTHING ? isoDay(rules.download_after) : ""} />
358 </Show>
359 </div>
360 </div>
361 <div class="yt-keywords" role="group" aria-label="Keyword rules">
362 <span /><span>title</span><span>description</span>
363 <For each={[["only if has", KEYWORDS[0], KEYWORDS[2]], ["skip if has", KEYWORDS[1], KEYWORDS[3]]] as const}>
364 {([row, ...cells]) => (
365 <>
366 <span>{row}</span>
367 <For each={cells}>
368 {([key, label]) => (
369 <input name={key} class="search" aria-label={label} autocomplete="off" spellcheck={false}
370 value={rules[key]?.join(", ") ?? ""} />
371 )}
372 </For>
373 </>
374 )}
375 </For>
376 <span /><span class="hint">separate words with commas</span>
377 </div>
378 </Show>
379 </>
380 );
381 },
382 onConfirm: async (form) => {
383 const channel = { name: String(form.get("name")).trim(), url: String(form.get("url")).trim() };
384 const at = index ?? all[list].length;
385 const replace = index === undefined ? 0 : 1;
386 const next = structuredClone(all);
387 if (list === "notify") next.notify.splice(at, replace, channel);
388 else {
389 const backlog = form.get("backlog");
390 const rules: Rules = {
391 download_after: backlog === "all" ? EVERYTHING : backlog === "since" ? String(form.get("after")).replaceAll("-", "") : undefined,
392 };
393 for (const [key] of KEYWORDS) {
394 const words = String(form.get(key)).split(",").map((word) => word.trim()).filter(Boolean);
395 if (words.length) rules[key] = words;
396 }
397 next.archive.splice(at, replace, { ...channel, rules });
398 }
399 await save(next);
400 },
401 });
402}
403
404function ChannelLists(props: { channels: Channels; pending: Video[]; onSaved: () => unknown }) {
405 const save = async (next: Channels) => {
406 await parseResponse(api.youtube.channels.$put({ json: next }));
407 await props.onSaved();
408 };
409 const remove = async (list: keyof Channels, index: number) => {
410 const before = props.channels;
411 const next = structuredClone(before);
412 const [gone] = next[list].splice(index, 1);
413 try {
414 await save(next);
415 toast(`Removed ${gone!.name}`, { label: "undo", run: () => save(before) });
416 } catch (failure) {
417 toast(`Couldn't remove ${gone!.name}. ${reason(failure)}`);
418 }
419 };
420 const queued = (channel: string) => props.pending.filter((v) => v.channel === channel);
421
422 const section = (list: keyof Channels, label: string, file: string) => (
423 <>
424 <div class="yt-list-head">
425 <h2 data-tip={file}>{label}</h2>
426 <span class="muted">{plural(props.channels[list].length, "channel")}</span>
427 <button class="button small" onClick={() => editChannel(props.channels, list, save)}>add channel</button>
428 </div>
429 <table class="data edge yt-channels">
430 <tbody>
431 <For each={props.channels[list]}>
432 {(channel, index) => (
433 <tr>
434 <td class="yt-channel">{channel.name}</td>
435 <td class="yt-handle"><a href={channel.url} target="_blank" rel="noreferrer">{handle(channel.url)}</a></td>
436 <td class="yt-rules">
437 <Show when={"rules" in channel && channel.rules} fallback={
438 <Show when={queued(channel.name).length}>
439 {(n) => (
440 <A class="yt-meta" href="/youtube" data-tip={queued(channel.name)[0]!.title + (n() > 1 ? `, and ${n() - 1} more` : "")}>
441 {n()} in queue
442 </A>
443 )}
444 </Show>
445 }>
446 {(r) => <For each={rules(r())}>{(rule) => <span class="chip">{rule}</span>}</For>}
447 </Show>
448 </td>
449 <td class="actions">
450 <div>
451 <button onClick={() => editChannel(props.channels, list, save, index())}>edit</button>
452 <button onClick={() => remove(list, index())}>remove</button>
453 </div>
454 </td>
455 </tr>
456 )}
457 </For>
458 </tbody>
459 </table>
460 </>
461 );
462
463 return (
464 <>
465 {section("notify", "review", "feed.yaml")}
466 {section("archive", "auto-download", "subscriptions.yaml")}
467 </>
468 );
469}
470
471export function YouTube() {
472 const [params, setParams] = useSearchParams<{ tab?: string }>();
473 const tab = () => TABS.find(([id]) => id === params.tab)?.[0] ?? "";
474 const [data, { refetch }] = createResource(() => parseResponse(api.youtube.$get()));
475 const [channels, { refetch: refetchChannels }] = createResource(() => parseResponse(api.youtube.channels.$get()));
476 const [library, { refetch: refetchLibrary }] = createResource(
477 () => tab() === "library" ? "library" : undefined,
478 () => parseResponse(api.youtube.library.$get()),
479 );
480 const [configs, { refetch: refetchConfigs }] = createResource(
481 () => tab() === "config" ? "config" : undefined,
482 () => parseResponse(api.youtube.configs.$get()),
483 );
484 const [librarySearch, setLibrarySearch] = createSignal("");
485 const [now, setNow] = createSignal(Date.now() / 1000);
486 const timers = [setInterval(() => { if (!data.loading && !unconnected(data.error)) refetch(); }, 4000), setInterval(() => setNow(Date.now() / 1000), 1000)];
487 onCleanup(() => timers.forEach(clearInterval));
488
489 const feed = lastGood(data);
490 const pendingByKey = createMemo(() => new Map(feed()?.pending.map((v) => [v.key, v])));
491 const jobById = createMemo(() => new Map(feed()?.jobs.map((j) => [j.id, j])));
492 const failed = () => feed()?.jobs.filter((j) => j.status === "error").length ?? 0;
493 const lists = lastGood(channels);
494 const channelUrl = createMemo(() => new Map([...lists()?.notify ?? [], ...lists()?.archive ?? []].map((c) => [c.name, c.url])));
495
496 const loaded = createMemo(() => feed() !== undefined && tab() === "");
497 createEffect(() => loaded() && rows()[0]?.focus({ preventScroll: true }));
498
499 const onKey = (event: KeyboardEvent) => {
500 const target = event.target as HTMLElement;
501 if (event.metaKey || event.ctrlKey || event.altKey || target.closest("input, select, textarea, dialog")) return;
502 const row = target.closest<HTMLFormElement>(".yt-item");
503 const step = { j: 1, k: -1, ...(row ? { ArrowDown: 1, ArrowUp: -1 } : {}) }[event.key];
504 if (!step) return;
505 event.preventDefault();
506 const all = rows();
507 all[Math.max(0, Math.min(all.length - 1, (row ? all.indexOf(row) : -1) + step))]?.focus();
508 };
509 const onPaste = async (event: ClipboardEvent) => {
510 if ((event.target as HTMLElement).closest("input, select, textarea, dialog, [contenteditable]")) return;
511 const urls = event.clipboardData?.getData("text").split(/[\s,]+/).filter((word) => /^https?:\/\//.test(word)) ?? [];
512 if (!urls.length) return;
513 event.preventDefault();
514 try {
515 await parseResponse(api.youtube.pending.$post({ json: { urls } }));
516 setParams({ tab: undefined });
517 await refetch();
518 rows().at(-urls.length)?.focus();
519 } catch (failure) {
520 toast(reason(failure));
521 }
522 };
523 document.addEventListener("keydown", onKey);
524 document.addEventListener("paste", onPaste);
525 onCleanup(() => {
526 document.removeEventListener("keydown", onKey);
527 document.removeEventListener("paste", onPaste);
528 });
529
530 const [toggling, setToggling] = createSignal(false);
531 const toggle = async (enabled: boolean) => {
532 setToggling(true);
533 try {
534 await parseResponse(api.youtube.upscaler.$put({ json: { enabled } }));
535 await refetch();
536 } catch (failure) {
537 toast(`Couldn't ${enabled ? "resume" : "pause"} the upscaler. ${reason(failure)}`);
538 } finally {
539 setToggling(false);
540 }
541 };
542
543 const stats = (d: NonNullable<ReturnType<typeof feed>>) => (
544 <>
545 <Show when={(d.archive.started ?? 0) > (d.archive.finished ?? 0)} fallback={
546 <A class="stat" href="/services/ytdl-sub" data-tip={d.archive.finished ? d.archive.walled
547 ? `YouTube blocked the last pass ${ago(d.archive.finished)}`
548 : `last pass ${ago(d.archive.finished)}, took ${duration(d.archive.finished - d.archive.started!)}` : undefined}>
549 <StatusLabel health={!d.archive.finished ? "stopped" : d.archive.walled ? "degraded" : "healthy"}>auto-download</StatusLabel>
550 <b>{d.archive.finished ? until(d.archive.next, now()) : "no pass yet"}</b>
551 </A>
552 }>
553 <A class="stat" href="/services/ytdl-sub" data-tip={`checking channels since ${datetime(d.archive.started!)}`}>
554 <StatusLabel health="working">auto-download</StatusLabel>
555 </A>
556 </Show>
557 <span class="stat" data-tip={d.upscaler.current ? `upscaling ${d.upscaler.current}` : undefined}>
558 <StatusLabel health={!d.upscaler.enabled ? "stopped" : d.upscaler.running ? "working" : "healthy"}>thumbnails</StatusLabel>
559 <b>{count(d.upscaler.done)}/{count(d.upscaler.total)}</b>
560 <Show when={d.upscaler.errors}>{(errors) => <span class="error">{count(errors())} failed</span>}</Show>
561 <button class="button icon-only" disabled={toggling()} aria-busy={toggling()}
562 aria-label={d.upscaler.enabled ? "Pause the upscaler" : "Resume the upscaler"}
563 data-tip={d.upscaler.enabled ? "pause" : "resume"} onClick={() => toggle(!d.upscaler.enabled)}>
564 <Show when={!toggling()}>{d.upscaler.enabled ? <Pause size={12} /> : <Play size={12} />}</Show>
565 </button>
566 </span>
567 </>
568 );
569
570 return (
571 <ListPage id="youtube" class="youtube" flush head={
572 <div class="page-head">
573 <h1>YouTube</h1>
574 <div class="stats"><Show when={feed()}>{(d) => stats(d())}</Show></div>
575 </div>
576 }>
577 <Show when={feed()?.wall.walled && tab() !== "channels"}>
578 <div class="yt-banner" role="status">
579 <strong>YouTube is blocking downloads.</strong> Ingested videos wait in line.
580 Next check {until(feed()!.wall.nextProbe ?? now(), now())}.
581 </div>
582 </Show>
583 <TabBar label="View">
584 <For each={TABS}>
585 {([id, label, Icon]) => (
586 <a href={id ? `/youtube?tab=${id}` : "/youtube"} aria-current={tab() === id ? "page" : undefined}
587 classList={{ alarm: id === "history" && failed() > 0 }}>
588 <Icon size={13} />{label}
589 <Show when={id === "" ? feed()?.pending.length : id === "history" ? failed() : 0}>
590 {(n) => <span class="count">{count(n())}</span>}
591 </Show>
592 </a>
593 )}
594 </For>
595 </TabBar>
596 <Switch>
597 <Match when={tab() === ""}>
598 <Loaded data={data} what="the YouTube queue" retry={refetch} skeleton={queueSkeleton()}>
599 {(d) => (
600 <div class="edge yt-queue">
601 <For each={[...pendingByKey().keys()]} fallback={
602 <div class="empty yt-caught-up">
603 <strong>All caught up!</strong>
604 <span>New uploads from review channels land here. Paste a video link anywhere to queue it.</span>
605 </div>
606 }>
607 {(key) => (
608 <Show when={pendingByKey().get(key)}>
609 {(video) => <Row video={video()} channel={channelUrl().get(video().channel)} shows={d().shows} onChange={refetch} />}
610 </Show>
611 )}
612 </For>
613 </div>
614 )}
615 </Loaded>
616 </Match>
617 <Match when={tab() === "history"}>
618 <Loaded data={data} what="download history" retry={refetch} skeleton={<div class="edge"><SkeletonRows count={8} /></div>}>
619 {() => (
620 <Show when={jobById().size} fallback={<div class="empty">No history yet. Ingested videos show up here.</div>}>
621 <table class="data edge yt-jobs">
622 <tbody>
623 <For each={[...jobById().keys()]}>
624 {(id) => (
625 <Show when={jobById().get(id)}>
626 {(job) => {
627 const [busy, setBusy] = createSignal(false);
628 const retry = async () => {
629 setBusy(true);
630 try {
631 await parseResponse(api.youtube.jobs[":id"].retry.$post({ param: { id } }));
632 await refetch();
633 } catch (failure) {
634 toast(`Couldn't retry ${job().title}. ${reason(failure)}`);
635 } finally {
636 setBusy(false);
637 }
638 };
639 return (
640 <tr>
641 <td class="yt-job-state"
642 data-tip={job().status === "waiting" ? "starts once YouTube stops blocking downloads" : undefined}>
643 <StatusLabel health={JOB[job().status][0]}>{JOB[job().status][1]}</StatusLabel>
644 <Show when={job().progress !== null}>
645 <Meter value={job().progress!} />
646 <span class="yt-meta num">{Math.floor(job().progress! * 100)}%</span>
647 </Show>
648 </td>
649 <td>
650 <div class="yt-job-title">
651 <Linked name={job().title} url={job().url} tip="watch on YouTube" />
652 </div>
653 <div class="yt-meta">
654 <Linked name={job().channel} url={channelUrl().get(job().channel)} /> →{" "}
655 <Linked name={job().destination} url={job().status === "done" ? job().folder ?? undefined : undefined}
656 tip="open in copyparty" />
657 </div>
658 </td>
659 <td class="num yt-meta"><Ago t={job().queuedAt} /></td>
660 <td class="actions">
661 <Show when={job().status === "error"}>
662 <div><button disabled={busy()} aria-busy={busy()} onClick={retry}>retry</button></div>
663 </Show>
664 </td>
665 </tr>
666 );
667 }}
668 </Show>
669 )}
670 </For>
671 </tbody>
672 </table>
673 </Show>
674 )}
675 </Loaded>
676 </Match>
677 <Match when={tab() === "channels"}>
678 <Loaded data={channels} what="the channel lists" retry={refetchChannels}>
679 {(c) => <ChannelLists channels={c()} pending={feed()?.pending ?? []} onSaved={refetchChannels} />}
680 </Loaded>
681 </Match>
682 <Match when={tab() === "library"}>
683 <Loaded data={library} what="the YouTube library" retry={refetchLibrary} skeleton={<div class="edge"><SkeletonRows count={8} /></div>}>
684 {(entries) => <div class="edge">
685 <input class="search yt-library-search" aria-label="Search videos" placeholder="Search videos" value={librarySearch()}
686 onInput={(event) => setLibrarySearch(event.currentTarget.value)} />
687 <Show when={entries().length} fallback={<div class="empty">No downloaded videos yet.</div>}>
688 <table class="data yt-library"><tbody>
689 <For each={entries().filter((entry) => `${entry.context} ${entry.title}`.toLowerCase().includes(librarySearch().toLowerCase()))}>
690 {(entry) => <LibraryRow entry={entry} onSaved={() => void refetchLibrary()} />}
691 </For>
692 </tbody></table>
693 </Show>
694 </div>}
695 </Loaded>
696 </Match>
697 <Match when={tab() === "config"}>
698 <Loaded data={configs} what="the YouTube config files" retry={refetchConfigs}>
699 {(files) => <RawConfigs files={files()} reload={refetchConfigs} />}
700 </Loaded>
701 </Match>
702 </Switch>
703 </ListPage>
704 );
705}