source: Klonkt/src/services/MusicMeta.js@ db81e56

main
Last change on this file since db81e56 was 328d837, checked in by roboburr <roboburr@…>, 2 months ago

feat(music): schema.org MusicRecording/MusicAlbum structured data on audio posts (music federation Fase 1)

An audio post now emits standard schema.org MusicRecording (single track) or MusicAlbum
(album/playlist) JSON-LD alongside the existing Article data — a real web standard read by
search engines and generic JSON-LD consumers, NOT a Klonkt-invented field. The url points
to the gated player page, so the anti-steal posture is unchanged. The track-resolution helper
is the reusable base for the (future) Funkwhale Audio/Library federation (Fase 2).

  • src/services/MusicMeta.js (new) — resolves a post's track/album/playlist shortcodes to hosted tracks and builds a schema.org MusicRecording/MusicAlbum (name, byArtist=MusicGroup, inAlbum, ISO-8601 duration, license, creditText, image, url)
  • src/routes/posts.js — passes a musicLd local on the post page
  • src/views/shell.ejs — renders a second ld+json script for music posts (Article kept intact)

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

  • Property mode set to 100644
File size: 4.0 KB
Line 
1// Phase 1 of music federation: emit STANDARD schema.org MusicRecording / MusicAlbum
2// structured data for an audio post (Google rich results + any generic JSON-LD consumer).
3// Deliberately a real, existing web standard — NOT a Klonkt-invented field. The track
4// resolution here is reused by the (future) Funkwhale Audio/Library federation (Phase 2).
5import db from '../config/database.js';
6
7const COLS = 'title, album, duration, credit, license, cover_url, media_id';
8
9function isoDuration(sec) {
10 const n = parseInt(sec, 10);
11 if (!n || n < 0) return null;
12 return `PT${Math.floor(n / 60)}M${n % 60}S`; // ISO-8601 duration, e.g. PT3M20S
13}
14function absUrl(base, u) {
15 if (!u) return null;
16 return /^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
17}
18
19// Resolve a post's [[track]]/[[album]]/[[playlist]] shortcodes to the HOSTED (playable)
20// tracks it references — only file-backed tracks (media_id), mirroring hasPlayableAudio.
21function resolveTracks(site, content) {
22 const tracks = [];
23 try {
24 for (const m of content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
25 const r = db.prepare(`SELECT ${COLS} FROM audio_tracks WHERE id = ?`).get(m[1]);
26 if (r && r.media_id) tracks.push(r);
27 }
28 for (const m of content.matchAll(/\[\[album:([^\]]+)\]\]/g)) {
29 for (const r of db.prepare(`SELECT ${COLS} FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL ORDER BY rowid`).all(site.id, m[1].trim())) tracks.push(r);
30 }
31 for (const m of content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) {
32 for (const r of db.prepare(`SELECT t.title, t.album, t.duration, t.credit, t.license, t.cover_url, t.media_id FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? AND t.media_id IS NOT NULL ORDER BY pt.position`).all(m[1])) tracks.push(r);
33 }
34 } catch { /* non-fatal */ }
35 return tracks;
36}
37
38// Build a schema.org MusicRecording (single track) or MusicAlbum (multiple) for a post,
39// or null when the post has no hosted audio. `url` points to the gated player page — the
40// anti-steal posture is preserved (no raw file URL is ever emitted).
41export function build(base, site, post) {
42 if (!post || !site || !post.content) return null;
43 if (!/\[\[(track|album|playlist):/i.test(post.content)) return null;
44 const b = (base || '').replace(/\/+$/, '');
45 const tracks = resolveTracks(site, post.content);
46 if (!tracks.length) return null;
47
48 const artist = {
49 '@type': 'MusicGroup',
50 name: site.title || site.slug,
51 url: `${b}/${site.is_primary ? '' : 'user/' + encodeURIComponent(site.slug)}`,
52 };
53 const postUrl = `${b}/${encodeURIComponent(post.slug)}`;
54 const recording = (t, withTop) => {
55 const o = { '@type': 'MusicRecording', name: t.title || post.title || 'Untitled' };
56 if (withTop) { o.byArtist = artist; o.url = postUrl; }
57 if (t.album) o.inAlbum = { '@type': 'MusicAlbum', name: t.album };
58 const d = isoDuration(t.duration); if (d) o.duration = d;
59 if (t.license) o.license = t.license; // e.g. "CC BY 4.0" — Klonkt leads on this
60 if (t.credit) o.creditText = t.credit;
61 const cov = absUrl(b, t.cover_url) || absUrl(b, post.cover_image_url); if (cov) o.image = cov;
62 return o;
63 };
64
65 let ld;
66 if (tracks.length > 1) {
67 const albums = [...new Set(tracks.map((t) => t.album).filter(Boolean))];
68 ld = {
69 '@type': 'MusicAlbum',
70 name: albums.length === 1 ? albums[0] : (post.title || 'Album'),
71 byArtist: artist,
72 url: postUrl,
73 numTracks: tracks.length,
74 track: tracks.map((t) => recording(t, false)),
75 };
76 const cov = absUrl(b, post.cover_image_url) || absUrl(b, tracks[0].cover_url); if (cov) ld.image = cov;
77 const lic = tracks.find((t) => t.license); if (lic) ld.license = lic.license;
78 } else {
79 ld = recording(tracks[0], true);
80 }
81 ld['@context'] = 'https://schema.org';
82 const dp = post.published_at || post.created_at;
83 if (dp) ld.datePublished = new Date(dp).toISOString();
84 return ld;
85}
86
87export default { build };
Note: See TracBrowser for help on using the repository browser.