/** * AudioEmbedService — Parse URLs and return embed HTML * Supports: Spotify, Bandcamp, SoundCloud, Apple Music, YouTube, Vimeo * * Usage in post content: *

https://open.spotify.com/track/123abc

* → *
* *
*/ // "Open in" icons (brand-colored via CSS .pat-link--). const OPEN_IN_SVG = { spotify: '', youtube: '', soundcloud: '', }; class AudioEmbedService { // Small "open in" links for a track (Spotify/YouTube/SoundCloud). The hrefs // are already validated server-side (https + correct host only). Returns '' // when no links exist. Placed next to the play button (outside the button → // no conflict with playback). static openInLinks(t) { if (!t) return ''; const out = []; const add = (url, key, label) => { if (!url) return; out.push(`${OPEN_IN_SVG[key]}`); }; add(t.link_spotify, 'spotify', 'Spotify'); add(t.link_youtube, 'youtube', 'YouTube'); add(t.link_soundcloud, 'soundcloud', 'SoundCloud'); return out.length ? `${out.join('')}` : ''; } static detectProvider(url) { if (!url || typeof url !== 'string') return null; url = url.trim(); // Only embed http(s) URLs. The provider regexes below are NOT anchored, // so without this check e.g. `javascript:alert(1)//youtu.be/x` would match // and land as an embed URL (stored XSS via an [[embed:...]] shortcode — // that text never passes through the HTML sanitizer because it lives in a // text node). The scheme guard excludes javascript:/data:/vbscript: etc. if (!/^https?:\/\//i.test(url)) return null; // Spotify if (/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i.test(url)) { const match = url.match(/\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i); return { provider: 'spotify', type: match[1], id: match[2], url }; } // Bandcamp if (/bandcamp\.com\/(track|album)/i.test(url)) { return { provider: 'bandcamp', url }; } // SoundCloud if (/soundcloud\.com/i.test(url)) { return { provider: 'soundcloud', url }; } // Apple Music if (/music\.apple\.com\/([a-z]{2})\/(?:album|playlist|song)\//i.test(url)) { return { provider: 'applemusic', url }; } // YouTube — video id is always exactly 11 characters (aligns with the client-side // ytId() in embed-player.js, which also expects {11}). if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) { const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i); return { provider: 'youtube', id: match[1], url }; } // Vimeo if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) { const match = url.match(/\d+/); return { provider: 'vimeo', id: match[0], url }; } return null; } // Direct media files (video/audio) hosted anywhere → a native