Files
NewTube/server/providers/channel-ref.mjs
T
bruno 665a0f0ebd
CI / build-and-test (push) Successful in 14m43s
feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
2026-09-30 07:57:11 -04:00

180 lines
6.5 KiB
JavaScript

/**
* 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/<slug>`
*
* 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://<channel>` — 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/<slug>` 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;
}