Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/common/src/adapters/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,7 @@ export const userTrackMetadataFromSDK = (
'id',
'user_id',
'followee_favorites',
'favorite_count',
'is_streamable'
'favorite_count'
]),

// Conversions
Expand Down
8 changes: 8 additions & 0 deletions packages/common/src/models/Track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccessConditions>
is_download_gated: boolean
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
23 changes: 23 additions & 0 deletions packages/common/src/utils/trackAvailability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Track, TrackMetadata } from '~/models/Track'

type MaybeTrack = Pick<TrackMetadata, 'is_delete' | 'is_streamable'> &
Partial<Pick<Track, '_marked_deleted'>>

/**
* 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
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
flex={1}
column
alignItems='center'
justifyContent='center'
gap='xl'
ph='l'
pv='2xl'
>
<Flex column alignItems='center' gap='s'>
<Text variant='heading' size='s' textAlign='center'>
{messages.heading}
</Text>
<Text variant='body' size='l' color='subdued' textAlign='center'>
{messages.description}
</Text>
</Flex>
<Button
variant='primary'
fullWidth
iconRight={IconArrowRight}
onPress={handlePress}
>
{messages.buttonText}
</Button>
</Flex>
)
}
16 changes: 15 additions & 1 deletion packages/mobile/src/screens/track-screen/TrackScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'
Expand Down Expand Up @@ -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 (
<Screen url={permalink}>
<ScreenContent>
<TrackUnavailable />
</ScreenContent>
</Screen>
)
}

return (
<Screen url={permalink}>
<ScreenContent isOfflineCapable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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 <UnavailableTrackPage />
}

const renderGiantTrackTile = () => (
<GiantTrackTile
loading={loading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
RepostType
} 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 { Flex } from '@audius/harmony'
import { Id } from '@audius/sdk'
import { useDispatch, useSelector } from 'react-redux'
Expand All @@ -47,6 +47,7 @@ import NavContext, {
} from 'components/nav/mobile/NavContext'
import DeletedPage from 'pages/deleted-page/DeletedPage'
import { getTrackDefaults } from 'pages/track-page/utils'
import { UnavailableTrackPage } from 'pages/unavailable-track-page/UnavailableTrackPage'
import { getTrackPageContext } from 'ssr/metaTags'
import { parseTrackRoute } from 'utils/route/trackRouteParser'

Expand Down Expand Up @@ -276,6 +277,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 <UnavailableTrackPage />
}

return (
<MobilePageContainer
title={seoFields.title ?? ''}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import IconArrowRight from '@audius/harmony/src/assets/icons/ArrowRight.svg'
import { Button } from '@audius/harmony/src/components/button/Button/Button'
import { Flex } from '@audius/harmony/src/components/layout/Flex'
import { Text } from '@audius/harmony/src/components/text'
import { Link } from 'react-router'

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'
}

/**
* Server-rendered twin of the unavailable-track message. Kept separate, and on
* deep harmony imports, so the SSR worker bundle doesn't pull in the client
* barrels - matching the other Server* page components.
*/
export const ServerUnavailableTrack = () => {
return (
<Flex
w='100%'
direction='column'
alignItems='center'
justifyContent='center'
p='xl'
css={{ minHeight: 'clamp(240px, 60vh, 640px)' }}
>
<Flex
direction='column'
alignItems='center'
gap='xl'
css={{ maxWidth: 400 }}
>
<Flex direction='column' alignItems='center' gap='s'>
<Text variant='heading' size='m' textAlign='center'>
{messages.heading}
</Text>
<Text variant='body' size='l' color='subdued' textAlign='center'>
{messages.description}
</Text>
</Flex>
<Button variant='primary' asChild iconRight={IconArrowRight}>
<Link to='/'>{messages.buttonText}</Link>
</Button>
</Flex>
</Flex>
)
}
Original file line number Diff line number Diff line change
@@ -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%;
}
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Flex
w='100%'
column
alignItems='center'
justifyContent='center'
p='xl'
// On mobile web the page container is stretched to the viewport, so
// `flex: 1` claims everything below the header. On desktop the page is
// sized by its content, so fall back to a fixed minimum.
flex={isMobile ? 1 : undefined}
css={{
minHeight: isMobile ? undefined : 'clamp(240px, 32vh, 420px)',
userSelect: 'none'
}}
>
<Flex column alignItems='center' gap='xl' css={{ maxWidth: 400 }}>
<Flex column alignItems='center' gap='s'>
<Text variant='heading' size='m' textAlign='center'>
{messages.heading}
</Text>
<Text variant='body' size='l' color='subdued' textAlign='center'>
{messages.description}
</Text>
</Flex>
<Button
variant='primary'
fullWidth={isMobile}
asChild
iconRight={IconArrowRight}
>
<Link to={HOME_PAGE}>{messages.buttonText}</Link>
</Button>
</Flex>
</Flex>
)

/**
* 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 (
<MobilePageContainer
title={messages.title}
description={messages.description}
containerClassName={styles.container}
noIndex
>
<UnavailableTrackContent isMobile />
</MobilePageContainer>
)
}

return (
<Page title={messages.title} description={messages.description} noIndex>
<UnavailableTrackContent isMobile={false} />
</Page>
)
}

export default UnavailableTrackPage
16 changes: 16 additions & 0 deletions packages/web/src/ssr/metaTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading
Loading