diff --git a/providers/Tidal/api_types.ts b/providers/Tidal/api_types.ts new file mode 100644 index 00000000..65672915 --- /dev/null +++ b/providers/Tidal/api_types.ts @@ -0,0 +1,127 @@ +export type SimpleArtist = { + /** The Tidal ID */ + id: string; + name: string; + picture: Image[]; + main: boolean; +}; + +export type Artist = SimpleArtist & { + tidalUrl: string; + popularity: number; +}; + +export type SimpleAlbum = { + /** The Tidal ID */ + id: string; + title: string; + imageCover: Image[]; + videoCover: Image[]; +}; + +export type Album = SimpleAlbum & { + barcodeId: string; + artists: SimpleArtist[]; + /** Full release duration in seconds */ + duration: number; + /** Release date in YYYY-MM-DD format */ + releaseDate: string; + numberOfVolumes: number; + numberOfTracks: number; + numberOfVideos: number; + type: string; + copyright: string; + mediaMetadata: MediaMetadata; + properties: Properties; + tidalUrl: string; + providerInfo: ProviderInfo; + popularity: number; +}; + +export type CommonAlbumItem = { + artifactType: 'track' | 'video'; + /** The Tidal ID */ + id: string; + title: string; + artists: SimpleArtist[]; + /** Track duration in seconds */ + duration: number; + /** Version of the album's item; complements title */ + version: string; + album: SimpleAlbum; + trackNumber: number; + volumeNumber: number; + isrc: string; + copyright: string; + mediaMetadata: MediaMetadata; + tidalUrl: string; + providerInfo: ProviderInfo; + popularity: number; +}; + +export type Track = CommonAlbumItem & { + properties: Properties; +}; + +export type Video = CommonAlbumItem & { + properties: VideoProperties; + image: Image; + releaseDate: string; +}; + +export type AlbumItem = Track | Video; + +export type Image = { + url: string; + width: number; + height: number; +}; + +export type Resource = { + id: string; + status: number; + message: string; + resource: T; +}; + +export type MediaMetadata = { + tags: string[]; +}; + +export type Properties = { + /** Can be "explicit", other? */ + content: string; +}; + +export type VideoProperties = Properties & { + /** Example: live-stream */ + 'video-type': string; +}; + +export type ProviderInfo = { + providerId: string; + providerName: string; +}; + +export type Error = { + category: string; + code: string; + detail: string; + field: string; +}; + +export type ApiError = { + errors: Error[]; +}; + +export type ResultMetadata = { + total: number; + requested: number; + success: number; + failure: number; +}; + +export type Result = { + data: Resource[]; + metadata: ResultMetadata; +}; diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts new file mode 100644 index 00000000..e6143c12 --- /dev/null +++ b/providers/Tidal/mod.ts @@ -0,0 +1,349 @@ +import { availableRegions } from './regions.ts'; +import { type CacheEntry, MetadataProvider, type ProviderOptions, ReleaseLookup } 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 { encodeBase64 } from 'std/encoding/base64.ts'; + +import type { Album, AlbumItem, ApiError, Image, Resource, Result, SimpleArtist } from './api_types.ts'; +import type { + ArtistCreditName, + Artwork, + CountryCode, + EntityId, + HarmonyMedium, + HarmonyRelease, + HarmonyTrack, + Label, +} from '@/harmonizer/types.ts'; +import { pluralWithCount } from '../../utils/plural.ts'; + +// See https://developer.tidal.com/reference/web-api + +const tidalClientId = Deno.env.get('HARMONY_TIDAL_CLIENT_ID') || ''; +const tidalClientSecret = Deno.env.get('HARMONY_TIDAL_CLIENT_SECRET') || ''; + +export default class TidalProvider extends MetadataProvider { + constructor(options: ProviderOptions = {}) { + super({ + rateLimitInterval: 1000, + concurrentRequests: 2, + ...options, + }); + } + + readonly name = 'Tidal'; + + readonly supportedUrls = new URLPattern({ + hostname: '{www.}?tidal.com', + pathname: String.raw`{/browse}?/:type(album|artist)/:id(\d+)`, + }); + + readonly features: FeatureQualityMap = { + 'cover size': 1280, + 'duration precision': DurationPrecision.SECONDS, + 'GTIN lookup': FeatureQuality.GOOD, + 'MBID resolving': FeatureQuality.PRESENT, + 'release label': FeatureQuality.BAD, + }; + + readonly entityTypeMap = { + artist: 'artist', + release: 'album', + }; + + readonly defaultRegion: CountryCode = 'US'; + + readonly availableRegions = new Set(availableRegions); + + readonly releaseLookup = TidalReleaseLookup; + + readonly launchDate: PartialDate = { + year: 2014, + month: 10, + day: 28, + }; + + readonly apiBaseUrl = 'https://openapi.tidal.com'; + + constructUrl(entity: EntityId): URL { + return new URL([entity.type, entity.id].join('/'), 'https://tidal.com/'); + } + + async query(apiUrl: URL, maxTimestamp?: number): Promise> { + const cacheEntry = await this.fetchJSON(apiUrl, { + policy: { maxTimestamp }, + requestInit: { + headers: { + 'Authorization': `Bearer ${await this.accessToken()}`, + 'Content-Type': 'application/vnd.tidal.v1+json', + }, + }, + }); + const { error } = cacheEntry.content as { error?: ApiError }; + + if (error) { + throw new TidalResponseError(error, apiUrl); + } + 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 }> { + // 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}`); + const body = new URLSearchParams(); + body.append('grant_type', 'client_credentials'); + body.append('client_id', tidalClientId); + + 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, + validUntil: Date.now() + (content.expires_in * 1000), + }; + } +} + +export class TidalReleaseLookup extends ReleaseLookup { + constructReleaseApiUrl(): URL { + const { method, value, region } = this.lookup; + let lookupUrl: URL; + const query = new URLSearchParams({ + countryCode: region || this.provider.defaultRegion, + }); + if (method === 'gtin') { + lookupUrl = new URL(`/albums/byBarcodeId`, this.provider.apiBaseUrl); + query.append('barcodeId', value); + } else { // if (method === 'id') + lookupUrl = new URL(`albums/${value}`, this.provider.apiBaseUrl); + } + + lookupUrl.search = query.toString(); + return lookupUrl; + } + + protected async getRawRelease(): Promise { + let cacheEntry, release; + + // Try querying all regions + for (const region of this.options.regions || [this.provider.defaultRegion]) { + this.lookup.region = region; + const apiUrl = this.constructReleaseApiUrl(); + if (this.lookup.method === 'gtin') { + cacheEntry = await this.provider.query>( + apiUrl, + this.options.snapshotMaxTimestamp, + ); + + if (cacheEntry.content?.data?.length) { + release = cacheEntry.content.data[0].resource; + break; + } + } else { // if (method === 'id') { + try { + cacheEntry = await this.provider.query>( + apiUrl, + this.options.snapshotMaxTimestamp, + ); + release = cacheEntry.content?.resource; + if (release) { + break; + } + } catch (e) { + // If this was a 404 not found error, ignore it and try next region. + if (e.response?.status !== 404) { + throw e; + } + } + } + } + + if (!cacheEntry || !release) { + throw new ResponseError(this.provider.name, 'API returned no results', this.constructReleaseApiUrl()); + } + + this.updateCacheTime(cacheEntry.timestamp); + return release; + } + + private async getRawTracklist(albumId: string): Promise { + const tracklist: AlbumItem[] = []; + const url = new URL(`albums/${albumId}/items`, this.provider.apiBaseUrl); + const limit = 100; + let offset = 0; + const query = new URLSearchParams({ + countryCode: this.lookup.region || this.provider.defaultRegion, + limit: String(limit), + offset: String(offset), + }); + + while (true) { + url.search = query.toString(); + const { content, timestamp }: CacheEntry> = await this.provider.query( + url, + this.options.snapshotMaxTimestamp, + ); + tracklist.push(...content.data.map((r) => r.resource)); + this.updateCacheTime(timestamp); + if (!content.metadata.total || content.metadata.total <= tracklist.length) { + break; + } + offset += limit; + query.set('offset', String(offset)); + } + + return tracklist; + } + + protected async convertRawRelease(rawRelease: Album): Promise { + this.id = rawRelease.id; + const rawTracklist = await this.getRawTracklist(this.id); + const media = this.convertRawTracklist(rawTracklist); + return { + title: rawRelease.title, + artists: rawRelease.artists.map(this.convertRawArtist.bind(this)), + gtin: rawRelease.barcodeId, + externalLinks: [{ + url: new URL(rawRelease.tidalUrl), + types: ['paid streaming'], + }], + media, + releaseDate: parseHyphenatedDate(rawRelease.releaseDate), + copyright: rawRelease.copyright, + status: 'Official', + packaging: 'None', + images: this.getLargestCoverImage(rawRelease.imageCover), + labels: this.getLabels(rawRelease), + info: this.generateReleaseInfo(), + }; + } + + private convertRawTracklist(tracklist: AlbumItem[]): HarmonyMedium[] { + const result: HarmonyMedium[] = []; + let medium: HarmonyMedium = { + number: 1, + format: 'Digital Media', + tracklist: [], + }; + + // Get info about video tracks to show a warning to the user. + const videoTrackInfo: string[] = []; + + // split flat tracklist into media + tracklist.forEach((item) => { + // store the previous medium and create a new one + if (item.volumeNumber !== medium.number) { + if (medium.number) { + result.push(medium); + } + + medium = { + number: item.volumeNumber, + format: 'Digital Media', + tracklist: [], + }; + } + + if (item.artifactType === 'video') { + videoTrackInfo.push(`${item.trackNumber}: ${item.title}`); + } + + medium.tracklist.push(this.convertRawTrack(item)); + }); + + if (videoTrackInfo.length) { + this.addMessage( + `This release contains ${pluralWithCount(videoTrackInfo.length, 'video track')}:\n- ${ + videoTrackInfo.join('\n- ') + }`, + 'warning', + ); + } + + // store the final medium + result.push(medium); + + return result; + } + + private convertRawTrack(track: AlbumItem): HarmonyTrack { + const result: HarmonyTrack = { + number: track.trackNumber, + title: track.title, + length: track.duration * 1000, + isrc: track.isrc, + artists: track.artists.map(this.convertRawArtist.bind(this)), + }; + + return result; + } + + private convertRawArtist(artist: SimpleArtist): ArtistCreditName { + return { + name: artist.name, + creditedName: artist.name, + externalIds: this.provider.makeExternalIds({ type: 'artist', id: artist.id.toString() }), + }; + } + + 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. + if (rawRelease.providerInfo?.providerName) { + return [{ + name: rawRelease.providerInfo?.providerName, + }]; + } + + return []; + } +} + +class TidalResponseError extends ResponseError { + constructor(readonly details: ApiError, url: URL) { + const msg = details.errors.map((e) => `${e.field}: ${e.detail}`).join(', '); + super('Tidal', msg, url); + } +} diff --git a/providers/Tidal/regions.ts b/providers/Tidal/regions.ts new file mode 100644 index 00000000..8d08b8fc --- /dev/null +++ b/providers/Tidal/regions.ts @@ -0,0 +1,65 @@ +// Availability of Tidal: https://support.tidal.com/hc/en-us/articles/202453191-Availability-by-Country + +export const availableRegions = [ + 'AD', // Andorra + 'AE', // United Arab Emirates + 'AL', // Albania + 'AR', // Argentina + 'AT', // Austria + 'AU', // Australia + 'BA', // Bosnia and Herzegovina + 'BE', // Belgium + 'BG', // Bulgaria + 'BR', // Brazil + 'CA', // Canada + 'CH', // Switzerland + 'CL', // Chile + 'CO', // Colombia + 'CY', // Cyprus + 'CZ', // Czech Republic + 'DE', // Germany + 'DK', // Denmark + 'DM', // Dominican Republic + 'EE', // Estonia + 'ES', // Spain + 'FI', // Finland + 'FR', // France + 'GB', // United Kingdom + 'GR', // Greece + 'HK', // Hong Kong + 'HR', // Croatia + 'HU', // Hungary + 'IL', // Israel + 'IR', // Ireland + 'IS', // Iceland + 'IT', // Italy + 'JM', // Jamaica + 'LI', // Liechtenstein + 'LT', // Lithuania + 'LU', // Luxembourg + 'LV', // Latvia + 'MC', // Monaco + 'ME', // Montenegro + 'MK', // North Macedonia + 'MT', // Malta + 'MX', // Mexico + 'MY', // Malaysia + 'NG', // Nigeria + 'NL', // Netherlands + 'NO', // Norway + 'NZ', // New Zealand + 'PE', // Peru + 'PL', // Poland + 'PR', // Puerto Rico + 'PT', // Portugal + 'RO', // Romania + 'RS', // Serbia + 'SE', // Sweden' + 'SG', // Singapore + 'SI', // Slovenia + 'SK', // Slovakia + 'TH', // Thailand + 'UG', // Uganda + 'US', // United States of America + 'ZA', // South Africa +]; diff --git a/providers/mod.ts b/providers/mod.ts index 3770e1bb..dc798189 100644 --- a/providers/mod.ts +++ b/providers/mod.ts @@ -6,6 +6,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 TidalProvider from './Tidal/mod.ts'; /** Registry with all supported providers. */ export const providers = new ProviderRegistry(); @@ -14,6 +15,7 @@ export const providers = new ProviderRegistry(); providers.addMultiple( DeezerProvider, iTunesProvider, + TidalProvider, BandcampProvider, BeatportProvider, ); diff --git a/server/components/ProviderIcon.tsx b/server/components/ProviderIcon.tsx index b121a02b..6ae44b4b 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', + tidal: 'brand-tidal', }; export type ProviderIconProps = Omit & { diff --git a/server/routes/icon-sprite.svg.tsx b/server/routes/icon-sprite.svg.tsx index 4b301b7d..e7800a1a 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 IconBrandTidal from 'tabler-icons/brand-tidal.tsx'; import IconAlertTriangle from 'tabler-icons/alert-triangle.tsx'; import IconBarcode from 'tabler-icons/barcode.tsx'; import IconBug from 'tabler-icons/bug.tsx'; @@ -45,6 +46,7 @@ const icons: Icon[] = [ IconBrandDeezer, IconBrandGit, IconBrandMetaBrainz, + IconBrandTidal, IconPuzzle, ]; diff --git a/server/static/harmony.css b/server/static/harmony.css index 05f66598..d56c2e1c 100644 --- a/server/static/harmony.css +++ b/server/static/harmony.css @@ -22,6 +22,7 @@ --bandcamp: #1da0c3; --beatport: #00e586; --deezer: #a238ff; + --tidal: #000000; } @media (prefers-color-scheme: dark) { @@ -367,6 +368,9 @@ label.deezer { label.itunes { background-color: var(--apple); } +label.tidal { + background-color: var(--tidal); +} /* ProviderIcon.tsx */