| 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).
|
|---|
| 5 | import db from '../config/database.js';
|
|---|
| 6 |
|
|---|
| 7 | const COLS = 'title, album, duration, credit, license, cover_url, media_id';
|
|---|
| 8 |
|
|---|
| 9 | function 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 | }
|
|---|
| 14 | function 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.
|
|---|
| 21 | function 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).
|
|---|
| 41 | export 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 |
|
|---|
| 87 | export default { build };
|
|---|