Changeset 4c9c60b in Klonkt


Ignore:
Timestamp:
06/25/2026 05:27:29 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
ca25f360
Parents:
e951b7c
Message:

feat(fediverse): player card for music posts (embed player, no raw mp3)

A post with audio now advertises a player card (twitter:player + og:video) pointing
at /embed?post=<slug>, so Mastodon shows an inline player that loads our embeddable
player — audio still streams via the gated /audio/stream, so no downloadable mp3 is
exposed. /embed?post=<slug> scopes the player to that post's tracks. Premium-gated
(the embed player is premium); non-premium falls back to the plain link card.

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

Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/embed.js

    re951b7c r4c9c60b  
    1212
    1313import express from 'express';
     14import db from '../config/database.js';
    1415import { premiumUnlocked } from '../services/PatreonService.js';
    1516
     
    2829  );
    2930
    30   const tracks = (res.locals.audioTracks || []).map((t) => ({
     31  let tracks = (res.locals.audioTracks || []).map((t) => ({
    3132    id: t.id, title: t.title, artist: t.artist, duration: t.duration, url: t.media_url,
    3233  })).filter((t) => t.url);
     34
     35  // ?post=<slug> → scope the player to that post's tracks (for the fediverse
     36  // player card). Resolve [[track]]/[[album]]/[[playlist]] shortcodes → track ids.
     37  const postSlug = (req.query.post || '').toString();
     38  if (postSlug) {
     39    try {
     40      const post = db.prepare("SELECT content FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(site.id, postSlug);
     41      if (post && post.content) {
     42        const ids = []; const seen = new Set();
     43        const add = (id) => { if (id && !seen.has(id)) { seen.add(id); ids.push(id); } };
     44        for (const m of post.content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) add(m[1]);
     45        for (const m of post.content.matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare('SELECT id FROM audio_tracks WHERE site_id = ? AND album = ? ORDER BY position').all(site.id, m[1].trim())) add(r.id);
     46        for (const m of post.content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position').all(m[1])) add(r.track_id);
     47        if (ids.length) {
     48          const byId = new Map(tracks.map((t) => [t.id, t]));
     49          const scoped = ids.map((id) => byId.get(id)).filter(Boolean);
     50          if (scoped.length) tracks = scoped;
     51        }
     52      }
     53    } catch { /* fall back to the full site player */ }
     54  }
    3355
    3456  res.render('pages/embed-player', {
  • src/views/shell.ejs

    re951b7c r4c9c60b  
    153153<% } %>
    154154<% if (_canonical) { %><meta property="og:url" content="<%= _e(_canonical) %>"><% } %>
     155<%
     156// Fediverse/social PLAYER card for posts with audio: instead of shipping the raw
     157// mp3, point at our embeddable player (/embed?post=slug) so Mastodon shows an
     158// inline player that streams via the gated /audio/stream (no downloadable file).
     159const _postAudio = !!(typeof post !== 'undefined' && post
     160  && /\[\[(track|album|playlist):/i.test(post.content || post.content_html || '')
     161  && typeof premiumUnlocked !== 'undefined' && premiumUnlocked);
     162const _embedUrl = _postAudio
     163  ? ((typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + safeUrlBase + '/embed?post=' + encodeURIComponent(post.slug))
     164  : '';
     165%>
     166<% if (_postAudio) { %>
     167<meta property="og:video"            content="<%= _e(_embedUrl) %>">
     168<meta property="og:video:secure_url" content="<%= _e(_embedUrl) %>">
     169<meta property="og:video:type"       content="text/html">
     170<meta property="og:video:width"      content="480">
     171<meta property="og:video:height"     content="160">
     172<% } %>
    155173<% if (typeof post !== 'undefined' && post && post.published_at) { %>
    156174<meta property="article:published_time" content="<%= _e(post.published_at) %>">
     
    160178
    161179<!-- Twitter Cards -->
    162 <meta name="twitter:card"        content="<%= _socialImage ? 'summary_large_image' : 'summary' %>">
     180<meta name="twitter:card"        content="<%= _postAudio ? 'player' : (_socialImage ? 'summary_large_image' : 'summary') %>">
     181<% if (_postAudio) { %>
     182<meta name="twitter:player"        content="<%= _e(_embedUrl) %>">
     183<meta name="twitter:player:width"  content="480">
     184<meta name="twitter:player:height" content="160">
     185<% } %>
    163186<meta name="twitter:title"       content="<%= _e(_socialTitle) %>">
    164187<meta name="twitter:description" content="<%= _e(_socialDescr) %>">
Note: See TracChangeset for help on using the changeset viewer.