diff --git a/providers/Deezer/mod.ts b/providers/Deezer/mod.ts index a60ea5b9..83366a0e 100644 --- a/providers/Deezer/mod.ts +++ b/providers/Deezer/mod.ts @@ -2,6 +2,7 @@ import { availableRegions } from './regions.ts'; import { type CacheEntry, MetadataApiProvider, type ProviderOptions, ReleaseApiLookup } from '@/providers/base.ts'; import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts'; import { parseHyphenatedDate, PartialDate } from '@/utils/date.ts'; +import { splitLabels } from '@/utils/label.ts'; import { ResponseError } from '@/utils/errors.ts'; import { formatGtin } from '@/utils/gtin.ts'; @@ -174,10 +175,7 @@ export class DeezerReleaseLookup extends ReleaseApiLookup ({ - name: label.trim(), - })), + labels: splitLabels(rawRelease.label), status: 'Official', packaging: 'None', images: [{ diff --git a/providers/Spotify/api_types.ts b/providers/Spotify/api_types.ts new file mode 100644 index 00000000..0cfe97e3 --- /dev/null +++ b/providers/Spotify/api_types.ts @@ -0,0 +1,126 @@ +export type SimplifiedAlbum = { + id: string; + type: 'album'; + href: string; + name: string; + uri: string; + artists: SimplifiedArtist[]; + album_type: AlbumType; + total_tracks: number; + release_date: string; + release_date_precision: ReleaseDatePrecision; + external_urls: { spotify: string }; + images: Image[]; + available_markets: string[]; + restrictions: { reason: string }; +}; + +export type Album = SimplifiedAlbum & { + tracks: ResultList; + copyrights: Copyright[]; + external_ids: ExternalIds; + genres: string[]; + label: string; + /** The popularity of the album. The value will be between 0 and 100, with 100 being the most popular. */ + popularity: number; +}; + +export type SimplifiedArtist = { + id: string; + type: 'artist'; + href: string; + name: string; + uri: string; +}; + +export type Artist = SimplifiedArtist & { + followers: { href: string; total: number }; +}; + +export type LinkedTrack = { + id: string; + type: 'track'; + href: string; + uri: string; + external_urls: { spotify: string }; +}; + +export type SimplifiedTrack = LinkedTrack & { + name: string; + artists: SimplifiedArtist[]; + track_number: number; + disc_number: number; + duration_ms: number; + explicit: boolean; + is_playable: boolean | undefined; + is_local: boolean; + preview_url: string; + linked_from: LinkedTrack | undefined; + available_markets: string[]; + restrictions: { reason: string }; +}; + +export type Track = SimplifiedTrack & { + album: Album; + artists: Artist[]; + external_ids: ExternalIds; + popularity: number; +}; + +export type Image = { + url: string; + width: number; + height: number; +}; + +export type Copyright = { + text: string; + type: CopyrightType; +}; + +export type ExternalIds = { + isrc: string; + ean: string; + upc: string; +}; + +export type ReleaseDatePrecision = 'year' | 'month' | 'day'; + +export type AlbumType = 'album' | 'single' | 'compilation'; + +export type CopyrightType = 'C' | 'P'; + +export type BaseResultList = { + href: string; + limit: number; + offset: number; + total: number; + next: string; + previous: string; +}; + +export type TrackList = BaseResultList & { + tracks: Track[]; +}; + +export type ResultList = BaseResultList & { + items: T[]; +}; + +export type SearchResult = { + albums: ResultList; + tracks: ResultList; + artists: ResultList; + // Unsupported / not needed: + // Playlists: ResultList; + // Shows: ResultList; + // Episodes: ResultList; + // Audiobooks: ResultList; +}; + +export type ApiError = { + error: { + status: number; + message: string; + }; +}; diff --git a/providers/Spotify/mod.ts b/providers/Spotify/mod.ts new file mode 100644 index 00000000..ba6578e4 --- /dev/null +++ b/providers/Spotify/mod.ts @@ -0,0 +1,351 @@ +import { ApiAccessToken, type CacheEntry, MetadataApiProvider, ReleaseApiLookup } from '@/providers/base.ts'; +import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts'; +import { parseHyphenatedDate, PartialDate } from '@/utils/date.ts'; +import { splitLabels } from '@/utils/label.ts'; +import { ResponseError } from '@/utils/errors.ts'; +import { selectLargestImage } from '@/utils/image.ts'; +import { encodeBase64 } from 'std/encoding/base64.ts'; +import { availableRegions } from './regions.ts'; + +import type { + Album, + ApiError, + Copyright, + ResultList, + SearchResult, + SimplifiedArtist, + SimplifiedTrack, + Track, + TrackList, +} from './api_types.ts'; +import type { ArtistCreditName, EntityId, HarmonyMedium, HarmonyRelease, HarmonyTrack } from '@/harmonizer/types.ts'; + +// See https://developer.spotify.com/documentation/web-api + +const spotifyClientId = Deno.env.get('HARMONY_SPOTIFY_CLIENT_ID') || ''; +const spotifyClientSecret = Deno.env.get('HARMONY_SPOTIFY_CLIENT_SECRET') || ''; + +export default class SpotifyProvider extends MetadataApiProvider { + readonly name = 'Spotify'; + + readonly supportedUrls = new URLPattern({ + hostname: 'open.spotify.com', + pathname: '{/intl-:language}?/:type(artist|album)/:id', + }); + + readonly features: FeatureQualityMap = { + 'cover size': 640, + 'duration precision': DurationPrecision.MS, + 'GTIN lookup': FeatureQuality.GOOD, + 'MBID resolving': FeatureQuality.GOOD, + 'release label': FeatureQuality.PRESENT, + }; + + readonly entityTypeMap = { + artist: 'artist', + release: 'album', + }; + + readonly availableRegions = new Set(availableRegions); + + readonly releaseLookup = SpotifyReleaseLookup; + + readonly launchDate: PartialDate = { + year: 2008, + month: 10, + }; + + readonly apiBaseUrl = 'https://api.spotify.com/v1/'; + + constructUrl(entity: EntityId): URL { + return new URL([entity.type, entity.id].join('/'), 'https://open.spotify.com'); + } + + async query(apiUrl: URL, maxTimestamp?: number): Promise> { + try { + const accessToken = await this.cachedAccessToken(this.requestAccessToken); + const cacheEntry = await this.fetchJSON(apiUrl, { + policy: { maxTimestamp }, + requestInit: { + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + }, + }); + const apiError = cacheEntry.content as ApiError; + if (apiError.error) { + throw new SpotifyResponseError(apiError, apiUrl); + } + return cacheEntry; + } catch (error) { + // Clone the response so the body of the original response can be + // consumed later if the error gets re-thrown. + const apiError = await error.response?.clone().json() as ApiError; + if (apiError?.error) { + throw new SpotifyResponseError(apiError, apiUrl); + } else { + throw error; + } + } + } + + private async requestAccessToken(): Promise { + // See https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow + const url = new URL('https://accounts.spotify.com/api/token'); + const auth = encodeBase64(`${spotifyClientId}:${spotifyClientSecret}`); + const body = new URLSearchParams(); + body.append('grant_type', 'client_credentials'); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body, + }); + + const content = await response.json(); + return { + accessToken: content?.access_token, + validUntilTimestamp: Date.now() + (content.expires_in * 1000), + }; + } +} + +export class SpotifyReleaseLookup extends ReleaseApiLookup { + constructReleaseApiUrl(): URL { + const { method, value, region } = this.lookup; + let lookupUrl: URL; + const query = new URLSearchParams(); + + if (method === 'gtin') { + lookupUrl = new URL(`search`, this.provider.apiBaseUrl); + query.set('type', 'album'); + query.set('q', `upc:${value}`); + if (region) { + query.set('market', region); + } + } else { // if (method === 'id') + lookupUrl = new URL(`albums/${value}`, this.provider.apiBaseUrl); + } + + lookupUrl.search = query.toString(); + return lookupUrl; + } + + protected async getRawRelease(): Promise { + if (this.lookup.method === 'gtin') { + const albumId = await this.queryAlbumIdByGtin(this.lookup.value); + + // No results found + if (!albumId) { + throw new ResponseError(this.provider.name, 'API returned no results', this.constructReleaseApiUrl()); + } + + // Result is a SimplifiedAlbum. Perform a regular ID lookup with the found release + // ID to retrieve complete data. + this.lookup.method = 'id'; + this.lookup.value = albumId; + } + + const cacheEntry = await this.provider.query( + this.constructReleaseApiUrl(), + this.options.snapshotMaxTimestamp, + ); + const release = cacheEntry.content; + + this.updateCacheTime(cacheEntry.timestamp); + return release; + } + + private async queryAlbumIdByGtin(gtin: string): Promise { + // Spotify does not always find UPC barcodes but expects them prefixed with + // 0 to a length of 14 characters. E.g. "810121774182" gives no results, + // but "00810121774182" does. + const gtins = this.getGtinCandidates(gtin); + // For GTIN lookups use the region + for (const region of this.options?.regions || [undefined]) { + this.lookup.region = region; + for (const gtin of gtins) { + this.lookup.value = gtin; + const cacheEntry = await this.provider.query( + this.constructReleaseApiUrl(), + this.options.snapshotMaxTimestamp, + ); + if (cacheEntry.content?.albums?.items?.length) { + return cacheEntry.content.albums.items[0].id; + } + } + } + + return undefined; + } + + private async getRawTracklist(rawRelease: Album): Promise { + const allTracks: SimplifiedTrack[] = [...rawRelease.tracks.items]; + + // The initial response contains max. 50 tracks. Fetch the remaining + // tracks with separate requests if needed. + let nextUrl = rawRelease.tracks.next; + while (nextUrl && allTracks.length < rawRelease.tracks.total) { + const cacheEntry = await this.provider.query>( + new URL(nextUrl), + this.options.snapshotMaxTimestamp, + ); + this.updateCacheTime(cacheEntry.timestamp); + allTracks.push(...cacheEntry.content.items); + nextUrl = cacheEntry.content.next; + } + + // Load full details including ISRCs + if (this.options.withISRC) { + return this.getRawTrackDetails(allTracks); + } else { + return allTracks; + } + } + + private async getRawTrackDetails(simplifiedTracks: SimplifiedTrack[]): Promise { + const allTracks: Track[] = []; + const trackIds = simplifiedTracks.map((track) => track.id); + + // The SimplifiedTrack entries do not contain ISRCs. + // Perform track queries to obtain the full details of all tracks. + // Each query can return up to 50 tracks. + const maxResults = 50; + const apiUrl = new URL('tracks', this.provider.apiBaseUrl); + for (let index = 0; index < trackIds.length; index += maxResults) { + apiUrl.searchParams.set('ids', trackIds.slice(index, index + maxResults).join(',')); + apiUrl.search = apiUrl.searchParams.toString(); + const cacheEntry = await this.provider.query( + apiUrl, + this.options.snapshotMaxTimestamp, + ); + this.updateCacheTime(cacheEntry.timestamp); + allTracks.push(...cacheEntry.content.tracks); + } + + return allTracks; + } + + protected async convertRawRelease(rawRelease: Album): Promise { + this.id = rawRelease.id; + const rawTracklist = await this.getRawTracklist(rawRelease); + const media = this.convertRawTracklist(rawTracklist); + const artwork = selectLargestImage(rawRelease.images, ['front']); + return { + title: rawRelease.name, + artists: rawRelease.artists.map(this.convertRawArtist.bind(this)), + gtin: rawRelease.external_ids.ean || rawRelease.external_ids.upc, + externalLinks: [{ + url: new URL(rawRelease.external_urls.spotify), + types: ['free streaming'], + }], + media, + releaseDate: parseHyphenatedDate(rawRelease.release_date), + copyright: this.getCopyright(rawRelease.copyrights), + status: 'Official', + packaging: 'None', + images: artwork ? [artwork] : [], + labels: splitLabels(rawRelease.label), + availableIn: rawRelease.available_markets, + info: this.generateReleaseInfo(), + }; + } + + private convertRawTracklist(tracklist: SimplifiedTrack[]): HarmonyMedium[] { + const result: HarmonyMedium[] = []; + let medium: HarmonyMedium = { + number: 1, + format: 'Digital Media', + tracklist: [], + }; + + // split flat tracklist into media + tracklist.forEach((item) => { + // store the previous medium and create a new one + if (item.disc_number !== medium.number) { + result.push(medium); + + medium = { + number: item.disc_number, + format: 'Digital Media', + tracklist: [], + }; + } + + medium.tracklist.push(this.convertRawTrack(item)); + }); + + // store the final medium + result.push(medium); + + return result; + } + + private convertRawTrack(track: SimplifiedTrack | Track): HarmonyTrack { + const result: HarmonyTrack = { + number: track.track_number, + title: track.name, + length: track.duration_ms, + isrc: (track as Track).external_ids?.isrc, + artists: track.artists.map(this.convertRawArtist.bind(this)), + availableIn: track.available_markets, + }; + + return result; + } + + private convertRawArtist(artist: SimplifiedArtist): ArtistCreditName { + return { + name: artist.name, + creditedName: artist.name, + externalIds: this.provider.makeExternalIds({ type: 'artist', id: artist.id }), + }; + } + + /** + * Returns a list of possible GTINs to search for. + * + * If a GTIN is shorter than 14 characters also try variants prefixed with 0 + * to a maximum length of 14 characters. + * + * @param gtin Original GTIN. + * @returns GTIN variations to try to search. + */ + private getGtinCandidates(gtin: string): string[] { + const candidates = [gtin]; + // Try padding to 14 characters, this seems to give results most often. + // As a last fallback also try 13 characters. + [14, 13].forEach((length) => { + if (gtin.length < length) { + candidates.push(gtin.padStart(length, '0')); + } + }); + return candidates; + } + + private getCopyright(copyrights: Copyright[]): string { + return copyrights.map(this.formatCopyright).join('\n'); + } + + private formatCopyright(copyright: Copyright): string { + // As Spotify provides separate fields for copyright and phonographic + // copyright those get often entered without the corresponding symbol. + // When only importing the text entry the information gets lost. Hence + // prefix the entries with the © or ℗ symbol if it is not already present. + let { text, type } = copyright; + text = text.replace(/\(c\)/i, '©').replace(/\(p\)/i, '℗'); + if (!text.includes('©') && !text.includes('℗')) { + text = `${type === 'P' ? '℗' : '©'} ${text}`; + } + return text; + } +} + +class SpotifyResponseError extends ResponseError { + constructor(readonly details: ApiError, url: URL) { + super('Spotify', details?.error?.message, url); + } +} diff --git a/providers/Spotify/regions.ts b/providers/Spotify/regions.ts new file mode 100644 index 00000000..b7468602 --- /dev/null +++ b/providers/Spotify/regions.ts @@ -0,0 +1,189 @@ +// Fetched from API endpoint https://developer.spotify.com/documentation/web-api/reference/get-available-markets + +export const availableRegions = [ + 'AD', + 'AE', + 'AG', + 'AL', + 'AM', + 'AO', + 'AR', + 'AT', + 'AU', + 'AZ', + 'BA', + 'BB', + 'BD', + 'BE', + 'BF', + 'BG', + 'BH', + 'BI', + 'BJ', + 'BN', + 'BO', + 'BR', + 'BS', + 'BT', + 'BW', + 'BY', + 'BZ', + 'CA', + 'CD', + 'CG', + 'CH', + 'CI', + 'CL', + 'CM', + 'CO', + 'CR', + 'CV', + 'CW', + 'CY', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DM', + 'DO', + 'DZ', + 'EC', + 'EE', + 'EG', + 'ES', + 'ET', + 'FI', + 'FJ', + 'FM', + 'FR', + 'GA', + 'GB', + 'GD', + 'GE', + 'GH', + 'GM', + 'GN', + 'GQ', + 'GR', + 'GT', + 'GW', + 'GY', + 'HK', + 'HN', + 'HR', + 'HT', + 'HU', + 'ID', + 'IE', + 'IL', + 'IN', + 'IQ', + 'IS', + 'IT', + 'JM', + 'JO', + 'JP', + 'KE', + 'KG', + 'KH', + 'KI', + 'KM', + 'KN', + 'KR', + 'KW', + 'KZ', + 'LA', + 'LB', + 'LC', + 'LI', + 'LK', + 'LR', + 'LS', + 'LT', + 'LU', + 'LV', + 'LY', + 'MA', + 'MC', + 'MD', + 'ME', + 'MG', + 'MH', + 'MK', + 'ML', + 'MN', + 'MO', + 'MR', + 'MT', + 'MU', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + 'NA', + 'NE', + 'NG', + 'NI', + 'NL', + 'NO', + 'NP', + 'NR', + 'NZ', + 'OM', + 'PA', + 'PE', + 'PG', + 'PH', + 'PK', + 'PL', + 'PR', + 'PS', + 'PT', + 'PW', + 'PY', + 'QA', + 'RO', + 'RS', + 'RW', + 'SA', + 'SB', + 'SC', + 'SE', + 'SG', + 'SI', + 'SK', + 'SL', + 'SM', + 'SN', + 'SR', + 'ST', + 'SV', + 'SZ', + 'TD', + 'TG', + 'TH', + 'TJ', + 'TL', + 'TN', + 'TO', + 'TR', + 'TT', + 'TV', + 'TW', + 'TZ', + 'UA', + 'UG', + 'US', + 'UY', + 'UZ', + 'VC', + 'VE', + 'VN', + 'VU', + 'WS', + 'XK', + 'ZA', + 'ZM', + 'ZW', +]; diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index b5268bbd..f4320715 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -1,14 +1,20 @@ import { availableRegions } from './regions.ts'; -import { type CacheEntry, MetadataApiProvider, type ProviderOptions, ReleaseApiLookup } from '@/providers/base.ts'; +import { + ApiAccessToken, + type CacheEntry, + MetadataApiProvider, + type ProviderOptions, + ReleaseApiLookup, +} from '@/providers/base.ts'; import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts'; import { parseHyphenatedDate, PartialDate } from '@/utils/date.ts'; import { ResponseError } from '@/utils/errors.ts'; +import { selectLargestImage } from '@/utils/image.ts'; import { encodeBase64 } from 'std/encoding/base64.ts'; -import type { Album, AlbumItem, ApiError, Image, Resource, Result, SimpleArtist } from './api_types.ts'; +import type { Album, AlbumItem, ApiError, Resource, Result, SimpleArtist } from './api_types.ts'; import type { ArtistCreditName, - Artwork, CountryCode, EntityId, HarmonyMedium, @@ -70,11 +76,12 @@ export default class TidalProvider extends MetadataApiProvider { } async query(apiUrl: URL, maxTimestamp?: number): Promise> { + const accessToken = await this.cachedAccessToken(this.requestAccessToken); const cacheEntry = await this.fetchJSON(apiUrl, { policy: { maxTimestamp }, requestInit: { headers: { - 'Authorization': `Bearer ${await this.accessToken()}`, + 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/vnd.tidal.v1+json', }, }, @@ -87,17 +94,7 @@ export default class TidalProvider extends MetadataApiProvider { return cacheEntry; } - private async accessToken(): Promise { - const cacheKey = `${this.name}:accessKey`; - let tokenResult = JSON.parse(localStorage.getItem(cacheKey) || '{}'); - if (!tokenResult?.accessToken || Date.now() > tokenResult?.validUntil) { - tokenResult = await this.requestAccessToken(); - localStorage.setItem(cacheKey, JSON.stringify(tokenResult)); - } - return tokenResult.accessToken; - } - - private async requestAccessToken(): Promise<{ accessToken: string; validUntil: number }> { + private async requestAccessToken(): Promise { // See https://developer.tidal.com/documentation/api-sdk/api-sdk-quick-start const url = new URL('https://auth.tidal.com/v1/oauth2/token'); const auth = encodeBase64(`${tidalClientId}:${tidalClientSecret}`); @@ -117,7 +114,7 @@ export default class TidalProvider extends MetadataApiProvider { const content = await response.json(); return { accessToken: content?.access_token, - validUntil: Date.now() + (content.expires_in * 1000), + validUntilTimestamp: Date.now() + (content.expires_in * 1000), }; } } @@ -197,6 +194,7 @@ export class TidalReleaseLookup extends ReleaseApiLookup { this.id = rawRelease.id; const rawTracklist = await this.getRawTracklist(this.id); const media = this.convertRawTracklist(rawTracklist); + const artwork = selectLargestImage(rawRelease.imageCover, ['front']); return { title: rawRelease.title, artists: rawRelease.artists.map(this.convertRawArtist.bind(this)), @@ -210,7 +208,7 @@ export class TidalReleaseLookup extends ReleaseApiLookup { copyright: rawRelease.copyright, status: 'Official', packaging: 'None', - images: this.getLargestCoverImage(rawRelease.imageCover), + images: artwork ? [artwork] : [], labels: this.getLabels(rawRelease), info: this.generateReleaseInfo(), }; @@ -269,29 +267,6 @@ export class TidalReleaseLookup extends ReleaseApiLookup { }; } - private getLargestCoverImage(images: Image[]): Artwork[] { - let largestImage: Image | undefined; - let thumbnail: Image | undefined; - images.forEach((i) => { - if (!largestImage || i.width > largestImage.width) { - largestImage = i; - } - if (i.width >= 200 && (!thumbnail || i.width < thumbnail.width)) { - thumbnail = i; - } - }); - if (!largestImage) return []; - let thumbUrl: URL | undefined; - if (thumbnail) { - thumbUrl = new URL(thumbnail.url); - } - return [{ - url: new URL(largestImage.url), - thumbUrl: thumbUrl, - types: ['front'], - }]; - } - private getLabels(rawRelease: Album): Label[] { // It is unsure whether providerInfo is actually used for some releases, // but it is documented in the API schemas. diff --git a/providers/base.ts b/providers/base.ts index 492774ff..cc1f8924 100644 --- a/providers/base.ts +++ b/providers/base.ts @@ -351,10 +351,34 @@ export abstract class ReleaseLookup(apiUrl: URL, maxTimestamp?: number): Promise>; + + /** + * Returns a cached API access token. + * + * Must be passed a function `requestAccessToken` which will return a promise resolving to a + * `ApiAccessToken`, containing the access token and an expiration Unix timestamp. The result is + * cached and `requestAccessToken` only gets called if the cached token has expired. + */ + protected async cachedAccessToken(requestAccessToken: () => Promise): Promise { + const cacheKey = `${this.name}:accessToken`; + let tokenResult = JSON.parse(localStorage.getItem(cacheKey) || '{}'); + if ( + !tokenResult?.accessToken || !tokenResult?.validUntilTimestamp || Date.now() > tokenResult.validUntilTimestamp + ) { + tokenResult = await requestAccessToken(); + localStorage.setItem(cacheKey, JSON.stringify(tokenResult)); + } + return tokenResult.accessToken; + } } /** Extends `ReleaseLookup` with functions common to lookups accessing web APIs. */ diff --git a/providers/mod.ts b/providers/mod.ts index b97c3556..1e14d8cc 100644 --- a/providers/mod.ts +++ b/providers/mod.ts @@ -5,6 +5,7 @@ import BandcampProvider from './Bandcamp/mod.ts'; import BeatportProvider from './Beatport/mod.ts'; import DeezerProvider from './Deezer/mod.ts'; import iTunesProvider from './iTunes/mod.ts'; +import SpotifyProvider from './Spotify/mod.ts'; import TidalProvider from './Tidal/mod.ts'; /** Registry with all supported providers. */ @@ -14,6 +15,7 @@ export const providers = new ProviderRegistry(); providers.addMultiple( DeezerProvider, iTunesProvider, + SpotifyProvider, TidalProvider, BandcampProvider, BeatportProvider, diff --git a/server/components/ProviderIcon.tsx b/server/components/ProviderIcon.tsx index 6ae44b4b..bf813a9a 100644 --- a/server/components/ProviderIcon.tsx +++ b/server/components/ProviderIcon.tsx @@ -8,6 +8,7 @@ const providerIconMap: Record = { deezer: 'brand-deezer', itunes: 'brand-apple', musicbrainz: 'brand-metabrainz', + spotify: 'brand-spotify', tidal: 'brand-tidal', }; diff --git a/server/routes/icon-sprite.svg.tsx b/server/routes/icon-sprite.svg.tsx index 2efbcd8e..16dbd236 100644 --- a/server/routes/icon-sprite.svg.tsx +++ b/server/routes/icon-sprite.svg.tsx @@ -4,6 +4,7 @@ import IconBrandApple from 'tabler-icons/brand-apple.tsx'; import IconBrandBandcamp from 'tabler-icons/brand-bandcamp.tsx'; import IconBrandDeezer from 'tabler-icons/brand-deezer.tsx'; import IconBrandGit from 'tabler-icons/brand-git.tsx'; +import IconBrandSpotify from 'tabler-icons/brand-spotify.tsx'; import IconBrandTidal from 'tabler-icons/brand-tidal.tsx'; import IconAlertTriangle from 'tabler-icons/alert-triangle.tsx'; import IconBarcode from 'tabler-icons/barcode.tsx'; @@ -48,6 +49,7 @@ const icons: Icon[] = [ IconBrandDeezer, IconBrandGit, IconBrandMetaBrainz, + IconBrandSpotify, IconBrandTidal, IconPuzzle, ]; diff --git a/server/routes/release.tsx b/server/routes/release.tsx index 3d2bda1d..591726f1 100644 --- a/server/routes/release.tsx +++ b/server/routes/release.tsx @@ -32,6 +32,7 @@ export default defineRoute(async (req, ctx) => { const options: ReleaseOptions = { withSeparateMedia: true, withAllTrackArtists: true, + withISRC: true, regions, providers, snapshotMaxTimestamp, diff --git a/server/routes/release/actions.tsx b/server/routes/release/actions.tsx index 50bc6e1a..ab1fa954 100644 --- a/server/routes/release/actions.tsx +++ b/server/routes/release/actions.tsx @@ -35,6 +35,7 @@ export default defineRoute(async (req, ctx) => { const { gtin, urls, regions, providerIds, providers, snapshotMaxTimestamp } = extractReleaseLookupState(ctx.url); const options: ReleaseOptions = { withSeparateMedia: true, + withISRC: true, regions, providers, snapshotMaxTimestamp, diff --git a/server/static/harmony.css b/server/static/harmony.css index d56c2e1c..03c7686a 100644 --- a/server/static/harmony.css +++ b/server/static/harmony.css @@ -22,6 +22,7 @@ --bandcamp: #1da0c3; --beatport: #00e586; --deezer: #a238ff; + --spotify: #1db954; --tidal: #000000; } @@ -368,6 +369,9 @@ label.deezer { label.itunes { background-color: var(--apple); } +label.spotify { + background-color: var(--spotify); +} label.tidal { background-color: var(--tidal); } diff --git a/utils/image.ts b/utils/image.ts new file mode 100644 index 00000000..c0085579 --- /dev/null +++ b/utils/image.ts @@ -0,0 +1,34 @@ +import type { Artwork, ArtworkType } from '@/harmonizer/types.ts'; + +interface ImageObject { + url: string; + width: number; + height: number; +} + +const minThumbnailSize = 200; + +/** + * From a list of images select the largest one (based on image width) and return an `Artwork` object. + * + * The smallest image from the list that is still larger than `minThumbnailSize` will + * be used as the thumbnail image. + */ +export function selectLargestImage(images: ImageObject[], types: ArtworkType[]): Artwork | undefined { + let largestImage: ImageObject | undefined; + let thumbnail: ImageObject | undefined; + images.forEach((image) => { + if (!largestImage || image.width > largestImage.width) { + largestImage = image; + } + if (image.width >= minThumbnailSize && (!thumbnail || image.width < thumbnail.width)) { + thumbnail = image; + } + }); + if (!largestImage) return; + return { + url: new URL(largestImage.url), + thumbUrl: thumbnail ? new URL(thumbnail.url) : undefined, + types, + }; +} diff --git a/utils/label.test.ts b/utils/label.test.ts new file mode 100644 index 00000000..19ae77a5 --- /dev/null +++ b/utils/label.test.ts @@ -0,0 +1,27 @@ +import { splitLabels } from './label.ts'; + +import { assertEquals } from 'std/assert/assert_equals.ts'; +import { describe, it } from 'std/testing/bdd.ts'; + +import type { FunctionSpec } from './test_spec.ts'; + +describe('GTIN validator', () => { + const passingCases: FunctionSpec = [ + ['preserves single label', 'Nuclear Blast', [{ name: 'Nuclear Blast' }]], + ['split multiple labels', 'Roc Nation/RocAFella/IDJ', [{ name: 'Roc Nation' }, { name: 'RocAFella' }, { + name: 'IDJ', + }]], + ['split multiple labels, trim values', 'Roc Nation / RocAFella / IDJ', [{ name: 'Roc Nation' }, { + name: 'RocAFella', + }, { + name: 'IDJ', + }]], + ['preserve label whose suffix contains a slash', 'EMI Belgium SA/NV', [{ name: 'EMI Belgium SA/NV' }]], + ]; + + passingCases.forEach(([description, input, expected]) => { + it(description, () => { + assertEquals(splitLabels(input), expected); + }); + }); +}); diff --git a/utils/label.ts b/utils/label.ts new file mode 100644 index 00000000..5753899a --- /dev/null +++ b/utils/label.ts @@ -0,0 +1,13 @@ +import { Label } from '@/harmonizer/types.ts'; + +/** + * Split label string using slashes if the results have at least 3 characters. + * + * @param labels String containing one or more label name. + * @returns List of `Label` entries + */ +export function splitLabels(labels: string): Label[] { + return labels?.split(/(?<=[^/]{3,})\/(?=[^/]{3,})/).map((label) => ({ + name: label.trim(), + })); +}