/** * Phase 6 — identité de chaîne normalisée (`channelRef`). * * AVANT, trois conventions d'identifiant de chaîne cohabitaient, chacune * implicite, codée à 4 endroits différents : * * 1. YouTube : `UC…` (dans `channelExternalId`) * 2. Dailymotion: identifiant numérique d'utilisateur (`owner.id`) * 3. Twitch : `login` * 4. PeerTube : `instance|channel` (construit par le FRONT depuis l'URL vidéo) * 5. Odysee : claim LBRY brut, avec ou sans `@` selon la source * 6. Rumble : slug `/c/` * * Aucun de ces formats ne disait « ceci est un identifiant de chaîne » : la même * chaîne pouvait être stockée sous deux formes (`yt` et `@MaChaine#a` pour Odysee, * `channel` seul et `instance|channel` pour PeerTube) et rien ne les réconciliait. * * `channelRef` rend l'imbrication explicite : le `scheme` DIT comment lire le * `value`. Le but n'est pas de supprimer les champs legacy (transition non * cassante, cf. phase 6.2) mais d'avoir une forme unique, sans perte et * réversible. * * SOURCE UNIQUE côté serveur. Le miroir TypeScript est * `src/app/shared/providers/channel-ref.ts`, et `npm run test:channelref` * vérifie que les deux listes de schemes ne divergent pas. */ /** Schemes par provider (id court). L'ordre suit `ALL_PROVIDER_IDS`. */ export const CHANNEL_REF_SCHEMES = Object.freeze({ yt: 'yt-uc', dm: 'dm-user', tw: 'tw-login', pt: 'pt-composite', od: 'od-claim', ru: 'ru-slug', }); /** * Normalise un claim LBRY (Odysee) : un seul `@` en tête. * * Odysee est le seul provider où la même chaîne arrivait avec ET sans le `@` * selon le chemin (`channel` dans la réponse `claim_search`, `short_url` dans * la résolution, `externalId` en base) — d'où les deux normalisations * divergentes (`startsWith('@') ? … : '@'+…` au POST de resolve, * `replace(/^@/,'')` pour l'URL). La forme canonique porte le `@`. * * @param {unknown} value * @returns {string|undefined} claim canonique, ou undefined si vide */ export function normalizeOdyseeClaim(value) { const raw = String(value ?? '').trim(); if (!raw) return undefined; return raw.startsWith('@') ? raw : `@${raw}`; } /** Un claim LBRY pour une URL publique : `https://odysee.com/@x` -> `x`. */ export function odyseeClaimToSlug(value) { const claim = normalizeOdyseeClaim(value); return claim ? claim.slice(1) : undefined; } /** Un claim LBRY pour l'appel `resolve`, qui exige le `@`. */ export function odyseeClaimToResolveArg(value) { return normalizeOdyseeClaim(value); } /** * Découpe un identifiant PeerTube composite `instance|channel`. * Rétrocompatible avec l'ancien cas « channel seul » (instance inconnue). * @param {unknown} value */ export function parsePeerTubeComposite(value) { const raw = String(value ?? '').trim(); if (!raw) return { instance: undefined, channel: undefined }; const sep = raw.indexOf('|'); if (sep < 0) return { instance: undefined, channel: raw }; const instance = raw.slice(0, sep) || undefined; const channel = raw.slice(sep + 1) || undefined; return { instance, channel }; } /** * Construit `instance|channel` à partir de l'URL vidéo et du channelId. * * L'instance N'EST PAS optionnelle : sans elle, `parsePeerTubeComposite` * renverrait `{instance: undefined}` et `fetchPeerTubeChannel` construirait * `https://` — une URL fausse. On renvoie donc `undefined` plutôt * qu'un composite bancal. C'est aussi ce que faisait le front (`pt.ts` exigeait * `host && channelId`), et le critère d'acceptation 8.2 impose la parité. */ export function buildPeerTubeComposite(url, channelId) { 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; } const firstString = (...values) => { for (const v of values) { const s = String(v ?? '').trim(); if (s) return s; } return undefined; }; /** * Construit le `channelRef` d'une Suggestion, à partir des champs **legacy** * déjà présents. N'invente jamais de donnée : si aucun identifiant n'est * disponible, renvoie `undefined` (jamais un objet à moitié rempli, jamais de * valeur inventée). * * Chaque règle reproduit à l'identique ce que le front calcule aujourd'hui dans * ses 6 adaptateurs — c'est le critère d'acceptation 8.2. * * @param {string} providerId id court ('yt', 'dm', …) * @param {object} item Suggestion * @returns {{provider: string, scheme: string, value: string}|undefined} */ export function buildChannelRef(providerId, item) { const pid = String(providerId ?? '').trim().toLowerCase(); const scheme = CHANNEL_REF_SCHEMES[pid]; if (!scheme || !item || typeof item !== 'object') return undefined; let value; switch (pid) { case 'yt': // `yt.ts:32` : channelExternalId || channelId value = firstString(item.channelExternalId, item.channelId); break; case 'dm': // `dm.ts:30` : channelId (l'API ne donne que `owner.id`) value = firstString(item.channelId, item.channelExternalId); break; case 'tw': // `tw.ts:24` : channelExternalId || channelHandle (le login) value = firstString(item.channelExternalId, item.channelHandle); break; case 'pt': // `pt.ts:25-27` : hostname(url) + '|' + channelId value = buildPeerTubeComposite(item.url, item.channelId); break; case 'od': // `od.ts:40` : le claim brut, ici canonique (un seul `@`). value = normalizeOdyseeClaim(firstString(item.channel, item.uploaderName, item.channelHandle)); break; case 'ru': // Phase 3.5 : slug extrait de `/c/` par l'adaptateur rumble. value = firstString(item.channelExternalId, item.channelId); break; default: value = undefined; } if (!value) return undefined; return { provider: pid, scheme, value }; } /** * Annote une liste de Suggestions avec leur `channelRef`. * Mutates les objets d'origine : c'est le même contrat que `type`/`isShort`, * ajoutés par les adaptateurs. Les objets sans identité restent inchangés * (pas de clé `channelRef` à `undefined`). * * @template T * @param {string} providerId * @param {T[]} items * @returns {T[]} */ export function withChannelRefs(providerId, items) { if (!Array.isArray(items)) return items; for (const item of items) { const ref = buildChannelRef(providerId, item); if (ref) item.channelRef = ref; } return items; }