Changeset 30271e6 in Klonkt


Ignore:
Timestamp:
06/25/2026 06:13:00 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
6c3d805
Parents:
2923a95
Message:

fix(fediverse): player card only for playable (hosted) audio; scope embed strictly

A link-only track (external, media_id NULL) has no hosted audio, so it must not get
a Klonkt player card — and /embed?post= must not fall back to ALL site tracks (that
showed unrelated songs). New hasPlayableAudio() gates the player card + cover-
suppression on a real file-backed track; link-only audio keeps its cover. /embed?post
now scopes strictly to the post's tracks (no all-site fallback).

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

Location:
src
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • src/routes/embed.js

    r2923a95 r30271e6  
    4545        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);
    4646        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         }
     47        // Strictly scope to this post's tracks — do NOT fall back to all-site
     48        // tracks (that showed unrelated songs for a link-only-track post).
     49        const byId = new Map(tracks.map((t) => [t.id, t]));
     50        tracks = ids.map((id) => byId.get(id)).filter(Boolean);
    5251      }
    5352    } catch { /* fall back to the full site player */ }
  • src/routes/posts.js

    r2923a95 r30271e6  
    848848    canManageSite,
    849849    siteAvatar,
     850    postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
    850851    pageTitle: post.title + ' - ' + site.title,
    851852    socialDescr: post.excerpt || '',
  • src/services/ActivityPubService.js

    r2923a95 r30271e6  
    9797}
    9898
     99// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
     100// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
     101// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
     102export function hasPlayableAudio(content, siteId) {
     103  if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
     104  try {
     105    for (const m of content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare('SELECT media_id FROM audio_tracks WHERE id = ?').get(m[1]); if (r && r.media_id) return true; }
     106    for (const m of content.matchAll(/\[\[album:([^\]]+)\]\]/g)) { if (db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL LIMIT 1').get(siteId, m[1].trim())) return true; }
     107    for (const m of content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) { if (db.prepare('SELECT 1 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 LIMIT 1').get(m[1])) return true; }
     108  } catch { /* non-fatal */ }
     109  return false;
     110}
     111
    99112// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
    100113export function buildNote(base, site, post) {
     
    117130  };
    118131  const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
     132  const playable = hasPlayableAudio(post.content || '', site && site.id);
    119133  const urls = [];
    120   // Audio posts suppress image attachments so Mastodon renders the player CARD
    121   // (twitter:player) instead of the cover — on Mastodon a media attachment and a
    122   // link/player card are mutually exclusive, and the inline player is the point.
    123   if (post.cover_image_url && !hadAudio) urls.push(abs(post.cover_image_url));
     134  // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
     135  // the player CARD (twitter:player) instead of the cover — media attachment and
     136  // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
     137  // keeps its cover (no player card to show).
     138  if (post.cover_image_url && !playable) urls.push(abs(post.cover_image_url));
    124139  let body = post.content || '';
    125   if (!hadAudio) for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1]));
     140  if (!playable) for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1]));
    126141  body = body.replace(/<img\b[^>]*>/gi, '');
    127142  // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
     
    960975  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    961976  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
    962   getReplyUris, markNotificationsSeen, countUnseenNotifications,
     977  getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    963978};
  • src/views/shell.ejs

    r2923a95 r30271e6  
    158158// inline player that streams via the gated /audio/stream (no downloadable file).
    159159const _postAudio = !!(typeof post !== 'undefined' && post
    160   && /\[\[(track|album|playlist):/i.test(post.content || post.content_html || '')
     160  && typeof postHasPlayableAudio !== 'undefined' && postHasPlayableAudio
    161161  && typeof premiumUnlocked !== 'undefined' && premiumUnlocked);
    162162const _embedUrl = _postAudio
Note: See TracChangeset for help on using the changeset viewer.