fix(embeds): parsing over/under-match + broken thumbnails + wide layout
Bugs found by a 3-agent audit of the inline-embed system (core posture — sandbox, postMessage origin+source, XSS, noreferrer, oEmbed — verified sound); fixes reviewed by 2 agents on the staged diff (both SHIP). Parsing (videoEmbed.ts, + tests): - Twitch/Kick/SoundCloud/Streamable reserved-path exclusion — their own utility pages (twitch.tv/directory, kick.com/browse, soundcloud.com/discover/…, streamable.com/login, bare /videos) no longer render as broken player embeds. - SoundCloud: `/<artist>/<tab>` profile-tab listings excluded; `/<artist>/sets/<slug>` real sets still detected. - Vimeo: unlisted-hash capture constrained to lowercase-hex, so a normal video's trailing segment (/likes, /settings, a slug) isn't captured as a bogus `h=` param that Vimeo then rejects. Rendering (UrlPreviewCard.tsx, RenderMessageContent.tsx): - Spotify/Steam/Discord/IMDb route og:image through mxcUrlToHttp like every other card — a raw og:image is an mxc:// URI (broken <img> on standard Synapse) or an off-homeserver request that defeats the click-to-play facade. - `wide` card class now follows the RESOLVED embed (incl. the og:url short-link fallback), so an og:url-resolved player gets the wide layout, not a cramped one. - Twitter host detection (isTwitter/isTwitterTweet) aligned with getTweetId — mobile.twitter.com and legacy /statuses/ now route to the Twitter card/embed. - De-dupe preview URLs so a message repeating a link doesn't render sibling cards with identical React keys. Gates: tsc 0, eslint 0, prettier clean, 910 tests, build ok. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -88,8 +88,9 @@ export function RenderMessageContent({
|
|||||||
}: RenderMessageContentProps) {
|
}: RenderMessageContentProps) {
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||||
// fetches + iframes at once.
|
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
||||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
|
// twice would otherwise render sibling cards with identical React keys.
|
||||||
|
const filteredUrls = [...new Set(urls.filter((url) => !testMatrixTo(url)))].slice(0, 6);
|
||||||
if (filteredUrls.length === 0) return undefined;
|
if (filteredUrls.length === 0) return undefined;
|
||||||
return (
|
return (
|
||||||
<UrlPreviewHolder>
|
<UrlPreviewHolder>
|
||||||
|
|||||||
@@ -116,11 +116,15 @@ function isGitHubRepo(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep these hosts + the /status(es) pattern in sync with getTweetId
|
||||||
|
// (videoEmbed.ts): otherwise a mobile.twitter.com / legacy /statuses/ tweet has
|
||||||
|
// an extractable id but never routes to the Twitter card or "View post" embed.
|
||||||
|
const TWITTER_HOSTS = new Set(['twitter.com', 'x.com', 'mobile.twitter.com']);
|
||||||
|
|
||||||
function isTwitter(url: string): boolean {
|
function isTwitter(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname } = new URL(url);
|
const { hostname } = new URL(url);
|
||||||
const h = hostname.replace(/^www\./, '');
|
return TWITTER_HOSTS.has(hostname.replace(/^www\./, ''));
|
||||||
return h === 'twitter.com' || h === 'x.com';
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -129,9 +133,8 @@ function isTwitter(url: string): boolean {
|
|||||||
function isTwitterTweet(url: string): boolean {
|
function isTwitterTweet(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
const h = hostname.replace(/^www\./, '');
|
if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false;
|
||||||
if (h !== 'twitter.com' && h !== 'x.com') return false;
|
return /\/status(?:es)?\/\d+/.test(pathname);
|
||||||
return /\/status\/\d+/.test(pathname);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1499,9 +1502,17 @@ function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const artworkUrl = (prev['og:image'] as string | undefined) ?? '';
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
// Route through the homeserver like every other card — a raw og:image would
|
||||||
|
// be an mxc:// URI (broken <img>) on a standard HS, or an off-HS request that
|
||||||
|
// defeats the click-to-play facade on a nonstandard one.
|
||||||
|
const artworkUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
|
||||||
|
: null;
|
||||||
const spotifyType = getSpotifyType(url) ?? 'track';
|
const spotifyType = getSpotifyType(url) ?? 'track';
|
||||||
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
|
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
|
||||||
|
|
||||||
@@ -1552,9 +1563,14 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const thumbnailUrl = (prev['og:image'] as string | undefined) ?? '';
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const thumbnailUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1671,9 +1687,14 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
|
|||||||
|
|
||||||
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const iconUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1834,9 +1855,14 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const posterUrl = (prev['og:image'] as string | undefined) ?? '';
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const posterUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 120, 180, 'scale', false)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -2075,28 +2101,34 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
// Interactive embeds (players, tweets) render in a wider, responsive card so
|
// Interactive embeds (players, tweets) render in a wider, responsive card so
|
||||||
// player chrome / tweet content isn't cramped or clipped.
|
// player chrome / tweet content isn't cramped or clipped.
|
||||||
const embed = parseMediaEmbed(url, window.location.hostname);
|
const embed = parseMediaEmbed(url, window.location.hostname);
|
||||||
const wide = !!embed || isTwitterTweet(url);
|
// Short "copy-link" links carry no id, so the embed is only resolvable from
|
||||||
|
// the homeserver's canonical og:url. Resolve it here so `wide` reflects the
|
||||||
|
// ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get
|
||||||
|
// the wide layout, not the cramped narrow one.
|
||||||
|
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
||||||
|
if (embed) return embed;
|
||||||
|
const ogUrl = prev['og:url'];
|
||||||
|
return typeof ogUrl === 'string' && ogUrl !== url
|
||||||
|
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||||
|
: null;
|
||||||
|
};
|
||||||
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
|
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
|
||||||
// in the card flex row; stack them on phones (no-op for the single-column
|
// in the card flex row; stack them on phones (no-op for the single-column
|
||||||
// embed cards). Desktop keeps the row layout.
|
// embed cards). Desktop keeps the row layout.
|
||||||
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
|
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
|
||||||
const cardClass =
|
const buildCardClass = (wide: boolean): string | undefined =>
|
||||||
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
|
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ') || undefined;
|
.join(' ') || undefined;
|
||||||
|
|
||||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
const renderContent = (
|
||||||
|
prev: IPreviewUrlResponse,
|
||||||
|
resolvedEmbed: MediaEmbed | null,
|
||||||
|
): React.ReactNode => {
|
||||||
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||||
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play
|
||||||
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
|
// tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already
|
||||||
// redirects) don't carry the id, so fall back to the canonical og:url that
|
// folds in the og:url fallback for short "copy-link" share URLs.
|
||||||
// the homeserver already resolved when fetching the preview.
|
|
||||||
const ogUrl = prev['og:url'];
|
|
||||||
const resolvedEmbed =
|
|
||||||
embed ??
|
|
||||||
(typeof ogUrl === 'string' && ogUrl !== url
|
|
||||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
|
||||||
: null);
|
|
||||||
if (resolvedEmbed) {
|
if (resolvedEmbed) {
|
||||||
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
|
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
|
||||||
}
|
}
|
||||||
@@ -2189,17 +2221,23 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
|
|
||||||
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
||||||
if (previewStatus.status === AsyncStatus.Success) {
|
if (previewStatus.status === AsyncStatus.Success) {
|
||||||
const content = renderContent(previewStatus.data);
|
const prev = previewStatus.data;
|
||||||
|
const resolvedEmbed = resolveEmbed(prev);
|
||||||
|
const content = renderContent(prev, resolvedEmbed);
|
||||||
if (content === null) return null;
|
if (content === null) return null;
|
||||||
|
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
|
||||||
|
// link that resolves to a player still gets the wide layout.
|
||||||
|
const wide = !!resolvedEmbed || isTwitterTweet(url);
|
||||||
return (
|
return (
|
||||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
|
||||||
{content}
|
{content}
|
||||||
</UrlPreview>
|
</UrlPreview>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
|
||||||
return (
|
return (
|
||||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
<UrlPreview {...props} ref={ref} className={buildCardClass(!!embed || isTwitterTweet(url))}>
|
||||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" size="400" />
|
<Spinner variant="Secondary" size="400" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => {
|
|||||||
assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871');
|
assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871');
|
||||||
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
|
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
|
||||||
assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321');
|
assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321');
|
||||||
|
// a normal video with a trailing sub-path segment must NOT capture it as a hash
|
||||||
|
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.hash, undefined);
|
||||||
|
assert.equal(getVimeoParts('https://vimeo.com/123456789/settings')?.hash, undefined);
|
||||||
|
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.id, '123456789');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
|
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
|
||||||
@@ -113,6 +117,7 @@ test('Dailymotion + Streamable', () => {
|
|||||||
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
|
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
|
||||||
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
|
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
|
||||||
assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path
|
assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path
|
||||||
|
assert.equal(getStreamableId('https://streamable.com/login'), null); // reserved page
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Twitch: channel / video / clip', () => {
|
test('Twitch: channel / video / clip', () => {
|
||||||
@@ -132,6 +137,10 @@ test('Twitch: channel / video / clip', () => {
|
|||||||
type: 'clip',
|
type: 'clip',
|
||||||
value: 'CoolSlug',
|
value: 'CoolSlug',
|
||||||
});
|
});
|
||||||
|
// reserved utility pages are not channels
|
||||||
|
assert.equal(getTwitchTarget('https://twitch.tv/directory'), null);
|
||||||
|
assert.equal(getTwitchTarget('https://twitch.tv/settings'), null);
|
||||||
|
assert.equal(getTwitchTarget('https://twitch.tv/videos'), null); // bare /videos, not a channel
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Spotify target + height', () => {
|
test('Spotify target + height', () => {
|
||||||
@@ -153,6 +162,9 @@ test('SoundCloud track detection', () => {
|
|||||||
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
|
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
|
||||||
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
|
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
|
||||||
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
|
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
|
||||||
|
assert.equal(isSoundCloudTrack('https://soundcloud.com/discover/xyz'), false); // site section
|
||||||
|
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets'), false); // profile-tab listing
|
||||||
|
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets/my-set'), true); // a real set
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
|
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
|
||||||
@@ -287,6 +299,8 @@ test('Bluesky / Loom / Kick', () => {
|
|||||||
|
|
||||||
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
|
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
|
||||||
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
|
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
|
||||||
|
assert.equal(getKickChannel('https://kick.com/browse'), null); // nav page, not a channel
|
||||||
|
assert.equal(getKickChannel('https://kick.com/following'), null);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
|
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
|
||||||
'player.kick.com/streamer?autoplay=true',
|
'player.kick.com/streamer?autoplay=true',
|
||||||
|
|||||||
+106
-6
@@ -66,8 +66,10 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
|
|||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||||
// Canonical /{id} or unlisted /{id}/{hash}
|
// Canonical /{id} or unlisted /{id}/{hash}. The hash is a lowercase-hex token
|
||||||
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
|
// (constrain it so a normal video's trailing segment — /likes, /settings, a
|
||||||
|
// review slug — isn't captured as a bogus `h=` param that Vimeo then rejects).
|
||||||
|
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-f]{6,}))?/);
|
||||||
if (m) return { id: m[1], hash: m[2] };
|
if (m) return { id: m[1], hash: m[2] };
|
||||||
// channels/groups/album share a trailing numeric video id
|
// channels/groups/album share a trailing numeric video id
|
||||||
m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/);
|
m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/);
|
||||||
@@ -192,12 +194,26 @@ export function getDailymotionId(url: string): string | null {
|
|||||||
|
|
||||||
// --- Streamable -----------------------------------------------------------
|
// --- Streamable -----------------------------------------------------------
|
||||||
|
|
||||||
|
// Streamable's own utility/first-path pages that are not video ids.
|
||||||
|
const STREAMABLE_RESERVED = new Set([
|
||||||
|
'e',
|
||||||
|
'login',
|
||||||
|
'signup',
|
||||||
|
'settings',
|
||||||
|
'account',
|
||||||
|
'dashboard',
|
||||||
|
'help',
|
||||||
|
'terms',
|
||||||
|
'privacy',
|
||||||
|
'about',
|
||||||
|
]);
|
||||||
|
|
||||||
export function getStreamableId(url: string): string | null {
|
export function getStreamableId(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
|
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
|
||||||
const m = pathname.match(/^\/([A-Za-z0-9]+)/);
|
const m = pathname.match(/^\/([A-Za-z0-9]+)/);
|
||||||
return m && m[1] !== 'e' ? m[1] : null;
|
return m && !STREAMABLE_RESERVED.has(m[1].toLowerCase()) ? m[1] : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -210,6 +226,32 @@ export type TwitchTarget =
|
|||||||
| { type: 'video'; value: string }
|
| { type: 'video'; value: string }
|
||||||
| { type: 'clip'; value: string };
|
| { type: 'clip'; value: string };
|
||||||
|
|
||||||
|
// Twitch's own reserved first-path segments — single-segment paths that are
|
||||||
|
// utility pages, not channels, and must NOT be embedded as `channel=<x>`.
|
||||||
|
const TWITCH_RESERVED = new Set([
|
||||||
|
'directory',
|
||||||
|
'videos',
|
||||||
|
'settings',
|
||||||
|
'subscriptions',
|
||||||
|
'following',
|
||||||
|
'followers',
|
||||||
|
'friends',
|
||||||
|
'inventory',
|
||||||
|
'wallet',
|
||||||
|
'drops',
|
||||||
|
'prime',
|
||||||
|
'turbo',
|
||||||
|
'downloads',
|
||||||
|
'jobs',
|
||||||
|
'store',
|
||||||
|
'search',
|
||||||
|
'dashboard',
|
||||||
|
'popout',
|
||||||
|
'p',
|
||||||
|
'u',
|
||||||
|
'team',
|
||||||
|
]);
|
||||||
|
|
||||||
export function getTwitchTarget(url: string): TwitchTarget | null {
|
export function getTwitchTarget(url: string): TwitchTarget | null {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
@@ -219,7 +261,9 @@ export function getTwitchTarget(url: string): TwitchTarget | null {
|
|||||||
if (h === 'twitch.tv' || h === 'm.twitch.tv') {
|
if (h === 'twitch.tv' || h === 'm.twitch.tv') {
|
||||||
if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] };
|
if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] };
|
||||||
if (parts[1] === 'clip' && parts[2]) return { type: 'clip', value: parts[2] };
|
if (parts[1] === 'clip' && parts[2]) return { type: 'clip', value: parts[2] };
|
||||||
if (parts.length === 1 && parts[0]) return { type: 'channel', value: parts[0] };
|
if (parts.length === 1 && parts[0] && !TWITCH_RESERVED.has(parts[0].toLowerCase())) {
|
||||||
|
return { type: 'channel', value: parts[0] };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -248,6 +292,38 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str
|
|||||||
|
|
||||||
// --- SoundCloud -----------------------------------------------------------
|
// --- SoundCloud -----------------------------------------------------------
|
||||||
|
|
||||||
|
// SoundCloud's own site sections (first segment) that are never `<artist>`.
|
||||||
|
const SOUNDCLOUD_RESERVED = new Set([
|
||||||
|
'discover',
|
||||||
|
'you',
|
||||||
|
'stream',
|
||||||
|
'search',
|
||||||
|
'upload',
|
||||||
|
'settings',
|
||||||
|
'notifications',
|
||||||
|
'messages',
|
||||||
|
'tags',
|
||||||
|
'charts',
|
||||||
|
'people',
|
||||||
|
'pages',
|
||||||
|
'terms',
|
||||||
|
'pro',
|
||||||
|
]);
|
||||||
|
// Profile tabs — `/<artist>/<tab>` is a listing, not a single track (a real set
|
||||||
|
// is the deeper `/<artist>/sets/<slug>`, which has length >= 3 and is allowed).
|
||||||
|
const SOUNDCLOUD_PROFILE_TABS = new Set([
|
||||||
|
'tracks',
|
||||||
|
'sets',
|
||||||
|
'albums',
|
||||||
|
'reposts',
|
||||||
|
'likes',
|
||||||
|
'following',
|
||||||
|
'followers',
|
||||||
|
'comments',
|
||||||
|
'popular-tracks',
|
||||||
|
'toptracks',
|
||||||
|
]);
|
||||||
|
|
||||||
export function isSoundCloudTrack(url: string): boolean {
|
export function isSoundCloudTrack(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
@@ -260,7 +336,11 @@ export function isSoundCloudTrack(url: string): boolean {
|
|||||||
.replace(/^\/+|\/+$/g, '')
|
.replace(/^\/+|\/+$/g, '')
|
||||||
.split('/')
|
.split('/')
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return parts.length >= 2;
|
if (parts.length < 2) return false;
|
||||||
|
if (SOUNDCLOUD_RESERVED.has(parts[0].toLowerCase())) return false;
|
||||||
|
// `/<artist>/<tab>` profile-tab listing (not a playable single track/set).
|
||||||
|
if (parts.length === 2 && SOUNDCLOUD_PROFILE_TABS.has(parts[1].toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -391,6 +471,22 @@ export function getLoomId(url: string): string | null {
|
|||||||
|
|
||||||
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
|
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
|
||||||
|
|
||||||
|
// Kick's own reserved first-path segments (nav pages, not channels).
|
||||||
|
const KICK_RESERVED = new Set([
|
||||||
|
'browse',
|
||||||
|
'following',
|
||||||
|
'category',
|
||||||
|
'categories',
|
||||||
|
'search',
|
||||||
|
'messages',
|
||||||
|
'subscriptions',
|
||||||
|
'settings',
|
||||||
|
'wallet',
|
||||||
|
'help',
|
||||||
|
'clips',
|
||||||
|
'dashboard',
|
||||||
|
]);
|
||||||
|
|
||||||
export function getKickChannel(url: string): string | null {
|
export function getKickChannel(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
@@ -399,7 +495,11 @@ export function getKickChannel(url: string): string | null {
|
|||||||
.replace(/^\/+|\/+$/g, '')
|
.replace(/^\/+|\/+$/g, '')
|
||||||
.split('/')
|
.split('/')
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
|
return parts.length === 1 &&
|
||||||
|
/^[A-Za-z0-9_]+$/.test(parts[0]) &&
|
||||||
|
!KICK_RESERVED.has(parts[0].toLowerCase())
|
||||||
|
? parts[0]
|
||||||
|
: null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user