| 1 | import { A, useSearchParams } from "@solidjs/router"; |
| 2 | import Clock from "lucide-solid/icons/clock"; |
| 3 | import ListVideo from "lucide-solid/icons/list-video"; |
| 4 | import Tv from "lucide-solid/icons/tv"; |
| 5 | import Library from "lucide-solid/icons/library"; |
| 6 | import Code from "lucide-solid/icons/code"; |
| 7 | import Pause from "lucide-solid/icons/pause"; |
| 8 | import Play from "lucide-solid/icons/play"; |
| 9 | import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"; |
| 10 | import { parseResponse } from "hono/client"; |
| 11 | import type { Channels, ConfigFile, Ingest, JobStatus, LibraryEntry, Rules, Show as IndieShow, Video } from "../types/youtube.ts"; |
| 12 | import { api, reason, unconnected } from "../api.ts"; |
| 13 | import { Ago } from "../components/Ago.tsx"; |
| 14 | import { showConfirmDialog } from "../components/Dialog.tsx"; |
| 15 | import { ListPage } from "../components/ListPage.tsx"; |
| 16 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; |
| 17 | import { Meter } from "../components/Meter.tsx"; |
| 18 | import { type StatusKind, StatusLabel } from "../components/Status.tsx"; |
| 19 | import { TabBar } from "../components/TabBar.tsx"; |
| 20 | import { toast } from "../components/Toast.tsx"; |
| 21 | import { ago, count, date, datetime, duration, plural, until } from "../format.ts"; |
| 22 | import "./YouTube.css"; |
| 23 | |
| 24 | const 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 | |
| 34 | export const TABS = [["", "queue", ListVideo], ["history", "history", Clock], ["channels", "channels", Tv], ["library", "library", Library], ["config", "YAML", Code]] as const; |
| 35 | |
| 36 | function 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 | |
| 72 | function 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 | |
| 106 | const 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 | |
| 111 | const EVERYTHING = "19700101"; |
| 112 | |
| 113 | function 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 | |
| 120 | const 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. */ |
| 122 | const local = (day: string) => Date.parse(`${day}T00:00`) / 1000; |
| 123 | |
| 124 | function 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 | |
| 131 | function 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 | |
| 136 | const 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 | |
| 142 | const pad = (n: number) => String(n).padStart(2, "0"); |
| 143 | const rows = () => [...document.querySelectorAll<HTMLFormElement>(".yt-item:not(.gone)")]; |
| 144 | |
| 145 | function 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 | |
| 299 | const 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 | |
| 316 | const 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. */ |
| 319 | function 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 | |
| 404 | function 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 | |
| 471 | export 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 | } |