Files
NewTube/src/app/shared/providers/channel-ref.ts
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

159 lines
6.5 KiB
TypeScript

/**
* 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<ProviderId, ChannelRefScheme> = {
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://<channel>`, 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<ChannelRef>;
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;
}
}