Changeset a5d14c7 in Klonkt for src/services/music/index.js


Ignore:
Timestamp:
08/09/2026 08:20:24 AM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
0677bac
Parents:
b2adc20
Message:

De post is de uitgave: losse tracks worden een collectie (shaer-38y)

Losse tracks gingen tot nu toe los de deur uit -- Audio-objecten die een lezer
nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
dat is wat /ap/users/<slug>/posts/<id>/tracks nu zegt. De post wijst er langs
dezelfde weg naartoe als naar een playlist: een Link-tag in de Note, want zonder
die link bestaat de collectie wel maar vindt niemand hem.

PUNT 1, DE VOLGORDE. De tracks staan in de volgorde van de POST en niet die van
de tabel. Zoals iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.

PUNT 3, DE METADATA. Een playlist heeft een titel en soms een hoes; een post
heeft een titel, een tekst, een hoes, tags en een datum. Voor audio-gebaseerde
inhoud is de post de uitgave -- dat is waar iemand hem heeft uitgebracht en waar
het verhaal erbij staat. Zowel de collectie van losse tracks als een
playlist-collectie leent nu van de post die haar uitbrengt.

De posttekst wordt content en niet summary: in AS2 is summary de korte
samenvatting en content het lijf. Artiest en jaar blijven dus in summary staan,
want dat IS een samenvatting en de posttekst is dat niet. De eigen naam van de
playlist gaat niet verloren maar verhuist naar alsoKnownAs.

WIE LEENT ER UIT: alleen een post die er EEN muzikale eenheid van maakt. Staan
er twee collecties in, dan is de post niet meer de drager van een identiteit en
houdt de playlist de zijne. Dezelfde regel als in de afleiding, hier alleen
toegepast.

Een test die eerder vastlegde dat losse tracks GEEN Link-tag kregen is
omgedraaid, met de reden erbij: dat klopte zolang alleen een playlist een
collectie had.

