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 ? ( ) : (