// OVE — music service metadata (v6.6 / v6.7 / v6.8.1), shared by client + server. // // The Settings tab lets a visitor log in to their own music services by // pasting that service's login cookie(s). The blob is encrypted in the // browser (src/lib/music-sessions.ts) and is only ever sent to this site's // own server transiently — to resolve links the visitor plays — never // stored there. // // v6.7 adds `streamStatus` + `comingSoonNote`: the music-service framework // marks which services can be STREAMED today (yt-dlp) and which are // registered ahead of a later update (Spotify / Apple Music — DRM-locked). // The server-side provider registry lives in src/lib/server/music-providers.ts. // // v6.8.1 adds the cookie-paste toolkit (bottom of this file): one shared // parser for the three formats the login box accepts (raw Cookie header, // Netscape cookies.txt, Cookie-Editor JSON), live validation for the dialog // (catches the "pasted a single cookie's value" mistake — e.g. LOGIN_INFO // alone cannot authenticate) and the netscape/header staging the resolve // route uses. One parser for both sides means validation and staging can // never drift apart. // // This module is pure data + pure functions (no DOM, no node APIs) so both // the client components and the /api/resolve route can import it. export type ServiceId = 'youtube' | 'soundcloud' | 'spotify' | 'apple' /** * Whether links of a service can be streamed today. 'live' = resolvable * through yt-dlp and playable; 'coming-soon' = the framework is registered * (logins are already saved) and playback ships in a later update. */ export type StreamStatus = 'live' | 'coming-soon' export interface ServiceDef { id: ServiceId /** Display name shown in the login dialog + settings chips. */ name: string /** Two-letter monogram for the chip (brand colors below). */ monogram: string /** Static brand color for the monogram dot (theme-independent). */ brandColor: string /** Hostnames (and their subdomains) whose links this login unlocks. */ domains: string[] /** Domain the cookie header should be scoped to (Netscape format). */ cookieDomain: string /** Short blurb shown in the login dialog. */ blurb: string /** Cookie acquisition instructions (numbered steps, rendered as a list). */ steps: string[] /** Cookie names a WORKING login paste must carry (paste validation hints — * a paste missing these gets an honest warning in the login dialog). * Undefined when there is no reliable signal for the service. */ criticalCookies?: string[] /** Honest limitation note, if any (DRM…). */ note?: string /** Can links of this service be streamed today? 'live' = resolvable+playable * through yt-dlp; 'coming-soon' = framework registered, playback ships in a * later update. */ streamStatus: StreamStatus /** Required when streamStatus === 'coming-soon': the note shown when a * visitor tries to play a link of this service. */ comingSoonNote?: string } export const SERVICES: ServiceDef[] = [ { id: 'youtube', name: 'YouTube / YouTube Music', monogram: 'YT', brandColor: '#ff0033', domains: ['youtube.com', 'youtu.be', 'music.youtube.com'], cookieDomain: '.youtube.com', blurb: 'One Google login covers both YouTube and YouTube Music — playlists, liked music and uploads, and no more "confirm you are not a bot" walls.', steps: cookieSteps( 'youtube.com (or music.youtube.com)', 'Paste the whole thing below — a working YouTube login is the FULL set of ~20 cookies (SID, SAPISID, __Secure-3PSID, LOGIN_INFO, …), never a single cookie on its own.', ), criticalCookies: ['SID', 'HSID', 'SSID', 'SAPISID', '__Secure-3PSID', 'LOGIN_INFO'], streamStatus: 'live', }, { id: 'soundcloud', name: 'SoundCloud', monogram: 'SC', brandColor: '#ff5500', domains: ['soundcloud.com', 'snd.sc'], cookieDomain: '.soundcloud.com', blurb: 'Access your private playlists, likes and sets from the Link button on this machine.', steps: cookieSteps( 'soundcloud.com', 'Paste the whole thing below — the cookie that carries your login is oauth_token (plus a few others).', ), criticalCookies: ['oauth_token'], streamStatus: 'live', }, { id: 'spotify', name: 'Spotify', monogram: 'SP', brandColor: '#1db954', domains: ['spotify.com', 'open.spotify.com'], cookieDomain: '.spotify.com', blurb: 'Store your Spotify login so playlist links resolve with your account.', note: 'Spotify streams are DRM-protected, so playback of Spotify links is not possible yet — your login is saved for when support arrives.', steps: cookieSteps( 'open.spotify.com', 'Paste the whole thing below — the cookie that carries your login is sp_dc.', ), criticalCookies: ['sp_dc'], streamStatus: 'coming-soon', comingSoonNote: 'Spotify playback is coming in a later update — your saved Spotify login will be used automatically the moment it ships. Meanwhile, YouTube, SoundCloud and direct-file links work today.', }, { id: 'apple', name: 'Apple Music', monogram: 'AM', brandColor: '#fb5c74', domains: ['music.apple.com', 'apple.com'], cookieDomain: '.apple.com', blurb: 'Store your Apple Music login so library links resolve with your account.', note: 'Apple Music streams are DRM-protected, so playback of Apple Music links is not possible yet — your login is saved for when support arrives.', steps: cookieSteps('music.apple.com', 'Paste the whole thing below.'), streamStatus: 'coming-soon', comingSoonNote: 'Apple Music playback is coming in a later update — your saved Apple Music login will be used automatically the moment it ships. Meanwhile, YouTube, SoundCloud and direct-file links work today.', }, ] /** * Shared "how to copy your login" steps — two acquisition paths in order of * ease: (1) a one-click cookies.txt extension export, (2) DevTools → Network * → the "cookie:" request-header row. The last step is per-service and says * what a WORKING paste looks like (the full set, never a single cookie). */ function cookieSteps(host: string, lastStep: string): string[] { return [ `Open ${host} in this browser and make sure you are logged in.`, 'Easiest (one click): install a cookie exporter — “Get cookies.txt LOCALLY” on Chrome/Edge/Brave or “cookies.txt” on Firefox. Open that page, click the extension, choose Export — then drag the downloaded file straight into the box below (no need to open or copy anything).', 'No extension? Press F12 → Network tab → reload the page → click the first request in the list (the page itself). The list stays empty until you reload with the tab open.', 'Scroll down to the “Request Headers” section → find the “cookie:” row → right-click its value → Copy value. (In Firefox this lives on the request’s Headers tab too.)', lastStep, ] } const SERVICE_IDS = new Set(SERVICES.map((s) => s.id)) export function isServiceId(v: unknown): v is ServiceId { return typeof v === 'string' && SERVICE_IDS.has(v) } export function serviceDef(id: ServiceId): ServiceDef { const def = SERVICES.find((s) => s.id === id) if (!def) throw new Error(`Unknown service: ${id}`) return def } /** * Match a URL against the service domains (host equals or is a subdomain). * Returns the service a link belongs to, or null for foreign links. */ export function serviceForUrl(url: string): ServiceId | null { let host: string try { host = new URL(url).hostname.toLowerCase() } catch { return null } if (host.startsWith('www.')) host = host.slice(4) for (const s of SERVICES) { for (const d of s.domains) { const dd = d.startsWith('www.') ? d.slice(4) : d if (host === dd || host.endsWith(`.${dd}`)) return s.id } } return null } // ---- cookie paste toolkit (v6.8.1) ------------------------------------------ // // The login box accepts three paste formats, and BOTH the login dialog's // live validation and the /api/resolve staging funnel through this one // parser so they can never disagree about what a paste contains: // • raw Cookie header "NAME=VALUE; NAME2=VALUE2; …" (DevTools → Network) // • Netscape cookies.txt export (tab-separated 7-field lines, from // exporter extensions like "Get cookies.txt LOCALLY" / "cookies.txt") // • Cookie-Editor JSON export [{"name":"SID","value":"…","domain":"…"}, …] export interface ParsedCookie { name: string value: string /** Domain as written in the paste (netscape / json exports). Absent on * raw header pastes — those are implicitly scoped to the service. */ domain?: string } export type CookiePasteFormat = 'header' | 'netscape' | 'json' | 'unknown' /** Strip tabs/newlines so a pasted token can never corrupt a TSV line. */ function cookieToken(s: string): string { return s.replace(/[\t\r\n]+/g, ' ').trim() } /** Parse a paste into its cookies, detecting the format. Never throws. */ export function parseCookiePaste( text: string, ): { format: CookiePasteFormat; cookies: ParsedCookie[] } { const trimmed = text.trim() if (trimmed.length === 0) return { format: 'unknown', cookies: [] } // Cookie-Editor style JSON export. if (trimmed.startsWith('[')) { try { const arr: unknown = JSON.parse(trimmed) if (Array.isArray(arr)) { const cookies: ParsedCookie[] = [] for (const e of arr) { if (e === null || typeof e !== 'object') continue const o = e as Record if (typeof o.name !== 'string' || typeof o.value !== 'string') continue const name = cookieToken(o.name) const value = cookieToken(o.value) if (!name || !value) continue const domain = typeof o.domain === 'string' ? cookieToken(o.domain) : '' cookies.push(domain ? { name, value, domain } : { name, value }) } if (cookies.length > 0) return { format: 'json', cookies } } } catch { /* not JSON — fall through to the other formats */ } } // Netscape cookies.txt — tab-separated 7-field lines. const tsvLines = trimmed .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l.length > 0 && !l.startsWith('#') && l.split('\t').length >= 7) if (tsvLines.length > 0) { const cookies: ParsedCookie[] = [] for (const line of tsvLines) { const f = line.split('\t') const [d, , , , , name, value] = f const n = cookieToken(name) const v = cookieToken(value) if (!n || !v) continue cookies.push({ name: n, value: v, domain: cookieToken(d) || undefined }) } if (cookies.length > 0) return { format: 'netscape', cookies } } // Raw Cookie header: "k=v; k2=v2" (possibly wrapped over several lines). // Values keep inner '=' (base64 padding); the FIRST '=' splits the pair. const cookies: ParsedCookie[] = [] for (const raw of trimmed.split(/[;\n]/)) { const s = raw.trim() if (!s) continue const eq = s.indexOf('=') if (eq <= 0) continue const name = cookieToken(s.slice(0, eq)) const value = cookieToken(s.slice(eq + 1)) if (name && value) cookies.push({ name, value }) } if (cookies.length > 0) return { format: 'header', cookies } return { format: 'unknown', cookies: [] } } export interface CookiePasteValidation { format: CookiePasteFormat /** cookies recognized in the paste */ count: number /** critical login cookies (ServiceDef.criticalCookies) missing from it */ missingCritical: string[] /** the paste parses AND carries the service's login cookies */ looksGood: boolean } /** * Live-check a paste for the login dialog. A paste that is a single * cookie's VALUE (no NAME= pairs at all — e.g. a bare LOGIN_INFO string * copied from the DevTools Storage tab) parses as 'unknown'; a paste with * pairs but without the service's auth cookies lists them as missing. * Either way the dialog shows an honest "this will not log you in" hint. */ export function validateCookiePaste(service: ServiceId, text: string): CookiePasteValidation { const { format, cookies } = parseCookiePaste(text) const critical = serviceDef(service).criticalCookies ?? [] const names = new Set(cookies.map((c) => c.name)) const missingCritical = critical.filter((n) => !names.has(n)) const looksGood = format !== 'unknown' && (critical.length > 0 ? missingCritical.length === 0 : cookies.length >= 4) return { format, count: cookies.length, missingCritical, looksGood } } /** * Normalize a paste into a Netscape cookies.txt body (for the transient * 0600 temp file yt-dlp reads via --cookies) + a "name=value; …" header * (forwarded to the stream proxy, service-domain cookies only). Returns * null when nothing parseable was pasted. * * • raw header pastes are scoped to the service's cookie domain; * • JSON entries keep their own domain when it belongs to the service * (foreign-site entries are dropped — an exporter grab from the wrong * site must never leak into the jar); * • netscape exports pass through with all their domains + real expiries. */ export function cookiePasteToNetscape( text: string, service: ServiceId, ): { netscape: string; header: string } | null { const { format, cookies } = parseCookiePaste(text) if (cookies.length === 0) return null const domain = serviceDef(service).cookieDomain const root = domain.replace(/^\./, '').toLowerCase() const inService = (d: string | undefined): boolean => { if (!d) return true // header pastes / JSON entries without a domain const dd = d.replace(/^\./, '').toLowerCase() return dd === root || dd.endsWith(`.${root}`) } if (format === 'netscape') { const body = text .trim() .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l.length > 0 && !l.startsWith('#') && l.split('\t').length >= 7) const header = cookies .filter((c) => inService(c.domain)) .map((c) => `${c.name}=${c.value}`) return { netscape: `# Netscape HTTP Cookie File\n${body.join('\n')}\n`, header: header.join('; '), } } const expiry = '2147483647' const lines: string[] = [] const headerPairs: string[] = [] for (const c of cookies) { if (format === 'json' && !inService(c.domain)) continue const secure = c.name.startsWith('__Secure-') || c.name.startsWith('__Host-') ? 'TRUE' : 'FALSE' lines.push(`${domain}\tTRUE\t/\t${secure}\t${expiry}\t${c.name}\t${c.value}`) headerPairs.push(`${c.name}=${c.value}`) } if (lines.length === 0) return null return { netscape: `# Netscape HTTP Cookie File\n${lines.join('\n')}\n`, header: headerPairs.join('; '), } }