diff --git a/.env.example b/.env.example index 7b02228..14ba3fb 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,11 @@ HARMONY_QOBUZ_APP_ID= # Bugs! app config. HARMONY_BUGS_CLIENT_SECRET= + +# Apple Music catalog API (optional). Official developer token is preferred. +# See https://developer.apple.com/documentation/applemusicapi/generating-developer-tokens +HARMONY_APPLE_MUSIC_TOKEN= +# Unofficial AMP API token for self-hosted Harmony (used if HARMONY_APPLE_MUSIC_TOKEN is unset). +HARMONY_APPLE_MUSIC_AMP_TOKEN= +# Scrape a MusicKit JWT from music.apple.com when no token is set (self-hosted only). Set true or false. +HARMONY_APPLE_MUSIC_SCRAPE= diff --git a/providers/AppleMusic/catalog.test.ts b/providers/AppleMusic/catalog.test.ts new file mode 100644 index 0000000..c7aea1b --- /dev/null +++ b/providers/AppleMusic/catalog.test.ts @@ -0,0 +1,79 @@ +import { assertEquals } from 'std/assert/assert_equals.ts'; +import { describe, it } from '@std/testing/bdd'; +import { + catalogArtworkUrl, + collectCatalogTracks, + extractAppleMusicJwt, + extractScriptUrls, + parseJwtExpiry, + resolveCatalogUrl, +} from './catalog.ts'; + +function makeJwt(exp: number): string { + const encode = (value: object) => + btoa(JSON.stringify(value)).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); + return `${encode({ alg: 'ES256', typ: 'JWT' })}.${encode({ exp })}.sig`; +} + +describe('Apple Music catalog helpers', () => { + it('extracts the JWT with the latest expiry', () => { + const older = makeJwt(1_700_000_000); + const newer = makeJwt(2_000_000_000); + const source = `const a="${older}"; const b='${newer}';`; + assertEquals(extractAppleMusicJwt(source), newer); + assertEquals(parseJwtExpiry(newer), 2_000_000_000 * 1000); + }); + + it('extracts crossorigin and musickit script URLs', () => { + const html = ` + + + + `; + assertEquals(extractScriptUrls(html), [ + 'https://js-cdn.music.apple.com/musickit/v3/musickit.js', + 'https://music.apple.com/assets/index.js', + ]); + }); + + it('hydrates track stubs from included resources', () => { + const tracks = collectCatalogTracks({ + data: [{ + id: '1', + type: 'albums', + attributes: { name: 'Mix', artistName: 'DJ' }, + relationships: { + tracks: { data: [{ id: 't1', type: 'songs' }], next: '/v1/next' }, + }, + }], + included: [{ + id: 't1', + type: 'songs', + attributes: { name: 'Track One', artistName: 'Artist', trackNumber: 1, discNumber: 1 }, + }], + }, { + id: '1', + type: 'albums', + attributes: { name: 'Mix', artistName: 'DJ' }, + relationships: { + tracks: { data: [{ id: 't1', type: 'songs' }] }, + }, + }); + assertEquals(tracks[0].attributes?.name, 'Track One'); + }); + + it('resolves relative catalog pagination URLs', () => { + const next = resolveCatalogUrl( + '/v1/catalog/au/albums/1/tracks?offset=10', + 'https://api.music.apple.com', + ); + assertEquals(next.href, 'https://api.music.apple.com/v1/catalog/au/albums/1/tracks?offset=10'); + }); + + it('fills artwork template dimensions', () => { + assertEquals( + catalogArtworkUrl({ url: 'https://example.com/{w}x{h}bb.jpg', width: 3000, height: 3000 }, 250), + 'https://example.com/250x250bb.jpg', + ); + }); +}); diff --git a/providers/AppleMusic/catalog.ts b/providers/AppleMusic/catalog.ts new file mode 100644 index 0000000..30a0a70 --- /dev/null +++ b/providers/AppleMusic/catalog.ts @@ -0,0 +1,96 @@ +// Helpers for the official Apple Music API and unofficial AMP catalog. +// music.apple.com is only fetched when scraping a MusicKit JWT; album metadata comes from the catalog API. +import { decodeBase64 } from 'std/encoding/base64.ts'; +import type { CatalogAlbum, CatalogArtist, CatalogDocument, CatalogTrack } from './catalog_types.ts'; + +// JWT as embedded in Apple Music / MusicKit assets. +const jwtPattern = /["'](eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)["']/g; + +// Returns the JWT with the latest expiry when a page or script embeds more than one token. +export function extractAppleMusicJwt(source: string): string | undefined { + const matches = source.matchAll(jwtPattern); + let best: string | undefined; + let bestExpiry = 0; + for (const match of matches) { + const token = match[1]; + const expiry = parseJwtExpiry(token) ?? 0; + if (expiry > bestExpiry) { + best = token; + bestExpiry = expiry; + } + } + return best; +} + +export function parseJwtExpiry(token: string): number | undefined { + try { + const payloadPart = token.split('.')[1]; + if (!payloadPart) return undefined; + const padded = payloadPart.replace(/-/g, '+').replace(/_/g, '/') + + '='.repeat((4 - (payloadPart.length % 4)) % 4); + const payload = JSON.parse(new TextDecoder().decode(decodeBase64(padded))) as { exp?: number }; + return typeof payload.exp === 'number' ? payload.exp * 1000 : undefined; + } catch { + return undefined; + } +} + +// Script URLs that typically contain the MusicKit developer token. +export function extractScriptUrls(html: string): string[] { + const urls: string[] = []; + const seen = new Set(); + const patterns = [ + /]*\bcrossorigin\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi, + /]*\bsrc=["']([^"']+)["'][^>]*\bcrossorigin\b[^>]*>/gi, + /]*\bsrc=["']([^"']*musickit[^"']*)["'][^>]*>/gi, + ]; + for (const pattern of patterns) { + for (const match of html.matchAll(pattern)) { + const src = match[1]; + if (!seen.has(src)) { + seen.add(src); + urls.push(src); + } + } + } + return urls; +} + +// Catalog pagination `next` is often a path (`/v1/catalog/...`), not an absolute URL. +export function resolveCatalogUrl(next: string, apiBaseUrl: string): URL { + if (next.startsWith('http://') || next.startsWith('https://')) { + return new URL(next); + } + return new URL(next, apiBaseUrl); +} + +export function isCatalogTrack( + resource: CatalogAlbum | CatalogTrack | CatalogArtist, +): resource is CatalogTrack { + return resource.type === 'songs' || resource.type === 'music-videos'; +} + +// Collects tracks from a catalog page. +// Relationships may only list IDs; full objects (name, ISRC, duration) are often in `included`. +export function collectCatalogTracks(body: CatalogDocument, album?: CatalogAlbum): CatalogTrack[] { + const includedTracks = (body.included ?? []).filter(isCatalogTrack); + const byId = new Map(includedTracks.map((track) => [track.id, track] as const)); + const hydrate = (partial: CatalogTrack[]) => + partial.map((track) => track.attributes ? track : (byId.get(track.id) ?? track)); + + if (album) { + return hydrate(album.relationships?.tracks?.data ?? []); + } + return hydrate((body.data ?? []).filter(isCatalogTrack)); +} + +// Artwork URLs use `{w}` / `{h}` placeholders; fill them with the requested or native size. +export function catalogArtworkUrl( + artwork?: { url: string; width?: number; height?: number }, + size?: number, +): string | undefined { + if (!artwork?.url) return undefined; + const width = size ?? artwork.width ?? 3000; + const height = size ?? artwork.height ?? 3000; + return artwork.url.replace('{w}', String(width)).replace('{h}', String(height)); +} diff --git a/providers/AppleMusic/catalog_types.ts b/providers/AppleMusic/catalog_types.ts new file mode 100644 index 0000000..21a4e13 --- /dev/null +++ b/providers/AppleMusic/catalog_types.ts @@ -0,0 +1,60 @@ +// Types shared by the official Apple Music API and AMP +// (https://api.music.apple.com/v1/catalog/... and https://amp-api.music.apple.com/v1/catalog/...). + +export type CatalogResourceType = 'albums' | 'songs' | 'music-videos' | 'artists'; + +export type CatalogArtwork = { + url: string; + width?: number; + height?: number; +}; + +export type CatalogAlbumAttributes = { + name: string; + artistName: string; + upc?: string; + releaseDate?: string; + copyright?: string; + recordLabel?: string; + trackCount?: number; + isComplete?: boolean; + url?: string; + artwork?: CatalogArtwork; +}; + +export type CatalogTrackAttributes = { + name: string; + artistName: string; + durationInMillis?: number; + trackNumber?: number; + discNumber?: number; + isrc?: string; + url?: string; +}; + +export type CatalogResource = { + id: string; + type: Type; + attributes?: Attributes; + relationships?: { + tracks?: CatalogRelationship; + artists?: CatalogRelationship; + }; +}; + +export type CatalogAlbum = CatalogResource<'albums', CatalogAlbumAttributes>; +export type CatalogTrack = CatalogResource<'songs' | 'music-videos', CatalogTrackAttributes>; +export type CatalogArtist = CatalogResource<'artists', { name: string; url?: string }>; + +export type CatalogRelationship = { + data?: T[]; + next?: string; + href?: string; +}; + +export type CatalogDocument = { + data?: Array; + included?: Array; + next?: string; + errors?: Array<{ title?: string; detail?: string; status?: string }>; +}; diff --git a/providers/AppleMusic/mod.test.ts b/providers/AppleMusic/mod.test.ts new file mode 100644 index 0000000..e02124e --- /dev/null +++ b/providers/AppleMusic/mod.test.ts @@ -0,0 +1,56 @@ +import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts'; +import { stubProviderLookups, stubTokenRetrieval } from '@/providers/test_stubs.ts'; +import { afterAll, describe } from '@std/testing/bdd'; + +import AppleMusicProvider from './mod.ts'; + +describe('Apple Music provider', () => { + const appleMusic = new AppleMusicProvider(makeProviderOptions()); + const stubs = [stubProviderLookups(appleMusic), stubTokenRetrieval(appleMusic)]; + + describeProvider(appleMusic, { + urls: [{ + description: 'Apple Music album URL', + url: new URL('https://music.apple.com/de/album/1705742568'), + id: { type: 'album', id: '1705742568', region: 'DE' }, + isCanonical: true, + }, { + description: 'Apple Music album URL with implicit region', + url: new URL('https://music.apple.com/album/1705742568'), + id: { type: 'album', id: '1705742568', region: 'US' }, + }, { + description: 'Apple Music album URL with slug', + url: new URL('https://music.apple.com/de/album/all-will-be-changed/1705742568'), + id: { type: 'album', id: '1705742568', region: 'DE', slug: 'all-will-be-changed' }, + }, { + description: 'Apple Music artist URL', + url: new URL('https://music.apple.com/gb/artist/136975'), + id: { type: 'artist', id: '136975', region: 'GB' }, + isCanonical: true, + }, { + description: 'Apple Music song URL', + url: new URL('https://music.apple.com/gb/song/1772318408'), + id: { type: 'song', id: '1772318408', region: 'GB' }, + isCanonical: true, + }, { + description: 'Apple Music video URL', + url: new URL('https://music.apple.com/gb/music-video/1441458100'), + id: { type: 'music-video', id: '1441458100', region: 'GB' }, + isCanonical: true, + }, { + description: 'Apple Music geo. album URL', + url: new URL('https://geo.music.apple.com/album/1135913516'), + id: { type: 'album', id: '1135913516', region: 'US' }, + }, { + description: 'iTunes album URL (handled by iTunes provider)', + url: new URL('https://itunes.apple.com/gb/album/id1722294645'), + id: undefined, + }], + invalidIds: ['text'], + releaseLookup: [], + }); + + afterAll(() => { + stubs.forEach((stub) => stub.restore()); + }); +}); diff --git a/providers/AppleMusic/mod.ts b/providers/AppleMusic/mod.ts new file mode 100644 index 0000000..c03b847 --- /dev/null +++ b/providers/AppleMusic/mod.ts @@ -0,0 +1,414 @@ +import { availableRegions } from '../iTunes/regions.ts'; +import { + type ApiAccessToken, + type ApiQueryOptions, + type CacheEntry, + MetadataApiProvider, + ReleaseApiLookup, +} from '@/providers/base.ts'; +import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts'; +import { parseISODateTime, PartialDate } from '@/utils/date.ts'; +import { getBooleanFromEnv, getFromEnv } from '@/utils/config.ts'; +import { ProviderError } from '@/utils/errors.ts'; +import { isValidGTIN } from '@/utils/gtin.ts'; +import { + catalogArtworkUrl, + collectCatalogTracks, + extractAppleMusicJwt, + extractScriptUrls, + parseJwtExpiry, + resolveCatalogUrl, +} from './catalog.ts'; + +import type { CatalogAlbum, CatalogDocument, CatalogTrack } from './catalog_types.ts'; +import type { + ArtistCreditName, + Artwork, + ArtworkType, + CountryCode, + EntityId, + HarmonyMedium, + HarmonyRelease, + LinkType, + ReleaseGroupType, +} from '@/harmonizer/types.ts'; +import type { ProviderCategory } from '@/providers/categories.ts'; + +// Official catalog: https://developer.apple.com/documentation/applemusicapi +const officialApiBaseUrl = 'https://api.music.apple.com'; +// Unofficial web catalog (AMP). Same JSON shape as the official API, used when no developer token is set. +const ampApiBaseUrl = 'https://amp-api.music.apple.com'; + +// Auth is one of: official JWT, AMP JWT, or scrape a MusicKit JWT from music.apple.com. +const officialToken = getFromEnv('HARMONY_APPLE_MUSIC_TOKEN') || ''; +const ampToken = getFromEnv('HARMONY_APPLE_MUSIC_AMP_TOKEN') || ''; +const scrapeToken = getBooleanFromEnv('HARMONY_APPLE_MUSIC_SCRAPE'); + +export type AppleMusicBackend = 'official' | 'amp'; + +export function isAppleMusicConfigured(): boolean { + return Boolean(officialToken || ampToken || scrapeToken); +} + +export function appleMusicBackend(): AppleMusicBackend { + return officialToken ? 'official' : 'amp'; +} + +export default class AppleMusicProvider extends MetadataApiProvider { + readonly name = 'Apple Music'; + + // music.apple.com only; itunes.apple.com stays on the iTunes Search provider. + readonly supportedUrls = new URLPattern({ + hostname: '{geo.}?music.apple.com', + pathname: String.raw`/:region(\w{2})?/:type(album|artist|song|music-video)/:slug?/{id}?:id(\d+)`, + }); + + override readonly categories = new Set(['digital']); + + override readonly features: FeatureQualityMap = { + 'cover size': 3000, + 'duration precision': DurationPrecision.MS, + 'GTIN lookup': FeatureQuality.GOOD, + 'MBID resolving': FeatureQuality.EXPENSIVE, + 'release label': FeatureQuality.PRESENT, + }; + + readonly entityTypeMap = { + artist: 'artist', + release: 'album', + recording: ['song', 'music-video'], + }; + + override readonly availableRegions = new Set(availableRegions); + + readonly releaseLookup = AppleMusicReleaseLookup; + + override readonly launchDate: PartialDate = { + year: 2015, + month: 6, + day: 30, + }; + + // Official API if HARMONY_APPLE_MUSIC_TOKEN is set, otherwise AMP. + readonly apiBaseUrl = officialToken ? officialApiBaseUrl : ampApiBaseUrl; + + readonly backend: AppleMusicBackend = appleMusicBackend(); + + readonly defaultRegion: CountryCode = 'US'; + + constructUrl(entity: EntityId): URL { + const region = entity.region ?? this.defaultRegion; + return new URL([region.toLowerCase(), entity.type, entity.id].join('/'), 'https://music.apple.com'); + } + + override extractEntityFromUrl(url: URL): EntityId | undefined { + const entity = super.extractEntityFromUrl(url); + if (entity && !entity.region) { + entity.region = this.defaultRegion; + } + return entity; + } + + override getLinkTypesForEntity(): LinkType[] { + return ['paid streaming']; + } + + async query(apiUrl: URL, options: ApiQueryOptions = {}): Promise> { + // Both backends require a Bearer JWT; AMP also needs Origin (set below). + const accessToken = await this.cachedAccessToken(() => this.requestAccessToken()); + const headers: Record = { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json', + }; + if (this.backend === 'amp') { + // AMP rejects requests that do not look like they come from the Apple Music web app. + headers['Origin'] = 'https://music.apple.com'; + } + return await this.fetchJSON(apiUrl, { + policy: { maxTimestamp: options.snapshotMaxTimestamp }, + offline: options.offline, + requestInit: { headers }, + }); + } + + constructAlbumApiUrl(albumId: string, region: CountryCode): URL { + const url = new URL(`v1/catalog/${region.toLowerCase()}/albums/${albumId}`, this.apiBaseUrl); + url.searchParams.set('include', 'tracks'); + return url; + } + + constructUpcSearchUrl(upc: string, region: CountryCode): URL { + const url = new URL(`v1/catalog/${region.toLowerCase()}/albums`, this.apiBaseUrl); + url.searchParams.set('filter[upc]', upc); + return url; + } + + // Loads a catalog album and follows `next` until the full tracklist is collected. + async fetchCatalogAlbum( + albumId: string, + region: CountryCode, + options: ApiQueryOptions = {}, + ): Promise<{ album: CatalogAlbum; tracks: CatalogTrack[]; timestamp: number }> { + let nextUrl: URL | undefined = this.constructAlbumApiUrl(albumId, region); + let album: CatalogAlbum | undefined; + const tracks: CatalogTrack[] = []; + let timestamp = 0; + + while (nextUrl) { + const cacheEntry: CacheEntry = await this.query(nextUrl, options); + timestamp = Math.max(timestamp, cacheEntry.timestamp); + const body: CatalogDocument = cacheEntry.content; + if (body.errors?.length) { + const detail = body.errors.map((error) => error.detail ?? error.title).join('; '); + throw new ProviderError(this.name, `Catalog API error: ${detail || 'unknown error'}`); + } + + if (!album) { + // First page is the album resource; later pages are additional tracks only. + const albumResource = (body.data ?? []).find((resource): resource is CatalogAlbum => + resource.type === 'albums' + ); + if (!albumResource) { + throw new ProviderError(this.name, 'Catalog API returned no album'); + } + album = albumResource; + tracks.push(...collectCatalogTracks(body, album)); + const next = album.relationships?.tracks?.next ?? body.next; + nextUrl = next ? resolveCatalogUrl(next, this.apiBaseUrl) : undefined; + } else { + tracks.push(...collectCatalogTracks(body)); + nextUrl = body.next ? resolveCatalogUrl(body.next, this.apiBaseUrl) : undefined; + } + } + + return { album: album!, tracks, timestamp }; + } + + private async requestAccessToken(): Promise { + if (officialToken) { + return this.tokenFromJwt(officialToken); + } + if (ampToken) { + return this.tokenFromJwt(ampToken); + } + return await this.scrapeAccessToken(); + } + + // AMP has no public client-credentials flow. The web app embeds a MusicKit JWT in + // music.apple.com HTML or JS; scrape that token (not the album DOM) and cache it until `exp`. + private async scrapeAccessToken(): Promise { + const pageUrl = new URL('https://music.apple.com'); + const page = await this.fetchSnapshot(pageUrl); + const html = await page.content.text(); + const fromHtml = extractAppleMusicJwt(html); + if (fromHtml) { + return this.tokenFromJwt(fromHtml); + } + + for (const scriptSrc of extractScriptUrls(html)) { + const scriptUrl = new URL(scriptSrc, pageUrl); + if (!scriptUrl.hostname.endsWith('apple.com') && !scriptUrl.hostname.endsWith('mzstatic.com')) { + continue; + } + try { + const script = await this.fetchSnapshot(scriptUrl); + const fromScript = extractAppleMusicJwt(await script.content.text()); + if (fromScript) { + return this.tokenFromJwt(fromScript); + } + } catch { + // Try the next candidate script. + } + } + + throw new ProviderError(this.name, 'Failed to extract MusicKit token from music.apple.com'); + } + + private tokenFromJwt(accessToken: string): ApiAccessToken { + return { + accessToken, + validUntilTimestamp: parseJwtExpiry(accessToken) ?? (Date.now() + 60 * 60 * 1000), + }; + } +} + +export class AppleMusicReleaseLookup extends ReleaseApiLookup { + private tracks: CatalogTrack[] = []; + + constructReleaseApiUrl(): URL { + const { method, value, region } = this.lookup; + const storefront = region ?? this.provider.defaultRegion; + if (method === 'gtin') { + return this.provider.constructUpcSearchUrl(value, storefront); + } + return this.provider.constructAlbumApiUrl(value, storefront); + } + + protected async getRawRelease(): Promise { + if (!this.options.regions?.size) { + this.options.regions = new Set([this.provider.defaultRegion]); + } + + // Catalog filter[upc] returns album IDs, not a full tracklist; fetch the album next. + if (this.lookup.method === 'gtin') { + const albumId = await this.queryAlbumIdByGtin(this.lookup.value); + if (!albumId) { + throw new ProviderError(this.provider.name, 'Catalog API returned no results for this GTIN'); + } + this.lookup.method = 'id'; + this.lookup.value = albumId; + } + + const region = this.lookup.region ?? this.provider.defaultRegion; + const { album, tracks, timestamp } = await this.provider.fetchCatalogAlbum( + this.lookup.value, + region, + { snapshotMaxTimestamp: this.options.snapshotMaxTimestamp }, + ); + this.updateCacheTime(timestamp); + this.tracks = tracks; + this.entity = { + id: album.id, + type: 'album', + region, + }; + return album; + } + + private async queryAlbumIdByGtin(gtin: string): Promise { + for (const region of this.options.regions || []) { + this.lookup.region = region; + const cacheEntry = await this.provider.query( + this.provider.constructUpcSearchUrl(gtin, region), + { snapshotMaxTimestamp: this.options.snapshotMaxTimestamp }, + ); + this.updateCacheTime(cacheEntry.timestamp); + const albums = (cacheEntry.content.data ?? []).filter((resource): resource is CatalogAlbum => + resource.type === 'albums' + ); + if (albums.length) { + if (albums.length > 1) { + this.warnMultipleResults( + albums.slice(1).map((album) => + this.provider.constructUrl({ + type: 'album', + id: album.id, + region, + }) + ), + ); + } + return albums[0].id; + } + } + return undefined; + } + + protected convertRawRelease(album: CatalogAlbum): HarmonyRelease { + const attributes = album.attributes; + if (!attributes) { + throw new ProviderError(this.provider.name, 'Album is missing attributes'); + } + + const { title, types } = this.getTypesFromTitle(attributes.name); + const gtin = attributes.upc && isValidGTIN(attributes.upc) ? attributes.upc : undefined; + const coverUrl = catalogArtworkUrl(attributes.artwork); + const thumbUrl = catalogArtworkUrl(attributes.artwork, 250); + + this.addMessage( + this.provider.backend === 'official' + ? 'Looked up via the official Apple Music API' + : 'Looked up via the Apple Music catalog (AMP) API', + ); + if (gtin) { + this.addMessage(`Catalog UPC ${gtin}`); + } + + const release: HarmonyRelease = { + title, + artists: [this.convertRawArtist(attributes.artistName)], + gtin, + externalLinks: [{ + url: this.provider.constructUrl(this.entity!).href, + types: ['paid streaming'], + }], + media: this.convertTracklist(this.tracks), + releaseDate: this.convertReleaseDate( + attributes.releaseDate ? parseISODateTime(attributes.releaseDate) : undefined, + ), + status: 'Official', + types, + packaging: 'None', + images: coverUrl ? [this.processImage(coverUrl, thumbUrl, ['front'])] : undefined, + copyright: attributes.copyright, + labels: attributes.recordLabel ? [{ name: attributes.recordLabel }] : undefined, + info: this.generateReleaseInfo(), + }; + + return release; + } + + private convertTracklist(tracklist: CatalogTrack[]): HarmonyMedium[] { + if (!tracklist.length) { + return []; + } + + const discCount = Math.max(1, ...tracklist.map((track) => track.attributes?.discNumber ?? 1)); + const media: HarmonyMedium[] = new Array(discCount).fill(null).map((_, index) => ({ + format: 'Digital Media', + number: index + 1, + tracklist: [], + })); + + for (const track of tracklist) { + const attributes = track.attributes; + if (!attributes) continue; + const discNumber = attributes.discNumber ?? 1; + const medium = media[discNumber - 1]; + if (!medium) continue; + + medium.tracklist.push({ + number: attributes.trackNumber, + title: attributes.name, + length: attributes.durationInMillis, + artists: [this.convertRawArtist(attributes.artistName)], + isrc: attributes.isrc, + type: track.type === 'music-videos' ? 'video' : undefined, + recording: { + externalIds: this.provider.makeExternalIds({ + type: track.type === 'music-videos' ? 'music-video' : 'song', + id: track.id, + region: this.lookup.region, + linkTypes: ['paid streaming'], + }), + }, + }); + } + + return media; + } + + private convertRawArtist(name: string, url?: string): ArtistCreditName { + const artistId = url ? this.provider.extractEntityFromUrl(new URL(url)) : undefined; + return { + name, + creditedName: name, + externalIds: artistId?.type === 'artist' ? this.provider.makeExternalIds(artistId) : undefined, + }; + } + + private processImage(url: string, thumbUrl: string | undefined, types?: ArtworkType[]): Artwork { + return { url, thumbUrl, types }; + } + + private getTypesFromTitle(title: string): { title: string; types: ReleaseGroupType[] } { + const re = /\s- (EP|Single)$/; + const match = title.match(re); + const types: ReleaseGroupType[] = []; + if (match) { + title = title.replace(re, ''); + types.push(match[1] as ReleaseGroupType); + } + return { title, types }; + } +} diff --git a/providers/mod.ts b/providers/mod.ts index 72133da..d273b46 100644 --- a/providers/mod.ts +++ b/providers/mod.ts @@ -7,6 +7,7 @@ import BandcampProvider from './Bandcamp/mod.ts'; import BeatportProvider from './Beatport/mod.ts'; import DeezerProvider from './Deezer/mod.ts'; import DiscogsProvider from './Discogs/mod.ts'; +import AppleMusicProvider, { isAppleMusicConfigured } from './AppleMusic/mod.ts'; import iTunesProvider from './iTunes/mod.ts'; import MusicBrainzProvider from './MusicBrainz/mod.ts'; import OtotoyProvider from './Ototoy/mod.ts'; @@ -28,6 +29,12 @@ providers.addMultiple( MusicBrainzProvider, DiscogsProvider, DeezerProvider, +); +// Apple Music claims music.apple.com URLs when credentials are set; otherwise iTunes handles them. +if (isAppleMusicConfigured()) { + providers.add(AppleMusicProvider); +} +providers.addMultiple( iTunesProvider, SpotifyProvider, TidalProvider, diff --git a/server/components/ProviderIcon.tsx b/server/components/ProviderIcon.tsx index b0a69b3..4d4b160 100644 --- a/server/components/ProviderIcon.tsx +++ b/server/components/ProviderIcon.tsx @@ -9,6 +9,7 @@ const providerIconMap: Record = { deezer: 'brand-deezer', discogs: 'brand-discogs', itunes: 'brand-apple', + applemusic: 'brand-apple', melon: 'brand-melon', musicbrainz: 'brand-metabrainz', mora: 'brand-mora', diff --git a/server/static/harmony.css b/server/static/harmony.css index 34e4117..c029291 100644 --- a/server/static/harmony.css +++ b/server/static/harmony.css @@ -493,7 +493,9 @@ td.discogs { background-color: var(--discogs); } label.itunes, -td.itunes { +td.itunes, +label.applemusic, +td.applemusic { background-color: var(--apple); } label.melon,