Vibe coding
This commit is contained in:
+3
-1
@@ -31,4 +31,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
downloads/
|
||||
+772
-13
@@ -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<string, CachedShowData>();
|
||||
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<void> {
|
||||
await Bun.write(filePath, `${magnet}\n`);
|
||||
}
|
||||
|
||||
async function downloadFirstThreeEpisodes(): Promise<void> {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
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(/<br\s*\/?>/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 =
|
||||
/<div class="all-shows-link">\s*<a href="([^"]+)"[^>]*title="([^"]+)"[^>]*>/g;
|
||||
const dedupe = new Map<string, ShowSummary>();
|
||||
|
||||
for (const match of html.matchAll(showRegex)) {
|
||||
const rawHref = match[1];
|
||||
const rawTitle = match[2];
|
||||
|
||||
if (!rawHref || !rawTitle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const absoluteHref = rawHref.startsWith("http")
|
||||
? rawHref
|
||||
: `https://subsplease.org${rawHref.startsWith("/") ? "" : "/"}${rawHref}`;
|
||||
const url = absoluteHref.replace(/\/$/, "");
|
||||
|
||||
if (!url.includes("/shows/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const slug = url.split("/").filter(Boolean).pop();
|
||||
if (!slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = sanitizeText(rawTitle);
|
||||
if (!title) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${slug}::${title}`;
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.set(key, { title, slug, url });
|
||||
}
|
||||
}
|
||||
|
||||
return [...dedupe.values()].sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
function parseShowDetails(html: string): ShowDetails {
|
||||
const titleMatch = html.match(/<h1 class="entry-title">([\s\S]*?)<\/h1>/i);
|
||||
const title = sanitizeText(titleMatch?.[1] ?? "Unknown show");
|
||||
|
||||
const synopsisBlockMatch = html.match(
|
||||
/<div class="series-syn">([\s\S]*?)<\/div>/i,
|
||||
);
|
||||
const synopsisBlock = synopsisBlockMatch?.[1] ?? "";
|
||||
|
||||
const paragraphMatches = [...synopsisBlock.matchAll(/<p>([\s\S]*?)<\/p>/gi)];
|
||||
const synopsis = paragraphMatches
|
||||
.map((match) => sanitizeText(match[1] ?? ""))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
return {
|
||||
title,
|
||||
synopsis: synopsis || "No synopsis available for this show.",
|
||||
};
|
||||
}
|
||||
|
||||
function parseShowSid(html: string): string | null {
|
||||
const sidMatch = html.match(/id="show-release-table"[^>]*\ssid="(\d+)"/i);
|
||||
return sidMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
function normalizeReleaseEntries(rawSection: unknown): ReleaseEntry[] {
|
||||
if (!rawSection || typeof rawSection !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = Array.isArray(rawSection)
|
||||
? rawSection
|
||||
: Object.values(rawSection as Record<string, unknown>);
|
||||
|
||||
const normalized: ReleaseEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const maybeEpisode = (entry as { episode?: unknown }).episode;
|
||||
const maybeDownloads = (entry as { downloads?: unknown }).downloads;
|
||||
if (typeof maybeEpisode !== "string" || !Array.isArray(maybeDownloads)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const downloads: ReleaseDownload[] = maybeDownloads
|
||||
.map((download) => {
|
||||
if (!download || typeof download !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = (download as { res?: unknown }).res;
|
||||
const magnet = (download as { magnet?: unknown }).magnet;
|
||||
if (typeof res !== "string" || typeof magnet !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { res, magnet };
|
||||
})
|
||||
.filter((item): item is ReleaseDownload => item !== null);
|
||||
|
||||
if (downloads.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.push({ episode: maybeEpisode, downloads });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function fetchShowDownloadData(
|
||||
sid: string,
|
||||
): Promise<ShowDownloadData | null> {
|
||||
const apiUrl = `https://subsplease.org/api/?f=show&tz=UTC&sid=${sid}`;
|
||||
const response = await Bun.fetch(apiUrl, {
|
||||
headers: {
|
||||
"User-Agent": "subs-please-browser/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as {
|
||||
episode?: unknown;
|
||||
batch?: unknown;
|
||||
};
|
||||
|
||||
const episodes = normalizeReleaseEntries(raw.episode);
|
||||
const batches = normalizeReleaseEntries(raw.batch);
|
||||
const qualities = sortQualities([
|
||||
...new Set(
|
||||
[...episodes, ...batches].flatMap((entry) =>
|
||||
entry.downloads.map((download) => download.res),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
]);
|
||||
|
||||
return {
|
||||
sid,
|
||||
episodes,
|
||||
batches,
|
||||
qualities,
|
||||
};
|
||||
}
|
||||
|
||||
function updateDetailsPane(showTitle: string, bodyText: string): void {
|
||||
detailsTitle.content = showTitle;
|
||||
detailsBody.content = bodyText;
|
||||
}
|
||||
|
||||
async function fetchShows(): Promise<ShowSummary[]> {
|
||||
const response = await Bun.fetch(SUBSPLEASE_SHOWS_URL, {
|
||||
headers: {
|
||||
"User-Agent": "subs-please-browser/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch shows list (${response.status})`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
return parseShowsPage(html);
|
||||
}
|
||||
|
||||
async function loadAndShowDetails(show: ShowSummary): Promise<void> {
|
||||
const requestId = ++currentDetailsRequest;
|
||||
activeShow = show;
|
||||
|
||||
const cached = detailsCache.get(show.slug);
|
||||
if (cached) {
|
||||
updateDetailsPane(cached.title, cached.synopsis);
|
||||
activeShowDownloadData = cached.downloadData;
|
||||
if (activeShowDownloadData?.qualities.length) {
|
||||
if (!activeShowDownloadData.qualities.includes(selectedQuality)) {
|
||||
selectedQuality = activeShowDownloadData.qualities[0] ?? "";
|
||||
}
|
||||
updateQualityDisplay();
|
||||
setDownloadStatus(
|
||||
`Ready. Episodes: ${activeShowDownloadData.episodes.length}, batches: ${activeShowDownloadData.batches.length}.`,
|
||||
);
|
||||
} else {
|
||||
updateQualityDisplay();
|
||||
setDownloadStatus("No downloadable magnets available.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateDetailsPane(show.title, "Loading synopsis...");
|
||||
activeShowDownloadData = null;
|
||||
updateQualityDisplay();
|
||||
setDownloadStatus("Loading download data...");
|
||||
|
||||
try {
|
||||
const response = await Bun.fetch(show.url, {
|
||||
headers: {
|
||||
"User-Agent": "subs-please-browser/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch show page (${response.status})`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const details = parseShowDetails(html);
|
||||
const sid = parseShowSid(html);
|
||||
const downloadData = sid ? await fetchShowDownloadData(sid) : null;
|
||||
detailsCache.set(show.slug, {
|
||||
...details,
|
||||
downloadData,
|
||||
});
|
||||
|
||||
// Ignore stale responses if the user moved to another item quickly.
|
||||
if (requestId !== currentDetailsRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDetailsPane(details.title, details.synopsis);
|
||||
activeShowDownloadData = downloadData;
|
||||
|
||||
const qualities = sortQualities(downloadData?.qualities ?? []);
|
||||
if (qualities.length > 0) {
|
||||
selectedQuality = qualities.includes(selectedQuality)
|
||||
? selectedQuality
|
||||
: (qualities[0] ?? "");
|
||||
updateQualityDisplay();
|
||||
setDownloadStatus(
|
||||
`Ready. Episodes: ${downloadData?.episodes.length ?? 0}, batches: ${downloadData?.batches.length ?? 0}.`,
|
||||
);
|
||||
} else {
|
||||
selectedQuality = "";
|
||||
updateQualityDisplay();
|
||||
setDownloadStatus("No downloadable magnets available for this show.");
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== currentDetailsRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
updateDetailsPane(show.title, `Could not load details. ${message}`);
|
||||
activeShowDownloadData = null;
|
||||
selectedQuality = "";
|
||||
updateQualityDisplay();
|
||||
setDownloadStatus(`Unable to load show downloads. ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const shows = await fetchShows();
|
||||
allShows = shows;
|
||||
|
||||
if (shows.length === 0) {
|
||||
showsSelect.options = [
|
||||
{ name: "No shows found", description: "Try again later" },
|
||||
];
|
||||
updateDetailsPane("No data", "SubsPlease returned no show entries.");
|
||||
} else {
|
||||
applyShowFilter();
|
||||
}
|
||||
} catch (error) {
|
||||
showsSelect.options = [
|
||||
{ name: "Failed to load shows", description: "Network error" },
|
||||
];
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
updateDetailsPane("Error", `Could not fetch show list. ${message}`);
|
||||
}
|
||||
|
||||
updateSearchText();
|
||||
showsSelect.focus();
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["bun-types"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
|
||||
Reference in New Issue
Block a user