// The feed's sort vocabulary. `FeedFilters.sort` is a single wire string ("newest", "views_asc", // …); the UI thinks in a key + direction. Kept here rather than inside Feed so the active-filter // chips can label a sort without re-deriving the mapping. import type { Video } from "./api"; import type { SortOption } from "./columnSort"; import { compareText } from "./collate"; export type SortKey = | "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle" | "relevance"; export const SORT_KEYS: SortKey[] = [ "date", "popular", "duration", "title", "subscribers", "priority", "shuffle", ]; /** Sorts with no meaningful asc/desc flip. */ const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"]; const SORT_MAP: Record, { asc: string; desc: string }> = { date: { desc: "newest", asc: "oldest" }, popular: { desc: "views", asc: "views_asc" }, duration: { desc: "duration_desc", asc: "duration_asc" }, title: { asc: "title", desc: "title_desc" }, subscribers: { desc: "subscribers", asc: "subscribers_asc" }, priority: { desc: "priority", asc: "priority_asc" }, }; const SORT_DEFAULT_DIR: Record, "asc" | "desc"> = { date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc", }; export function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } { if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" }; for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) { if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" }; if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" }; } return { key: "date", dir: "desc" }; } export function buildSort(key: SortKey, dir: "asc" | "desc"): string { if (key === "shuffle" || key === "relevance") return key; return SORT_MAP[key][dir]; } /** Feed sort keys as options for the shared SortControl. ONE mapping for the library feed and the * live search results, which share the vocabulary: two copies could hand the same key a different * direction default, or lose its directionless flag, depending on which mode you were in. */ export function feedSortOptions(keys: SortKey[], label: (k: SortKey) => string): SortOption[] { return keys.map((k) => ({ key: k, label: label(k), directionless: DIRECTIONLESS.includes(k), defaultDir: DIRECTIONLESS.includes(k) ? undefined : SORT_DEFAULT_DIR[k as Exclude], })); } // --- Live YouTube search results ---------------------------------------------------------------- // // The results view had NO sort control at all — the order was the provider's and that was that. It is // sorted HERE, on the page already loaded, rather than by re-asking YouTube with an `order=` param, // for three reasons: the owner's case is "even one page of results", i.e. reorder what is on screen; // a re-query would REPLACE the results, which is not what "sort these" means; and the zero-quota // scrape source has no order parameter at all, so a server-side control would silently do nothing (or // quietly cost 100 quota units) depending on which source happened to serve the page. // // Only the keys a search result actually carries are offered. `subscribers` and `priority` are // properties of a SUBSCRIPTION, which a live search result is not, and `shuffle` on a ranked result // list is noise — an option that cannot mean anything here is worse than no option. export const YT_SORT_KEYS: SortKey[] = ["relevance", "date", "popular", "duration", "title"]; const YT_SORT_VALUE: Partial number | string | null | undefined>> = { date: (v) => (v.published_at ? Date.parse(v.published_at) : null), popular: (v) => v.view_count, duration: (v) => v.duration_seconds, title: (v) => v.title, }; /** Order a loaded page of live-search results. `relevance` is the provider's own ranking, i.e. the * order they arrived in — so it is the identity, not a sort. * * Results missing the value sink to the END in both directions (a video whose duration YouTube did * not report is not "the shortest"), and ties keep the provider's ranking, in both directions — * hence a signed comparator rather than sorting and reversing, which would flip the ties too. */ export function sortYtResults( items: Video[], key: SortKey, dir: "asc" | "desc", lang: string, ): Video[] { const value = YT_SORT_VALUE[key]; if (!value) return items; // relevance, and anything a stale pref names that we no longer offer const sign = dir === "asc" ? 1 : -1; const have = items.filter((v) => value(v) != null); const missing = items.filter((v) => value(v) == null); have.sort((a, b) => { const x = value(a)!; const y = value(b)!; return sign * (typeof x === "string" ? compareText(x, String(y), lang) : x - Number(y)); }); return [...have, ...missing]; }