Files
musicshare/packages/shared/src/services.ts
T
chrisandClaude Opus 5.5 2b02aa8e89
CI / check (push) Canceled after 0s
Profile pictures, display name default, coloured service names
- Upload a profile picture on web and mobile. The API square-crops it to
  512px WebP, strips metadata (phone photos carry GPS), stores it in
  Postgres and serves it with immutable caching. Avatar URLs are stored as
  paths, so they survive moving the API. Arbitrary avatar URLs are no
  longer accepted (they'd let a profile track its viewers).
- New accounts get the username as typed as their display name; existing
  accounts are backfilled, and clearing it falls back to the username.
- Service names in Settings get a brand-coloured badge and larger type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-05 23:38:00 -04:00

111 lines
3.6 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',
};
/** 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;
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
);
}