354 lines
15 KiB
TypeScript
Executable file
354 lines
15 KiB
TypeScript
Executable file
// 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('; '),
|
||
}
|
||
}
|