Changeset 995b100 in Klonkt


Ignore:
Timestamp:
08/17/2026 07:32:49 AM (3 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
e854ace
Parents:
d97c58c
git-author:
Robin <roboburr@…> (08/17/2026 07:30:41 AM)
git-committer:
Robin <roboburr@…> (08/17/2026 07:32:49 AM)
Message:

YouTube-playlists in een post, met dezelfde parsing als de hub

Een link naar een YouTube-album speelde het eerste nummer en stopte, en
een kale playlist-link werd helemaal niet als YouTube herkend.
detectProvider hield alleen de video-id vast en gooide list= weg.

De ref kent nu drie vormen, exact die van de Klonkt hub, zodat één ref
tussen de twee heen en weer kan zonder vertaling:

"<video>" een video
"<video>?list=<L>" die video, en door de lijst heen
"list:<L>" de hele playlist (YouTube's videoseries)

list mag voor of na v= staan en is in een gebakken href vaak
entity-gecodeerd (&amp;), dus er wordt over de hele URL gezocht in plaats
van op een vaste volgorde. youtube-nocookie.com telt mee als host: dat is
wat een embed zelf uitzendt en dus wat mensen terugplakken.

videoseries is EXACT elf tekens, net als een video-id. Geen lengte- of
grensregel vangt hem -- hij moet bij naam uitgesloten worden. Ik liep er
tijdens het bouwen zelf in met een grenscontrole die eroverheen leek te
gaan; vandaar de test die hem apart vastlegt.

Aan de clientkant (embed-player.js) kennen ytId/ytList/ytEmbedSrc dezelfde
drie vormen. Twee dingen die daar meekomen:

  • De poster interpoleerde een null video-id tot i.ytimg.com/vi/null/hqdefault.jpg -- een 404 als achtergrond. Een kale playlist heeft geen video, en krijgt nu gewoon geen poster.
  • YouTube meldt binnen een playlist "ended" TUSSEN elk tweetal nummers. Daar meteen onEnded op vuren geeft de wachtrij door na nummer één en kapt het album af. Op een lijst wachten we daarom 2,5 seconde, en telt alleen een stilte die niet door het volgende nummer wordt onderbroken -- dezelfde regel als in de hub.

De dode youtubeIframe() is meegegaan: hij wordt nergens aangeroepen, maar
een terugval die de playlist stil laat vallen is de ergste soort, want
die ziet eruit alsof het werkte.

Wat hier NIET in zit: de hub haalt ook de nummerlijst van een playlist op
(Data API met sleutel, anders keyless via de RSS-feed). Dat vraagt een
sleutel en een bewaarplek en is een aparte keuze.

Co-Authored-By: Claude Opus 5 <noreply@…>

Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/embed-player.js

    rd97c58c r995b100  
    113113  function fallbackIframe(provider, ref, url) {
    114114    if (provider === 'youtube') {
    115       const id = ytId(ref, url);
    116       return { src: 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + '?autoplay=1&rel=0', ratio: true, fs: true };
     115      const src = ytEmbedSrc(ref, url, 'autoplay=1&rel=0');
     116      if (src) return { src, ratio: true, fs: true };
    117117    }
    118118    if (provider === 'soundcloud') {
     
    134134    return m + ':' + (s < 10 ? '0' : '') + s;
    135135  }
     136  // Three ref shapes, the same ones AudioEmbedService.detectProvider produces
     137  // and the same ones the Klonkt hub uses, so a ref travels between them
     138  // unchanged:  "<video>" | "<video>?list=<L>" | "list:<L>"
     139  //
     140  // ytId answers only "which VIDEO", because that is what a poster thumbnail
     141  // needs; for a bare playlist there is no video and it returns null.
    136142  function ytId(ref, url) {
    137     if (ref && /^[A-Za-z0-9_-]{11}$/.test(ref)) return ref;
     143    const r = String(ref || '');
     144    if (r.indexOf('list:') === 0) return null;              // a playlist has no single video
     145    const bare = r.split('?list=')[0];
     146    if (/^[A-Za-z0-9_-]{11}$/.test(bare)) return bare;
    138147    const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
    139148    if (m) return m[1];
    140149    return null;  // no blind slice — an invalid ref returns nothing rather than a broken id
     150  }
     151  /** The playlist id of a ref (or of the URL it came from), else null. */
     152  function ytList(ref, url) {
     153    const r = String(ref || '');
     154    if (r.indexOf('list:') === 0) return r.slice(5) || null;
     155    const i = r.indexOf('?list=');
     156    if (i > 0) return r.slice(i + 6) || null;
     157    const m = (url || '').match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/);
     158    return m ? m[1] : null;
     159  }
     160  /** The /embed/ URL for a ref. A bare playlist embeds as `videoseries`. */
     161  function ytEmbedSrc(ref, url, query) {
     162    const base = 'https://www.youtube-nocookie.com/embed/';
     163    const id = ytId(ref, url);
     164    const list = ytList(ref, url);
     165    let src;
     166    if (!id && list) src = base + 'videoseries?list=' + encodeURIComponent(list);
     167    else if (id && list) src = base + encodeURIComponent(id) + '?list=' + encodeURIComponent(list);
     168    else if (id) src = base + encodeURIComponent(id);
     169    else return null;
     170    return src + (src.indexOf('?') > 0 ? '&' : '?') + (query || '');
    141171  }
    142172  // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs).
     
    166196    let html = '';
    167197    if (provider === 'youtube') {
    168       const id = ytId(ref, url);
    169       html = id
    170         ? '<div class="pcms-embed-ratio"><iframe src="https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + '?rel=0" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>'
     198      const src = ytEmbedSrc(ref, url, 'rel=0');
     199      html = src
     200        ? '<div class="pcms-embed-ratio"><iframe src="' + escAttr(src) + '" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>'
    171201        : '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">YouTube</a>';
    172202    } else if (provider === 'soundcloud') {
     
    215245    const isVideo = provider === 'youtube';
    216246    const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
    217     const poster = provider === 'youtube'
    218       ? `https://i.ytimg.com/vi/${ytId(ref, url)}/hqdefault.jpg` : '';
     247    // A bare playlist has no video id, and interpolating null gave
     248    // i.ytimg.com/vi/null/hqdefault.jpg — a 404 painted as the poster.
     249    const posterId = provider === 'youtube' ? ytId(ref, url) : null;
     250    const poster = posterId ? `https://i.ytimg.com/vi/${posterId}/hqdefault.jpg` : '';
    219251
    220252    el.innerHTML = ''
     
    388420      const YT = await ytApi();
    389421      const id = ytId(ref, url);
     422      const list = ytList(ref, url);
    390423      return new Promise((resolve, reject) => {
    391424        let pollTimer = null;
     425        let endTimer = null;       // see onStateChange: a list "ends" between items
    392426        const player = new YT.Player(mountEl, {
    393           videoId: id,
     427          // With a video AND a list, `list` makes the player continue into the
     428          // playlist after this song. Without a video it is the playlist
     429          // itself, and then listType says so -- videoId must stay absent, or
     430          // the API loads that one video and forgets the list.
     431          ...(id ? { videoId: id } : {}),
    394432          host: 'https://www.youtube-nocookie.com',
    395433          playerVars: {
    396434            controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0,
    397435            disablekb: 1, iv_load_policy: 3, origin: window.location.origin,
     436            ...(list ? (id ? { list } : { list, listType: 'playlist' }) : {}),
    398437          },
    399438          events: {
     
    408447                destroy() {
    409448                  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
     449                  if (endTimer) { clearTimeout(endTimer); endTimer = null; }
    410450                  try { player.destroy(); } catch (e) {}
    411451                },
     
    415455              // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued
    416456              if (e.data === 1) {
     457                // A next playlist item started, so the pending "it is over" was
     458                // false alarm.
     459                if (endTimer) { clearTimeout(endTimer); endTimer = null; }
    417460                hooks.onPlay();
    418461                if (!pollTimer) pollTimer = setInterval(() => {
     
    423466                if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
    424467              } else if (e.data === 0) {
    425                 hooks.onEnded();
    426468                if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
     469                // INSIDE a playlist, YouTube reports "ended" between every two
     470                // songs as well. Firing onEnded there hands the queue on after
     471                // song one and cuts the album off. So on a list we wait, and
     472                // only a silence that is not interrupted by the next song
     473                // counts as the end. Same rule as the hub (2.5s).
     474                if (list) {
     475                  if (!endTimer) endTimer = setTimeout(() => { endTimer = null; hooks.onEnded(); }, 2500);
     476                } else {
     477                  hooks.onEnded();
     478                }
    427479              }
    428480            },
  • src/services/AudioEmbedService.js

    rd97c58c r995b100  
    6868    }
    6969
    70     // YouTube — video id is always exactly 11 characters (aligns with the client-side
    71     // ytId() in embed-player.js, which also expects {11}).
    72     if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) {
    73       const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i);
    74       return { provider: 'youtube', id: match[1], url };
     70    // YouTube — a video id is always exactly 11 characters (aligns with the
     71    // client-side ytId() in embed-player.js, which also expects {11}).
     72    //
     73    // A link may carry a video, a playlist, or both, and until now we kept only
     74    // the video and threw `list=` away -- so a link to an album played its first
     75    // song and stopped. The ref now keeps whichever is there, in the same three
     76    // shapes the Klonkt hub uses, so one ref travels between the two unchanged:
     77    //
     78    //   "<video>"           one video
     79    //   "<video>?list=<L>"  that video, and on through the list
     80    //   "list:<L>"          the whole playlist (YouTube's `videoseries`)
     81    //
     82    // `list` may sit before or after `v=` and is often entity-encoded (&amp;)
     83    // in a baked href, hence the scan over the whole URL rather than a fixed
     84    // order. A list id is 10-60 chars: longer and looser than a video id.
     85    if (/(?:youtube(?:-nocookie)?\.com\/(?:watch\?|playlist\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i.test(url)) {
     86      const vm = url.match(/(?:[?&](?:amp;)?v=|youtu\.be\/|\/embed\/|\/shorts\/|\/live\/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])/i);
     87      const lm = url.match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/i);
     88      // `videoseries` is a marker, not a video: a bare playlist embed URL reads
     89      // /embed/videoseries?list=..., and taking that for an id gives a dead
     90      // frame. It is EXACTLY eleven characters, so no length rule catches it --
     91      // it has to be named. (Measured, not assumed: it slipped through a
     92      // boundary check that looked like it covered this.)
     93      const id = vm && vm[1] !== 'videoseries' ? vm[1] : null;
     94      const list = lm ? lm[1] : null;
     95      if (id || list) {
     96        const ref = id ? (list ? `${id}?list=${list}` : id) : `list:${list}`;
     97        // `id` stays exactly what it was for every caller that only wants a
     98        // video; `list` and `ref` are additions.
     99        return { provider: 'youtube', id, list, ref, url };
     100      }
    75101    }
    76102
     
    122148      // iframe, so the embed appears in OUR brand style.
    123149      case 'youtube':
    124         return this.embedPlaceholder('youtube', config.id, 'video',
    125           config.url || `https://youtu.be/${config.id}`);
     150        // The ref carries the list when there is one; `id` alone would drop it
     151        // and play a single song out of an album.
     152        return this.embedPlaceholder('youtube', config.ref || config.id, 'video',
     153          config.url || (config.id ? `https://youtu.be/${config.id}`
     154                                   : `https://www.youtube.com/playlist?list=${config.list}`));
    126155      case 'soundcloud':
    127156        return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
     
    224253  }
    225254
    226   static youtubeIframe({ id }) {
    227     const src = `https://www.youtube-nocookie.com/embed/${id}`;
     255  /**
     256   * The plain provider iframe. Takes the same ref shapes as the placeholder:
     257   * "<video>", "<video>?list=<L>" and "list:<L>" -- a bare playlist embeds as
     258   * `videoseries`. Kept in step with the placeholder path on purpose: this is
     259   * the fallback, and a fallback that silently drops the playlist is the worst
     260   * kind, because it looks like it worked.
     261   */
     262  static youtubeIframe({ id, ref }) {
     263    const r = ref || id || '';
     264    const base = 'https://www.youtube-nocookie.com/embed/';
     265    let src;
     266    if (r.startsWith('list:')) {
     267      src = `${base}videoseries?list=${encodeURIComponent(r.slice(5))}`;
     268    } else if (r.includes('?list=')) {
     269      const [v, l] = r.split('?list=');
     270      src = `${base}${encodeURIComponent(v)}?list=${encodeURIComponent(l)}`;
     271    } else {
     272      src = base + encodeURIComponent(r);
     273    }
    228274    return `
    229275      <figure class="folio-embed folio-embed--youtube">
Note: See TracChangeset for help on using the changeset viewer.