/** * Phase 6 — identité de chaîne normalisée, côté front. * * MROIT de `server/providers/channel-ref.mjs` : mêmes schemes, mêmes règles de * dérivation. `npm run test:channelref` échoue si les deux divergent — le serveur * étant du `.mjs` et le front du TypeScript, il n'y a pas d'import commun * possible, seulement une parité vérifiée. * * Rappel du problème résolu : avant, l'identifiant d'une chaîne PeerTube * voyageait sous la forme `instance|channel` reconstruite par l'adaptateur front * à partir de l'URL de la vidéo, celui d'Odysee sous forme de claim LBRY brut * (avec ou sans `@` selon le chemin), celui de YouTube en `UC…`… sans que rien * ne dise lequel était lequel. Voir `docs/plan-phases-catalogue-classification.md` §8. */ import { ProviderId, SHORT_TO_LONG } from './provider-ids'; export type ChannelRefScheme = 'yt-uc' | 'dm-user' | 'tw-login' | 'pt-composite' | 'od-claim' | 'ru-slug'; export interface ChannelRef { provider: ProviderId; /** Déclare comment lire `value`. */ scheme: ChannelRefScheme; value: string; } /** Schemes par provider — doit rester identique à `CHANNEL_REF_SCHEMES`. */ export const CHANNEL_REF_SCHEMES: Record = { yt: 'yt-uc', dm: 'dm-user', tw: 'tw-login', pt: 'pt-composite', od: 'od-claim', ru: 'ru-slug', }; const firstString = (...values: unknown[]): string | undefined => { for (const v of values) { const s = String(v ?? '').trim(); if (s) return s; } return undefined; }; /** Un seul `@` en tête — forme canonique d'un claim LBRY. */ export function normalizeOdyseeClaim(value: unknown): string | undefined { const raw = String(value ?? '').trim(); if (!raw) return undefined; return raw.startsWith('@') ? raw : `@${raw}`; } export function odyseeClaimToSlug(value: unknown): string | undefined { const claim = normalizeOdyseeClaim(value); return claim ? claim.slice(1) : undefined; } /** Découpe `instance|channel` (instance inconnue = ancien format « channel seul »). */ export function parsePeerTubeComposite(value: unknown): { instance?: string; channel?: string } { const raw = String(value ?? '').trim(); if (!raw) return {}; const sep = raw.indexOf('|'); if (sep < 0) return { channel: raw }; return { instance: raw.slice(0, sep) || undefined, channel: raw.slice(sep + 1) || undefined }; } /** * Construit `instance|channel` à partir de l'URL vidéo et du channelId. * L'instance est obligatoire : sans elle, `fetchPeerTubeChannel` produirait * `https://`, une URL fausse. Doit rester aligné avec * `buildPeerTubeComposite` de `server/providers/channel-ref.mjs`. */ export function buildPeerTubeComposite(url: unknown, channelId: unknown): string | undefined { const channel = String(channelId ?? '').trim(); if (!channel) return undefined; let host = ''; try { if (url) host = new URL(String(url)).hostname || ''; } catch {} return host ? `${host}|${channel}` : undefined; } /** * Champs legacy d'une Suggestion (`server` ou cache localStorage), dont on peut * encore avoir besoin de reconstruire l'identité de chaîne — entrées en cache * écrites avant la phase 6, qui ne contiennent pas `channelRef`. */ export interface LegacyChannelFields { channelExternalId?: string; channelId?: string; channelHandle?: string; channel?: string; uploaderName?: string; url?: string; } /** * Tâche 6.3 — reconstruit un `channelRef` depuis les champs legacy. * * `providerRef` est le `VideoItem.provider` (nom **long**), parce que c'est * sous cette forme que le front stocke ses entrées de cache. * * Renvoie `undefined` si rien n'est exploitable : jamais de valeur inventée, et * surtout jamais une chaîne vide qui passerait pour un identifiant valide. */ export function channelRefFrom( providerRef: string | undefined | null, fields: LegacyChannelFields | null | undefined, ): ChannelRef | undefined { const short = (Object.keys(SHORT_TO_LONG) as ProviderId[]).find((id) => SHORT_TO_LONG[id] === providerRef) ?? (Object.keys(SHORT_TO_LONG) as ProviderId[]).find((id) => id === String(providerRef ?? '').trim().toLowerCase()); if (!short || !fields || typeof fields !== 'object') return undefined; let value: string | undefined; switch (short) { case 'yt': value = firstString(fields.channelExternalId, fields.channelId); break; case 'dm': value = firstString(fields.channelId, fields.channelExternalId); break; case 'tw': value = firstString(fields.channelExternalId, fields.channelHandle); break; case 'pt': value = buildPeerTubeComposite(fields.url, fields.channelId); break; case 'od': value = normalizeOdyseeClaim(firstString(fields.channel, fields.uploaderName, fields.channelHandle)); break; case 'ru': value = firstString(fields.channelExternalId, fields.channelId); break; } if (!value) return undefined; return { provider: short, scheme: CHANNEL_REF_SCHEMES[short], value }; } /** * Valeur canonique reçue du serveur, si elle est cohérente avec le provider. * * Les adaptateurs l'utilisent en tête de chaîne avant de retomber sur leur * dérivation legacy : une réponse déjà annotée (phase 6) est reprise telle * quelle, une entrée de cache ancienne (sans `channelRef`) est reconstruite * localement. Le `scheme` est vérifié pour ne pas reprendre un ref qui * viendrait d'un autre provider. */ export function serverRefValue(ref: unknown, short: ProviderId): string | undefined { if (!ref || typeof ref !== 'object') return undefined; const r = ref as Partial; if (r?.scheme !== CHANNEL_REF_SCHEMES[short]) return undefined; const value = typeof r.value === 'string' ? r.value.trim() : ''; return value || undefined; } /** URL de la page chaîne à partir d'un `channelRef` — source unique côté front. */ export function channelUrlFromRef(ref: ChannelRef | undefined): string | undefined { if (!ref) return undefined; switch (ref.scheme) { case 'yt-uc': return `https://www.youtube.com/channel/${ref.value}`; case 'dm-user': return `https://www.dailymotion.com/${ref.value}`; case 'tw-login': return `https://www.twitch.tv/${ref.value}`; case 'pt-composite': { const { instance, channel } = parsePeerTubeComposite(ref.value); return instance ? `https://${instance}/video-channels/${channel}` : undefined; } case 'od-claim': return `https://odysee.com/${odyseeClaimToSlug(ref.value) || ''}`; case 'ru-slug': return `https://rumble.com/c/${ref.value}`; default: return undefined; } }