openvisengine/src/lib/music-services.ts
2026-10-29 07:19:50 +00:00

354 lines
15 KiB
TypeScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string>(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<string, unknown>
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('; '),
}
}