From 60e798450195f684297e061b1e2aaf059c481835 Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Sun, 5 Apr 2026 14:46:59 +0100 Subject: [PATCH] Vibe coding --- .gitignore | 4 +- src/index.ts | 785 +++++++++++++++++++++++++++++++++++++++++++++++++- tsconfig.json | 3 +- 3 files changed, 777 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 81649dc..0084196 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json .idea # Finder (MacOS) folder config -.DS_Store \ No newline at end of file +.DS_Store + +downloads/ \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 32ad41a..8a883e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,20 +1,779 @@ import { - ASCIIFont, - Box, + BoxRenderable, createCliRenderer, - Text, + SelectRenderable, TextAttributes, + TextRenderable, + type SelectOption, } from "@opentui/core"; +import { mkdir } from "node:fs/promises"; -const renderer = await createCliRenderer({ exitOnCtrlC: true }); +type ShowSummary = { + title: string; + slug: string; + url: string; +}; -renderer.root.add( - Box( - { alignItems: "center", justifyContent: "center", flexGrow: 1 }, - Box( - { justifyContent: "center", alignItems: "flex-end" }, - ASCIIFont({ font: "tiny", text: "OpenTUI" }), - Text({ content: "What will you build?", attributes: TextAttributes.DIM }), +type ShowDetails = { + title: string; + synopsis: string; +}; + +type ReleaseDownload = { + res: string; + magnet: string; +}; + +type ReleaseEntry = { + episode: string; + downloads: ReleaseDownload[]; +}; + +type ShowDownloadData = { + sid: string; + episodes: ReleaseEntry[]; + batches: ReleaseEntry[]; + qualities: string[]; +}; + +type CachedShowData = { + title: string; + synopsis: string; + downloadData: ShowDownloadData | null; +}; + +const SUBSPLEASE_SHOWS_URL = "https://subsplease.org/shows"; + +const renderer = await createCliRenderer({ + exitOnCtrlC: true, + autoFocus: true, +}); + +const root = new BoxRenderable(renderer, { + flexDirection: "row", + flexGrow: 1, + width: "100%", + height: "100%", + padding: 1, + gap: 1, +}); + +const leftPane = new BoxRenderable(renderer, { + width: "42%", + border: true, + title: "SubsPlease Shows", + padding: 1, + flexDirection: "column", + gap: 1, +}); + +const rightPane = new BoxRenderable(renderer, { + flexGrow: 1, + border: true, + title: "Show Details", + padding: 1, +}); + +const showsSelect = new SelectRenderable(renderer, { + width: "100%", + flexGrow: 1, + showScrollIndicator: true, + wrapSelection: true, + showDescription: true, + options: [{ name: "Loading shows...", description: "Please wait" }], + onKeyDown: (key) => { + if (key.name === "q" || key.name === "escape") { + renderer.destroy(); + return; + } + + if (key.name === "tab") { + cycleQuality(key.shift ? -1 : 1); + key.preventDefault(); + return; + } + + if (key.ctrl && key.name === "d") { + key.preventDefault(); + void downloadFirstThreeEpisodes(); + return; + } + + if (key.ctrl && key.name === "b") { + key.preventDefault(); + void downloadEntireSeries(); + return; + } + + if (key.ctrl && key.name === "u") { + searchQuery = ""; + applyShowFilter(); + key.preventDefault(); + return; + } + + if (key.name === "backspace") { + searchQuery = searchQuery.slice(0, -1); + applyShowFilter(); + key.preventDefault(); + return; + } + + if (!key.ctrl && !key.meta) { + const nextChar = + key.name === "space" + ? " " + : key.name && key.name.length === 1 + ? key.name + : ""; + + if (nextChar) { + searchQuery += nextChar; + applyShowFilter(); + key.preventDefault(); + return; + } + } + + // Select handles navigation keys internally; defer to read the updated item. + queueMicrotask(() => { + const selected = showsSelect.getSelectedOption() as + | (SelectOption & { value?: ShowSummary }) + | null; + const show = selected?.value; + + if (show) { + void loadAndShowDetails(show); + } + }); + }, +}); + +const searchText = new TextRenderable(renderer, { + content: "Search: ", + attributes: TextAttributes.DIM, +}); + +const detailsTitle = new TextRenderable(renderer, { + content: "Loading shows...", + attributes: TextAttributes.BOLD, +}); + +const detailsBody = new TextRenderable(renderer, { + content: "Fetching show list from SubsPlease.", + marginTop: 1, +}); + +const qualityText = new TextRenderable(renderer, { + content: "Quality: (loading)", + marginTop: 1, + attributes: TextAttributes.BOLD, +}); + +const downloadActionsText = new TextRenderable(renderer, { + content: "Actions: Ctrl+D first 3 episodes, Ctrl+B entire series", + marginTop: 1, + attributes: TextAttributes.DIM, +}); + +const downloadStatusText = new TextRenderable(renderer, { + content: "Download status: Waiting for show data.", + marginTop: 1, + attributes: TextAttributes.DIM, +}); + +const helpText = new TextRenderable(renderer, { + content: + "Keys: Type filter, Backspace delete, Ctrl+U clear, Tab quality+, Shift+Tab quality-, Ctrl+D first 3, Ctrl+B series, Q/Esc quit", + marginTop: 1, + attributes: TextAttributes.DIM, +}); + +leftPane.add(searchText); +leftPane.add(showsSelect); +rightPane.add(detailsTitle); +rightPane.add(detailsBody); +rightPane.add(qualityText); +rightPane.add(downloadActionsText); +rightPane.add(downloadStatusText); +rightPane.add(helpText); + +root.add(leftPane); +root.add(rightPane); +renderer.root.add(root); + +const detailsCache = new Map(); +let allShows: ShowSummary[] = []; +let filteredShows: ShowSummary[] = []; +let searchQuery = ""; +let activeShow: ShowSummary | null = null; +let activeShowDownloadData: ShowDownloadData | null = null; +let selectedQuality = ""; +let currentDetailsRequest = 0; + +function sortQualities(qualities: string[]): string[] { + return [...qualities].sort((a, b) => Number(b) - Number(a)); +} + +function getDownloadByQuality( + entry: ReleaseEntry, + quality: string, +): ReleaseDownload | null { + return entry.downloads.find((download) => download.res === quality) ?? null; +} + +function parseEpisodeStart(episodeText: string): number { + const match = episodeText.match(/\d+/); + if (!match) { + return Number.POSITIVE_INFINITY; + } + + return Number(match[0]); +} + +function getEpisodesSortedAscending(episodes: ReleaseEntry[]): ReleaseEntry[] { + return [...episodes].sort( + (a, b) => parseEpisodeStart(b.episode) - parseEpisodeStart(a.episode), + ); +} + +function getBestBatchEntry(batches: ReleaseEntry[]): ReleaseEntry | null { + if (batches.length === 0) { + return null; + } + + const scored = [...batches].sort((a, b) => { + const aBounds = a.episode.match(/(\d+)\D+(\d+)/); + const bBounds = b.episode.match(/(\d+)\D+(\d+)/); + + const aSpan = aBounds ? Number(aBounds[2]) - Number(aBounds[1]) : -1; + const bSpan = bBounds ? Number(bBounds[2]) - Number(bBounds[1]) : -1; + return bSpan - aSpan; + }); + + return scored[0] ?? null; +} + +function sanitizeFileSegment(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/-+/g, "-"); +} + +function setDownloadStatus(message: string): void { + downloadStatusText.content = `Download status: ${message}`; +} + +function updateQualityDisplay(): void { + const qualities = activeShowDownloadData?.qualities ?? []; + const hasData = qualities.length > 0; + + if (!hasData) { + qualityText.content = "Quality: unavailable"; + return; + } + + const currentIndex = Math.max(0, qualities.indexOf(selectedQuality)); + qualityText.content = `Quality: ${selectedQuality}p (${currentIndex + 1}/${qualities.length})`; +} + +function cycleQuality(direction: 1 | -1): void { + const qualities = activeShowDownloadData?.qualities ?? []; + if (qualities.length === 0) { + setDownloadStatus("No qualities available for this show."); + return; + } + + const currentIndex = qualities.indexOf(selectedQuality); + const startIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = + (startIndex + direction + qualities.length) % qualities.length; + selectedQuality = qualities[nextIndex] ?? qualities[0] ?? ""; + updateQualityDisplay(); + setDownloadStatus(`Selected ${selectedQuality}p.`); +} + +async function writeMagnetFile( + filePath: string, + magnet: string, +): Promise { + await Bun.write(filePath, `${magnet}\n`); +} + +async function downloadFirstThreeEpisodes(): Promise { + const show = activeShow; + const data = activeShowDownloadData; + const quality = selectedQuality; + + if (!show || !data) { + setDownloadStatus("Select a show first."); + return; + } + + if (!quality) { + setDownloadStatus("No quality selected."); + return; + } + + const episodes = getEpisodesSortedAscending(data.episodes).slice(0, 3); + if (episodes.length === 0) { + setDownloadStatus("No episodes available."); + return; + } + + const baseDir = `downloads/${sanitizeFileSegment(show.slug)}`; + await mkdir(baseDir, { recursive: true }); + + let writtenCount = 0; + for (const entry of episodes) { + const download = getDownloadByQuality(entry, quality); + if (!download) { + continue; + } + + const episodeNumber = parseEpisodeStart(entry.episode); + const episodeSuffix = Number.isFinite(episodeNumber) + ? `e${String(episodeNumber).padStart(2, "0")}` + : sanitizeFileSegment(entry.episode.toLowerCase()); + const filePath = `${baseDir}/${sanitizeFileSegment(show.slug)}-${episodeSuffix}-${quality}p.magnet`; + await writeMagnetFile(filePath, download.magnet); + writtenCount += 1; + } + + if (writtenCount === 0) { + setDownloadStatus(`No first-3 episode magnets found for ${quality}p.`); + return; + } + + setDownloadStatus(`Saved ${writtenCount} magnet file(s) to ${baseDir}.`); +} + +async function downloadEntireSeries(): Promise { + const show = activeShow; + const data = activeShowDownloadData; + const quality = selectedQuality; + + if (!show || !data) { + setDownloadStatus("Select a show first."); + return; + } + + if (!quality) { + setDownloadStatus("No quality selected."); + return; + } + + const baseDir = `downloads/${sanitizeFileSegment(show.slug)}`; + await mkdir(baseDir, { recursive: true }); + + const bestBatch = getBestBatchEntry(data.batches); + if (bestBatch) { + const batchDownload = getDownloadByQuality(bestBatch, quality); + if (batchDownload) { + const filePath = `${baseDir}/${sanitizeFileSegment(show.slug)}-batch-${quality}p.magnet`; + await writeMagnetFile(filePath, batchDownload.magnet); + setDownloadStatus(`Saved batch magnet to ${filePath}.`); + return; + } + } + + let savedEpisodeCount = 0; + const episodes = getEpisodesSortedAscending(data.episodes); + for (const entry of episodes) { + const download = getDownloadByQuality(entry, quality); + if (!download) { + continue; + } + + const episodeNumber = parseEpisodeStart(entry.episode); + const episodeSuffix = Number.isFinite(episodeNumber) + ? `e${String(episodeNumber).padStart(2, "0")}` + : sanitizeFileSegment(entry.episode.toLowerCase()); + const filePath = `${baseDir}/${sanitizeFileSegment(show.slug)}-${episodeSuffix}-${quality}p.magnet`; + await writeMagnetFile(filePath, download.magnet); + savedEpisodeCount += 1; + } + + if (savedEpisodeCount === 0) { + setDownloadStatus(`No series magnets found for ${quality}p.`); + return; + } + + setDownloadStatus( + `Saved ${savedEpisodeCount} episode magnet links to ${baseDir}.`, + ); +} + +function updateSearchText(): void { + const suffix = searchQuery.length > 0 ? searchQuery : "(all)"; + searchText.content = `Search: ${suffix}`; +} + +function showToOption( + show: ShowSummary, +): SelectOption & { value: ShowSummary } { + return { + name: show.title, + description: show.slug, + value: show, + }; +} + +function applyShowFilter(): void { + const normalized = searchQuery.trim().toLocaleLowerCase(); + const previousSelected = showsSelect.getSelectedOption() as + | (SelectOption & { value?: ShowSummary }) + | null; + const previousSlug = previousSelected?.value?.slug; + + filteredShows = + normalized.length === 0 + ? allShows + : allShows.filter((show) => { + const haystack = `${show.title} ${show.slug}`.toLocaleLowerCase(); + return haystack.includes(normalized); + }); + + updateSearchText(); + leftPane.title = `SubsPlease Shows (${filteredShows.length})`; + + if (filteredShows.length === 0) { + showsSelect.options = [ + { + name: "No matches", + description: searchQuery.length > 0 ? `for \"${searchQuery}\"` : "", + }, + ]; + updateDetailsPane( + "No matching show", + searchQuery.length > 0 + ? `No shows match \"${searchQuery}\". Try a different search.` + : "No shows available.", + ); + return; + } + + showsSelect.options = filteredShows.map(showToOption); + + const nextIndex = previousSlug + ? Math.max( + 0, + filteredShows.findIndex((show) => show.slug === previousSlug), + ) + : 0; + + showsSelect.setSelectedIndex(nextIndex); + + const selected = filteredShows[nextIndex] ?? filteredShows[0]; + if (selected) { + void loadAndShowDetails(selected); + } +} + +function decodeHtmlEntities(value: string): string { + const named: Record = { + amp: "&", + quot: '"', + apos: "'", + lt: "<", + gt: ">", + nbsp: " ", + mdash: "-", + ndash: "-", + hellip: "...", + }; + + return value + .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) + .replace(/&#x([0-9a-fA-F]+);/g, (_, code) => + String.fromCodePoint(parseInt(code, 16)), + ) + .replace(/&([a-zA-Z]+);/g, (full, name) => named[name] ?? full); +} + +function sanitizeText(value: string): string { + return decodeHtmlEntities(value) + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n\n") + .replace(/<[^>]+>/g, " ") + .replace(/[\t ]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/[\t ]{2,}/g, " ") + .trim(); +} + +function parseShowsPage(html: string): ShowSummary[] { + const showRegex = + /