ListenBrainz accounts are MusicBrainz accounts, so its sign-up link goes to MusicBrainz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
4.4 KiB
TypeScript
132 lines
4.4 KiB
TypeScript
/** Services a listener can open a song in. */
|
|
export const STREAMING_SERVICES = [
|
|
'spotify',
|
|
'apple_music',
|
|
'youtube_music',
|
|
'deezer',
|
|
'tidal',
|
|
] as const;
|
|
export type StreamingService = (typeof STREAMING_SERVICES)[number];
|
|
|
|
/** Sources we can read "now playing" from. */
|
|
export const NOW_PLAYING_PROVIDERS = [
|
|
'spotify',
|
|
'lastfm',
|
|
'listenbrainz',
|
|
'plex',
|
|
'jellyfin',
|
|
'apple_music',
|
|
'android_media_session',
|
|
] as const;
|
|
export type NowPlayingProvider = (typeof NOW_PLAYING_PROVIDERS)[number];
|
|
|
|
export const SERVICE_LABELS: Record<StreamingService | NowPlayingProvider, string> = {
|
|
spotify: 'Spotify',
|
|
apple_music: 'Apple Music',
|
|
youtube_music: 'YouTube Music',
|
|
deezer: 'Deezer',
|
|
tidal: 'TIDAL',
|
|
lastfm: 'Last.fm',
|
|
listenbrainz: 'ListenBrainz',
|
|
plex: 'Plex',
|
|
jellyfin: 'Jellyfin',
|
|
android_media_session: 'your phone',
|
|
};
|
|
|
|
/** Brand colours, for the badge shown next to a service's name. */
|
|
export const SERVICE_COLORS: Record<StreamingService | NowPlayingProvider, string> = {
|
|
spotify: '#1DB954',
|
|
apple_music: '#FA2D48',
|
|
youtube_music: '#FF0033',
|
|
deezer: '#A238FF',
|
|
tidal: '#000000',
|
|
lastfm: '#D51007',
|
|
listenbrainz: '#EB743B',
|
|
plex: '#E5A00D',
|
|
jellyfin: '#AA5CC3',
|
|
android_media_session: '#5b4cf0',
|
|
};
|
|
|
|
/**
|
|
* The services to offer for a song, main one first: the ones the viewer uses,
|
|
* or all of them if they haven't said.
|
|
*/
|
|
export function viewerServices(
|
|
viewer: { preferredService: StreamingService | null; services: StreamingService[] | null } | null | undefined,
|
|
): StreamingService[] {
|
|
const chosen = viewer?.services?.length ? viewer.services : STREAMING_SERVICES;
|
|
const main = viewer?.preferredService;
|
|
return main ? [main, ...chosen.filter((s) => s !== main)] : [...chosen];
|
|
}
|
|
|
|
/** Fallback when we have no direct link: a search on that service. */
|
|
export function searchUrl(service: StreamingService, title: string, artist: string): string {
|
|
const q = encodeURIComponent(`${artist} ${title}`);
|
|
switch (service) {
|
|
case 'spotify':
|
|
return `https://open.spotify.com/search/${q}`;
|
|
case 'apple_music':
|
|
return `https://music.apple.com/search?term=${q}`;
|
|
case 'youtube_music':
|
|
return `https://music.youtube.com/search?q=${q}`;
|
|
case 'deezer':
|
|
return `https://www.deezer.com/search/${q}`;
|
|
case 'tidal':
|
|
return `https://listen.tidal.com/search?q=${q}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The URL to open a track in the given service. All direct links are https
|
|
* universal/app links, so they open the native app when installed.
|
|
*/
|
|
export function openUrl(
|
|
track: { title: string; artist: string; links: Partial<Record<StreamingService, string>> },
|
|
service: StreamingService,
|
|
): { url: string; exact: boolean } {
|
|
const direct = track.links[service];
|
|
if (direct) return { url: direct, exact: true };
|
|
return { url: searchUrl(service, track.title, track.artist), exact: false };
|
|
}
|
|
|
|
/** Public profile page for scrobbling services (attribution links). */
|
|
export function sourceProfileUrl(provider: string, username: string): string | null {
|
|
const u = encodeURIComponent(username);
|
|
if (provider === 'lastfm') return `https://www.last.fm/user/${u}`;
|
|
if (provider === 'listenbrainz') return `https://listenbrainz.org/user/${u}/`;
|
|
return null;
|
|
}
|
|
|
|
/** Where users re-link Spotify on each scrobbling service. */
|
|
export const SPOTIFY_LINK_PAGES = {
|
|
lastfm: 'https://www.last.fm/settings/applications',
|
|
listenbrainz: 'https://listenbrainz.org/settings/music-services/details/',
|
|
} as const;
|
|
|
|
/**
|
|
* Where to make a free account (neither lets other apps create one). ListenBrainz accounts are
|
|
* MusicBrainz accounts: register there, then sign in to ListenBrainz once to start it.
|
|
*/
|
|
export const SIGNUP_PAGES = {
|
|
lastfm: 'https://www.last.fm/join',
|
|
listenbrainz: 'https://musicbrainz.org/register',
|
|
} as const;
|
|
|
|
const QUIET_AFTER_MS = 7 * 86_400_000;
|
|
|
|
/**
|
|
* A scrobbler that used to report plays but has gone silent for a week. Usually
|
|
* Spotify's link to it expired (Spotify makes users re-approve every 6 months).
|
|
*/
|
|
export function isQuietScrobbler(
|
|
c: { provider: string; status: string; lastActivityAt: string | null },
|
|
now = Date.now(),
|
|
): c is { provider: 'lastfm' | 'listenbrainz'; status: string; lastActivityAt: string } {
|
|
return (
|
|
(c.provider === 'lastfm' || c.provider === 'listenbrainz') &&
|
|
c.status === 'active' &&
|
|
c.lastActivityAt !== null &&
|
|
now - new Date(c.lastActivityAt).getTime() > QUIET_AFTER_MS
|
|
);
|
|
}
|