Punt 2 van de bead (type afleiden) stond al in postMusicType.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/music/index.js

    rb2adc20 ra5d14c7  
    1515import db from '../../config/database.js';
    1616import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType } from '../ap-core.js';
    17 import { afleidenUitInsluitingen } from '../../assets/js/shared/post-music-type.js';
     17import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
    1818
    1919// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
     
    222222// zonder site-check zou een post van site A naar de collectie van site B
    223223// kunnen wijzen.
    224 export function playlistLinkTags(base, site, content) {
     224export function playlistLinkTags(base, site, content, post = null) {
    225225  const out = [];
    226   const seen = new Set();
    227226  try {
    228     for (const m of (content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) {
    229       if (seen.has(m[1])) continue;
    230       seen.add(m[1]);
    231       const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(m[1], site.id);
     227    // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
     228    // krijgt ook geen link. Dit stond hier met een eigen patroon dat
     229    // underscores accepteerde die nergens anders meetellen.
     230    for (const id of ingeslotenPlaylists(content)) {
     231      const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
    232232      if (!pl) continue;
    233233      out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
     234    }
     235    // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
     236    // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
     237    // link zou die collectie bestaan maar door niemand te vinden zijn.
     238    if (post && post.id) {
     239      const eenheid = postMusicType(content, site.id);
     240      if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
     241        out.push({
     242          type: 'Link',
     243          href: postTracksId(base, site, post.id),
     244          mediaType: 'application/activity+json',
     245          name: post.title || 'Tracks',
     246        });
     247      }
    234248    }
    235249  } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
     
    291305  const cover = abs(playlist.cover_url || null);
    292306  if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
    293   return out;
     307  return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
     308}
     309
     310// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
     311
     312/** Het AS2-id van de collectie losse tracks van een post. */
     313function postTracksId(base, site, postId) {
     314  return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
     315}
     316
     317/**
     318 * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
     319 *
     320 * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
     321 * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
     322 * post niet meer de drager van een identiteit en houdt de playlist de zijne --
     323 * dezelfde regel als in de afleiding, hier alleen toegepast.
     324 *
     325 * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
     326 * uitgebracht.
     327 */
     328export function uitgavePost(siteId, playlistId) {
     329  if (!siteId || !playlistId) return null;
     330  try {
     331    const rijen = db.prepare(`
     332      SELECT id, slug, title, excerpt, content, cover_image_url, tags
     333      FROM posts
     334      WHERE site_id = ? AND status = 'published'
     335        AND content LIKE '%[[playlist:' || ? || ']]%'
     336      ORDER BY COALESCE(published_at, created_at) DESC
     337    `).all(siteId, playlistId);
     338    for (const p of rijen) {
     339      const r = postMusicType(p.content, siteId);
     340      if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
     341    }
     342  } catch { /* geen lening is geen fout */ }
     343  return null;
     344}
     345
     346/**
     347 * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
     348 *
     349 * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
     350 * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
     351 * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
     352 * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
     353 * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
     354 * hij nu krijgt.
     355 *
     356 * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
     357 * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
     358 */
     359function leenVanPost(base, site, obj, post) {
     360  if (!post) return obj;
     361  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
     362
     363  if (post.title) {
     364    if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
     365    obj.name = post.title;
     366  }
     367  // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
     368  // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
     369  // staan -- dat is een samenvatting, en de posttekst is dat niet.
     370  const tekst = (post.excerpt || '').trim();
     371  if (tekst) obj.content = tekst;
     372
     373  const cover = abs(post.cover_image_url || null);
     374  if (cover) {
     375    obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
     376    if (!obj.icon) obj.icon = obj.image;      // geen eigen hoes? dan die van de post
     377  }
     378
     379  const tags = hashtagsVanPost(base, post.tags);
     380  if (tags.length) obj.tag = tags;
     381
     382  // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
     383  // losse track: url wijst een mens naar de post, context zegt waar dit object
     384  // thuishoort.
     385  obj.url = `${base}/${post.slug}`;
     386  obj.context = noteId(base, post.id);
     387  return obj;
     388}
     389
     390/** De tags van een post als AS2 Hashtags. Zelfde vorm als buildHashtagList. */
     391function hashtagsVanPost(base, tagsField) {
     392  const ruw = Array.isArray(tagsField)
     393    ? tagsField
     394    : String(tagsField || '').split(',');
     395  const uit = [], gezien = new Set();
     396  for (const t of ruw) {
     397    const label = String(t || '').trim().replace(/^#/, '');
     398    if (!label) continue;
     399    const slug = label.toLowerCase().replace(/\s+/g, '-');
     400    if (gezien.has(slug)) continue;
     401    gezien.add(slug);
     402    uit.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(slug)}`, name: '#' + label });
     403  }
     404  return uit;
     405}
     406
     407/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
     408function losseTracksVanPost(siteId, ids) {
     409  if (!siteId || !ids?.length) return [];
     410  const gaten = ids.map(() => '?').join(',');
     411  const rijen = db.prepare(
     412    `SELECT ${TRACK_KOLOMMEN}
     413     FROM audio_tracks t JOIN media m ON m.id = t.media_id
     414     WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
     415  ).all(siteId, ...ids);
     416  // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
     417  // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
     418  const opId = new Map(rijen.map((r) => [r.id, r]));
     419  return ids.map((id) => opId.get(id)).filter(Boolean);
     420}
     421
     422/**
     423 * De losse tracks van een post als EEN uitgave (shaer-38y).
     424 *
     425 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
     426 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
     427 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
     428 * heeft ze wel en de losse tracks niet.
     429 *
     430 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
     431 * post die geen enkele muzikale eenheid IS.
     432 */
     433export function buildPostTrackCollection(base, site, post) {
     434  if (!post || !post.id) return null;
     435  const eenheid = postMusicType(post.content, site.id);
     436  if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
     437
     438  const rows = losseTracksVanPost(site.id, eenheid.tracks);
     439  if (!rows.length) return null;
     440
     441  const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
     442  const out = {
     443    '@context': AP_CONTEXT,
     444    id: postTracksId(base, site, post.id),
     445    type: 'OrderedCollection',
     446    attributedTo: actorId(base, site.slug),
     447    totalItems: rows.length,
     448    orderedItems: rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
     449  };
     450  return leenVanPost(base, site, out, post);
    294451}
    295452
Note: See TracChangeset for help on using the changeset viewer.