From 691fb000727bedca4092178a5fd01e01a0c651d7 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Wed, 19 Aug 2026 18:00:49 -0700 Subject: [PATCH] fix(track): honor is_streamable so inactive artists' tracks don't render The API has always reported `is_streamable: false` for tracks whose owner is no longer active - either the artist deactivated their own account or the account was delisted by the trusted notifier - but the shared adapter listed the field in its omit list, so it was stripped before reaching web or mobile. With no signal, the track page rendered and played normally, and SSR served the track's title and artwork to crawlers and social unfurls. Stop dropping the field, add it to TrackMetadata, and gate the track page on it behind a shared `isTrackUnavailable` helper. Deleted tracks are excluded so they keep their existing "deleted by artist" treatment. The copy deliberately says nothing about the account: the same flag covers a self deactivation and a delisted account, and we shouldn't tell users an artist deleted their account when moderation suppressed it. Reported by Marcus for audius.co/rehoxx/just-for-tonight-wmellark-hoonds. Co-Authored-By: Claude Opus 5 --- packages/common/src/adapters/track.ts | 3 +- packages/common/src/models/Track.ts | 8 ++ packages/common/src/utils/index.ts | 1 + .../common/src/utils/trackAvailability.ts | 23 +++++ .../track-unavailable/TrackUnavailable.tsx | 56 ++++++++++++ .../src/screens/track-screen/TrackScreen.tsx | 16 +++- .../components/desktop/TrackPage.tsx | 10 ++- .../components/mobile/TrackPage.tsx | 10 ++- .../ServerUnavailableTrack.tsx | 48 ++++++++++ .../UnavailableTrackPage.module.css | 9 ++ .../UnavailableTrackPage.tsx | 87 +++++++++++++++++++ packages/web/src/ssr/metaTags.ts | 16 ++++ packages/web/src/ssr/track/+onRenderHtml.tsx | 28 ++++-- 13 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 packages/common/src/utils/trackAvailability.ts create mode 100644 packages/mobile/src/components/track-unavailable/TrackUnavailable.tsx create mode 100644 packages/web/src/pages/unavailable-track-page/ServerUnavailableTrack.tsx create mode 100644 packages/web/src/pages/unavailable-track-page/UnavailableTrackPage.module.css create mode 100644 packages/web/src/pages/unavailable-track-page/UnavailableTrackPage.tsx diff --git a/packages/common/src/adapters/track.ts b/packages/common/src/adapters/track.ts index 6043907ceb6..8fb17bca055 100644 --- a/packages/common/src/adapters/track.ts +++ b/packages/common/src/adapters/track.ts @@ -124,8 +124,7 @@ export const userTrackMetadataFromSDK = ( 'id', 'user_id', 'followee_favorites', - 'favorite_count', - 'is_streamable' + 'favorite_count' ]), // Conversions diff --git a/packages/common/src/models/Track.ts b/packages/common/src/models/Track.ts index b5103982ea3..77d3f789727 100644 --- a/packages/common/src/models/Track.ts +++ b/packages/common/src/models/Track.ts @@ -193,6 +193,14 @@ export type TrackMetadata = { is_scheduled_release: boolean is_unlisted: boolean is_available: boolean + /** + * Whether the API will serve audio for this track. The API sets it to false + * when the track is deleted or its owner is no longer active (a self + * deactivation or a trusted-notifier delist). Optional because not every + * track source populates it, so treat `undefined` as "no opinion" rather + * than as "not streamable". + */ + is_streamable?: boolean is_stream_gated: boolean stream_conditions: Nullable is_download_gated: boolean diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 6e6a3f7af0d..50ab9d84c28 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -10,6 +10,7 @@ export * from './performance' export * from './reducer' export * from './selectorHelpers' export * from './timeUtil' +export * from './trackAvailability' export * from './trackCollaboration' export * from './timingUtils' export * from './typeUtils' diff --git a/packages/common/src/utils/trackAvailability.ts b/packages/common/src/utils/trackAvailability.ts new file mode 100644 index 00000000000..2c31a9e0aa1 --- /dev/null +++ b/packages/common/src/utils/trackAvailability.ts @@ -0,0 +1,23 @@ +import type { Track, TrackMetadata } from '~/models/Track' + +type MaybeTrack = Pick & + Partial> + +/** + * Whether a track should be shown as no longer available. + * + * The API reports this via `is_streamable`, which it sets to false when the + * track is deleted or its owner is no longer active - either because the + * artist deactivated their own account or because the account was delisted by + * the trusted notifier. Deleted tracks are excluded here because they have + * their own, more specific "deleted by artist" treatment. + * + * The check is an explicit `=== false` on purpose: not every track source + * populates `is_streamable`, and an absent field must not be read as + * unavailable. + */ +export const isTrackUnavailable = (track: MaybeTrack | null | undefined) => + !!track && + track.is_streamable === false && + !track.is_delete && + !track._marked_deleted diff --git a/packages/mobile/src/components/track-unavailable/TrackUnavailable.tsx b/packages/mobile/src/components/track-unavailable/TrackUnavailable.tsx new file mode 100644 index 00000000000..c9abb01fbdd --- /dev/null +++ b/packages/mobile/src/components/track-unavailable/TrackUnavailable.tsx @@ -0,0 +1,56 @@ +import { useCallback } from 'react' + +import { route } from '@audius/common/utils' +import { useLinkTo } from '@react-navigation/native' + +import { Button, Flex, IconArrowRight, Text } from '@audius/harmony-native' + +const { FEED_PAGE } = route + +const messages = { + heading: 'This Track Isn’t Available', + description: 'This track can no longer be streamed on Audius.', + buttonText: 'Take Me Back To The Music' +} + +/** + * Shown in place of a track screen the API reports as non-streamable - today + * that means the owner is no longer active. Says nothing about the account, + * since the same flag covers a self deactivation and a delisted account. + */ +export const TrackUnavailable = () => { + const linkTo = useLinkTo() + + const handlePress = useCallback(() => { + linkTo(FEED_PAGE) + }, [linkTo]) + + return ( + + + + {messages.heading} + + + {messages.description} + + + + + ) +} diff --git a/packages/mobile/src/screens/track-screen/TrackScreen.tsx b/packages/mobile/src/screens/track-screen/TrackScreen.tsx index 212e0caf397..c1dc97bc629 100644 --- a/packages/mobile/src/screens/track-screen/TrackScreen.tsx +++ b/packages/mobile/src/screens/track-screen/TrackScreen.tsx @@ -8,7 +8,7 @@ import { } from '@audius/common/api' import { Kind } from '@audius/common/models' import { reachabilitySelectors } from '@audius/common/store' -import { makeStableUid } from '@audius/common/utils' +import { isTrackUnavailable, makeStableUid } from '@audius/common/utils' import type { FlatList } from 'react-native' import { useSelector } from 'react-redux' @@ -21,6 +21,7 @@ import { } from 'app/components/core' import { ScreenPrimaryContent } from 'app/components/core/Screen/ScreenPrimaryContent' import { ScreenSecondaryContent } from 'app/components/core/Screen/ScreenSecondaryContent' +import { TrackUnavailable } from 'app/components/track-unavailable/TrackUnavailable' import { useRoute } from 'app/hooks/useRoute' import { TrackContestsSection } from './TrackContestsSection' @@ -66,6 +67,19 @@ export const TrackScreen = () => { const { track_id, permalink, comments_disabled } = track + // The API reports tracks whose owner is no longer active as non-streamable. + // Honor that instead of rendering a playable track screen. Deleted tracks + // are excluded by the helper and keep their existing DeletedTile treatment. + if (isTrackUnavailable(track)) { + return ( + + + + + + ) + } + return ( diff --git a/packages/web/src/pages/track-page/components/desktop/TrackPage.tsx b/packages/web/src/pages/track-page/components/desktop/TrackPage.tsx index c4600386b56..1f7c643d98b 100644 --- a/packages/web/src/pages/track-page/components/desktop/TrackPage.tsx +++ b/packages/web/src/pages/track-page/components/desktop/TrackPage.tsx @@ -27,7 +27,7 @@ import { playbackActions } from '@audius/common/store' import type { PlaybackTrack } from '@audius/common/store' -import { formatDate, route } from '@audius/common/utils' +import { formatDate, isTrackUnavailable, route } from '@audius/common/utils' import { Box, Flex } from '@audius/harmony' import { Id } from '@audius/sdk' import { useDispatch, useSelector } from 'react-redux' @@ -44,6 +44,7 @@ import { EmptyStatBanner } from 'components/stat-banner/StatBanner' import { GiantTrackTile } from 'components/track/GiantTrackTile' import DeletedPage from 'pages/deleted-page/DeletedPage' import { getTrackDefaults, emptyStringGuard } from 'pages/track-page/utils' +import { UnavailableTrackPage } from 'pages/unavailable-track-page/UnavailableTrackPage' import { getTrackPageContext } from 'ssr/metaTags' import { parseTrackRoute } from 'utils/route/trackRouteParser' @@ -262,6 +263,13 @@ const TrackPage = () => { ) } + // The API reports tracks whose owner is no longer active as non-streamable. + // Honor that instead of rendering a playable track page. Checked after the + // deleted case above, which has its own more specific treatment. + if (isTrackUnavailable(track)) { + return + } + const renderGiantTrackTile = () => ( { ) } + // The API reports tracks whose owner is no longer active as non-streamable. + // Honor that instead of rendering a playable track page. Checked after the + // deleted case above, which has its own more specific treatment. + if (isTrackUnavailable(track)) { + return + } + return ( { + return ( + + + + + {messages.heading} + + + {messages.description} + + + + + + ) +} diff --git a/packages/web/src/pages/unavailable-track-page/UnavailableTrackPage.module.css b/packages/web/src/pages/unavailable-track-page/UnavailableTrackPage.module.css new file mode 100644 index 00000000000..27044c2cea9 --- /dev/null +++ b/packages/web/src/pages/unavailable-track-page/UnavailableTrackPage.module.css @@ -0,0 +1,9 @@ +/** + * The message is the only content on the page, so stretch the container to the + * full scroll area and let the message's `flex: 1` center it rather than + * pinning it to the top of the viewport. + */ +.container { + flex-direction: column; + min-height: 100%; +} diff --git a/packages/web/src/pages/unavailable-track-page/UnavailableTrackPage.tsx b/packages/web/src/pages/unavailable-track-page/UnavailableTrackPage.tsx new file mode 100644 index 00000000000..00bef97aea8 --- /dev/null +++ b/packages/web/src/pages/unavailable-track-page/UnavailableTrackPage.tsx @@ -0,0 +1,87 @@ +import { route } from '@audius/common/utils' +import { Button, Flex, IconArrowRight, Text } from '@audius/harmony' +import { Link } from 'react-router' + +import MobilePageContainer from 'components/mobile-page-container/MobilePageContainer' +import Page from 'components/page/Page' +import { useIsMobile } from 'hooks/useIsMobile' + +import styles from './UnavailableTrackPage.module.css' + +const { HOME_PAGE } = route + +const messages = { + title: 'Track Unavailable', + heading: 'This Track Isn’t Available', + description: 'This track can no longer be streamed on Audius.', + buttonText: 'Take Me Back To The Music' +} + +const UnavailableTrackContent = ({ isMobile }: { isMobile: boolean }) => ( + + + + + {messages.heading} + + + {messages.description} + + + + + +) + +/** + * Shown in place of a track page the API reports as non-streamable - today + * that means the owner is no longer active. Deliberately says nothing about + * the account, since the same flag covers an artist deactivating their own + * account and an account being delisted, and emits none of the track's own + * metadata or artwork. Marked noIndex so it stays out of search results. + */ +export const UnavailableTrackPage = () => { + const isMobile = useIsMobile() + + if (isMobile) { + return ( + + + + ) + } + + return ( + + + + ) +} + +export default UnavailableTrackPage diff --git a/packages/web/src/ssr/metaTags.ts b/packages/web/src/ssr/metaTags.ts index cbdd0293df5..cd760a7b551 100644 --- a/packages/web/src/ssr/metaTags.ts +++ b/packages/web/src/ssr/metaTags.ts @@ -162,6 +162,22 @@ export const getDefaultContext = () => { } } +/** + * Meta tag context for a track the API reports as non-streamable (its owner is + * no longer active). Deliberately generic - none of the track's or the + * account's title, artwork, or canonical URL - and always paired with + * `noIndex` at the call site so these pages stay out of search results and + * social unfurls. + */ +export const getUnavailableTrackContext = () => ({ + title: 'Track Unavailable', + description: 'This track can no longer be streamed on Audius.', + ogDescription: 'This track can no longer be streamed on Audius.', + image: DEFAULT_IMAGE_URL, + imageAlt: 'The Audius Platform', + thumbnail: true +}) + /** * Upload page meta tag context */ diff --git a/packages/web/src/ssr/track/+onRenderHtml.tsx b/packages/web/src/ssr/track/+onRenderHtml.tsx index 29c50ed9982..e1502221d4a 100644 --- a/packages/web/src/ssr/track/+onRenderHtml.tsx +++ b/packages/web/src/ssr/track/+onRenderHtml.tsx @@ -11,12 +11,14 @@ import { ServerWebPlayer } from 'app/web-player/ServerWebPlayer' import { MetaTags } from 'components/meta-tags/MetaTags' import { DesktopServerTrackPage } from 'pages/track-page/DesktopServerTrackPage' import { MobileServerTrackPage } from 'pages/track-page/MobileServerTrackPage' +import { ServerUnavailableTrack } from 'pages/unavailable-track-page/ServerUnavailableTrack' import { canEmbed, DEFAULT_IMAGE_URL, getAppUrl, getEmbedUrl, getTrackPageContext, + getUnavailableTrackContext, getWebUrl, isDiscord } from 'ssr/metaTags' @@ -50,9 +52,15 @@ export default function render(pageContext: TrackPageContext) { const userAgent = headers?.['user-agent'] ?? '' const isMobile = isMobileUserAgent(userAgent) + // The API reports tracks whose owner is no longer active as non-streamable. + // Serve none of their metadata: no title, artwork, embed player, or index. + // Explicit `=== false` because the field is absent on older API responses. + const isUnavailable = track?.is_streamable === false + // Check if this request can show an embed player (Twitter/Discord bots) // Don't show embed for comment pages - const shouldEmbed = canEmbed(userAgent) && !is_stream_gated && !commentData + const shouldEmbed = + canEmbed(userAgent) && !is_stream_gated && !commentData && !isUnavailable // Create a fresh cache instance for this SSR request // This ensures the theme context is properly connected @@ -62,7 +70,9 @@ export default function render(pageContext: TrackPageContext) { // Build meta tags - use comment-specific if we have comment data let seoMetadata - if (commentData) { + if (isUnavailable) { + seoMetadata = getUnavailableTrackContext() + } else if (commentData) { const trackName = commentData.track.title const artistName = commentData.track.user.name const commenterName = commentData.commenter.name @@ -99,9 +109,10 @@ export default function render(pageContext: TrackPageContext) { // Discord uses a weird aspect ratio for OG unfurls, so serve artwork // directly instead of the custom OG image const discordBot = isDiscord(userAgent) - const discordImageOverride = discordBot - ? (track?.artwork?.['1000x1000'] ?? DEFAULT_IMAGE_URL) - : undefined + const discordImageOverride = + discordBot && !isUnavailable + ? (track?.artwork?.['1000x1000'] ?? DEFAULT_IMAGE_URL) + : undefined const pageHtml = renderToString( @@ -120,9 +131,12 @@ export default function render(pageContext: TrackPageContext) { embedUrl={embedUrl} appUrl={appUrl} webUrl={webUrl} - thumbnail={false} + thumbnail={isUnavailable} + noIndex={isUnavailable} /> - {isMobile ? ( + {isUnavailable ? ( + + ) : isMobile ? ( ) : (