From 4886ea75db0eb37be6ca8da7583c66d04bf747f8 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 5 Jun 2024 15:24:04 +0200 Subject: [PATCH 01/12] feat(Tidal): initial implelementation of Tidal provider --- providers/Tidal/api_types.ts | 92 ++++++++++ providers/Tidal/mod.ts | 282 +++++++++++++++++++++++++++++ providers/Tidal/regions.ts | 65 +++++++ providers/mod.ts | 2 + server/components/ProviderIcon.tsx | 1 + server/routes/icon-sprite.svg.tsx | 2 + server/static/harmony.css | 4 + 7 files changed, 448 insertions(+) create mode 100644 providers/Tidal/api_types.ts create mode 100644 providers/Tidal/mod.ts create mode 100644 providers/Tidal/regions.ts diff --git a/providers/Tidal/api_types.ts b/providers/Tidal/api_types.ts new file mode 100644 index 00000000..b03ca60c --- /dev/null +++ b/providers/Tidal/api_types.ts @@ -0,0 +1,92 @@ +export type Artist = { + /** The Tidal ID */ + id: string; + name: string; + picture: Image[]; + main: boolean; + tidalUrl: string; +}; + +export type Album = { + /** The Tidal ID */ + id: string; + barcodeId: string; + title: string; + artists: Artist[]; + /** Full release duration in seconds */ + duration: number; + /** Release date in YYYY-MM-DD format */ + releaseDate: string; + imageCover: Image[]; + videoCover: Image[]; + numberOfVolumes: number; + numberOfTracks: number; + numberOfVideos: number; + type: string; + copyright: string; + mediaMetadata: MediaMetadata; + properties: Properties; + tidalUrl: string; +}; + +export type AlbumItem = { + artifactType: 'track' | 'video'; + /** The Tidal ID */ + id: string; + title: string; + artists: Artist[]; + /** Track duration in seconds */ + duration: number; + trackNumber: number; + volumeNumber: number; + isrc: string; + copyright: string; + mediaMetadata: MediaMetadata; + properties: Properties; + tidalUrl: string; +}; + +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 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..dd290089 --- /dev/null +++ b/providers/Tidal/mod.ts @@ -0,0 +1,282 @@ +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, Artist, Image, Resource, Result } from './api_types.ts'; +import type { + ArtistCreditName, + Artwork, + EntityId, + HarmonyMedium, + HarmonyRelease, + HarmonyTrack, +} from '@/harmonizer/types.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.MISSING, + }; + + readonly entityTypeMap = { + artist: 'artist', + release: 'album', + }; + + 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://www.tidal.com/browse/'); + } + + 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 { + // 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 snapshot = await this.snaps.cache(url, { + fetch: this.fetch, + requestInit: { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }, + policy: { + maxAge: 60 * 60 * 24, // 24 hours + }, + }); + + const result = await snapshot.content.json(); + return result.access_token; + } +} + +export class TidalReleaseLookup extends ReleaseLookup { + constructReleaseApiUrl(): URL { + const { method, value, region } = this.lookup; + let lookupUrl: URL; + const query = new URLSearchParams({ + countryCode: region || 'US', + }); + 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 { + const apiUrl = this.constructReleaseApiUrl(); + + let cacheEntry, release; + if (this.lookup.method === 'gtin') { + cacheEntry = await this.provider.query>( + apiUrl, + this.options.snapshotMaxTimestamp, + ); + + if (cacheEntry.content.data.length === 0) { + throw new ResponseError(this.provider.name, 'API returned no results for this barcode', apiUrl); + } + release = cacheEntry.content.data[0].resource; + } else { // if (method === 'id') { + cacheEntry = await this.provider.query>( + apiUrl, + this.options.snapshotMaxTimestamp, + ); + release = cacheEntry.content.resource; + } + + 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 || 'US', + 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)); + if (!content.metadata.total || content.metadata.total <= tracklist.length) { + break; + } + offset += limit; + query.set('offset', String(offset)); + this.updateCacheTime(timestamp); + } + + 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), + info: this.generateReleaseInfo(), + }; + } + + private convertRawTracklist(tracklist: AlbumItem[]): 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.volumeNumber !== medium.number) { + if (medium.number) { + result.push(medium); + } + + medium = { + number: item.volumeNumber, + format: 'Digital Media', + tracklist: [], + }; + } + + medium.tracklist.push(this.convertRawTrack(item)); + }); + + // 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: Artist): 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 maxSize = 0; + images.forEach((i) => { + if (i.width > maxSize) { + largestImage = i; + maxSize = i.width; + } + }); + if (!largestImage) return []; + return [{ + url: new URL(largestImage.url), + types: ['front'], + }]; + } +} + +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 */ From 7aedeea8261aae27b4a1edc67254dcf3ce19a4d5 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 5 Jun 2024 15:36:12 +0200 Subject: [PATCH 02/12] fix(Tidal): use provided region for lookups --- providers/Tidal/mod.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index dd290089..ec06b531 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -129,6 +129,10 @@ export class TidalReleaseLookup extends ReleaseLookup { } protected async getRawRelease(): Promise { + if (!this.lookup.region && this.options.regions && this.options.regions.size > 0) { + this.lookup.region = [...this.options.regions][0]; + } + const apiUrl = this.constructReleaseApiUrl(); let cacheEntry, release; From 76633247b35ad82a8ef8025578635ec978742c4c Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 5 Jun 2024 17:50:52 +0200 Subject: [PATCH 03/12] refactor(Tidal): use MetadataProvider.fetchJSON for fetching access token --- providers/Tidal/api_types.ts | 7 +++++++ providers/Tidal/mod.ts | 9 ++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/providers/Tidal/api_types.ts b/providers/Tidal/api_types.ts index b03ca60c..2401cd35 100644 --- a/providers/Tidal/api_types.ts +++ b/providers/Tidal/api_types.ts @@ -90,3 +90,10 @@ export type Result = { data: Resource[]; metadata: ResultMetadata; }; + +export type TokenResult = { + access_token: string; + expires_in: number; + scope: string; + token_type: string; +}; diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index ec06b531..f1bea1fe 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -5,7 +5,7 @@ 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, Artist, Image, Resource, Result } from './api_types.ts'; +import type { Album, AlbumItem, ApiError, Artist, Image, Resource, Result, TokenResult } from './api_types.ts'; import type { ArtistCreditName, Artwork, @@ -90,8 +90,8 @@ export default class TidalProvider extends MetadataProvider { const body = new URLSearchParams(); body.append('grant_type', 'client_credentials'); body.append('client_id', tidalClientId); - const snapshot = await this.snaps.cache(url, { - fetch: this.fetch, + + const cacheEntry = await this.fetchJSON(url, { requestInit: { method: 'POST', headers: { @@ -105,8 +105,7 @@ export default class TidalProvider extends MetadataProvider { }, }); - const result = await snapshot.content.json(); - return result.access_token; + return cacheEntry?.content?.access_token; } } From 374438ac18cc090fa6b2299dbdb7ddee9167e748 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 5 Jun 2024 18:24:32 +0200 Subject: [PATCH 04/12] feat(Tidal): extended models to follow documented API schema --- providers/Tidal/api_types.ts | 53 ++++++++++++++++++++++++++++++------ providers/Tidal/mod.ts | 4 +-- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/providers/Tidal/api_types.ts b/providers/Tidal/api_types.ts index 2401cd35..ecf4bd87 100644 --- a/providers/Tidal/api_types.ts +++ b/providers/Tidal/api_types.ts @@ -1,24 +1,31 @@ -export type Artist = { +export type SimpleArtist = { /** The Tidal ID */ id: string; name: string; picture: Image[]; main: boolean; +}; + +export type Artist = SimpleArtist & { tidalUrl: string; + popularity: number; }; -export type Album = { +export type SimpleAlbum = { /** The Tidal ID */ id: string; - barcodeId: string; title: string; - artists: Artist[]; + 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; - imageCover: Image[]; - videoCover: Image[]; numberOfVolumes: number; numberOfTracks: number; numberOfVideos: number; @@ -27,25 +34,43 @@ export type Album = { mediaMetadata: MediaMetadata; properties: Properties; tidalUrl: string; + providerInfo: ProviderInfo; + popularity: number; }; -export type AlbumItem = { +export type CommonAlbumItem = { artifactType: 'track' | 'video'; /** The Tidal ID */ id: string; title: string; - artists: Artist[]; + 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; - properties: Properties; 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; @@ -68,6 +93,16 @@ export type Properties = { 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; diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index f1bea1fe..5b2d7dc1 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -5,7 +5,7 @@ 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, Artist, Image, Resource, Result, TokenResult } from './api_types.ts'; +import type { Album, AlbumItem, ApiError, Image, Resource, Result, SimpleArtist, TokenResult } from './api_types.ts'; import type { ArtistCreditName, Artwork, @@ -252,7 +252,7 @@ export class TidalReleaseLookup extends ReleaseLookup { return result; } - private convertRawArtist(artist: Artist): ArtistCreditName { + private convertRawArtist(artist: SimpleArtist): ArtistCreditName { return { name: artist.name, creditedName: artist.name, From dced9bb7c5ad0f6892a36108ea3fb48729833bb5 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 5 Jun 2024 18:32:47 +0200 Subject: [PATCH 05/12] feat(Tidal): use providerInfo for the label, if present --- providers/Tidal/mod.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index 5b2d7dc1..20478085 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -13,6 +13,7 @@ import type { HarmonyMedium, HarmonyRelease, HarmonyTrack, + Label, } from '@/harmonizer/types.ts'; // See https://developer.tidal.com/reference/web-api @@ -41,7 +42,7 @@ export default class TidalProvider extends MetadataProvider { 'duration precision': DurationPrecision.SECONDS, 'GTIN lookup': FeatureQuality.GOOD, 'MBID resolving': FeatureQuality.PRESENT, - 'release label': FeatureQuality.MISSING, + 'release label': FeatureQuality.BAD, }; readonly entityTypeMap = { @@ -204,6 +205,7 @@ export class TidalReleaseLookup extends ReleaseLookup { status: 'Official', packaging: 'None', images: this.getLargestCoverImage(rawRelease.imageCover), + labels: this.getLabels(rawRelease), info: this.generateReleaseInfo(), }; } @@ -275,6 +277,18 @@ export class TidalReleaseLookup extends ReleaseLookup { 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 { From 4548fb68447954391c3cb81c4004d852ca6ae4a0 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 5 Jun 2024 21:42:42 +0200 Subject: [PATCH 06/12] feat(Tidal): show a warning message if the tracklist contains videos --- providers/Tidal/mod.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index 20478085..338449b7 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -15,6 +15,7 @@ import type { HarmonyTrack, Label, } from '@/harmonizer/types.ts'; +import { pluralWithCount } from '../../utils/plural.ts'; // See https://developer.tidal.com/reference/web-api @@ -218,6 +219,9 @@ export class TidalReleaseLookup extends ReleaseLookup { 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 @@ -233,9 +237,22 @@ export class TidalReleaseLookup extends ReleaseLookup { }; } + 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); From 8481e384dab6576998894bc4c29960dbb133f8c5 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 5 Jun 2024 22:38:10 +0200 Subject: [PATCH 07/12] fix(Tidal): try all regions for release request --- providers/Tidal/mod.ts | 54 ++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index 338449b7..23aff3e6 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -130,29 +130,43 @@ export class TidalReleaseLookup extends ReleaseLookup { } protected async getRawRelease(): Promise { - if (!this.lookup.region && this.options.regions && this.options.regions.size > 0) { - this.lookup.region = [...this.options.regions][0]; - } - - const apiUrl = this.constructReleaseApiUrl(); - let cacheEntry, release; - if (this.lookup.method === 'gtin') { - cacheEntry = await this.provider.query>( - apiUrl, - this.options.snapshotMaxTimestamp, - ); - if (cacheEntry.content.data.length === 0) { - throw new ResponseError(this.provider.name, 'API returned no results for this barcode', apiUrl); + // Try querying all regions + for (const region of this.options.regions || []) { + 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; + } + } } - release = cacheEntry.content.data[0].resource; - } else { // if (method === 'id') { - cacheEntry = await this.provider.query>( - apiUrl, - this.options.snapshotMaxTimestamp, - ); - release = cacheEntry.content.resource; + } + + if (!cacheEntry || !release) { + throw new ResponseError(this.provider.name, 'API returned no results', this.constructReleaseApiUrl()); } this.updateCacheTime(cacheEntry.timestamp); From 4b888588e04f85c3c6767c276d2fe99351d1b48e Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 6 Jun 2024 07:26:44 +0200 Subject: [PATCH 08/12] refactor(Tidal): use session storage to store access key --- providers/Tidal/api_types.ts | 7 ------- providers/Tidal/mod.ts | 35 ++++++++++++++++++++++------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/providers/Tidal/api_types.ts b/providers/Tidal/api_types.ts index ecf4bd87..65672915 100644 --- a/providers/Tidal/api_types.ts +++ b/providers/Tidal/api_types.ts @@ -125,10 +125,3 @@ export type Result = { data: Resource[]; metadata: ResultMetadata; }; - -export type TokenResult = { - access_token: string; - expires_in: number; - scope: string; - token_type: string; -}; diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index 23aff3e6..101e2c25 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -5,7 +5,7 @@ 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, TokenResult } from './api_types.ts'; +import type { Album, AlbumItem, ApiError, Image, Resource, Result, SimpleArtist } from './api_types.ts'; import type { ArtistCreditName, Artwork, @@ -86,6 +86,16 @@ export default class TidalProvider extends MetadataProvider { } 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}`); @@ -93,21 +103,20 @@ export default class TidalProvider extends MetadataProvider { body.append('grant_type', 'client_credentials'); body.append('client_id', tidalClientId); - const cacheEntry = await this.fetchJSON(url, { - requestInit: { - method: 'POST', - headers: { - 'Authorization': `Basic ${auth}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body, - }, - policy: { - maxAge: 60 * 60 * 24, // 24 hours + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', }, + body: body, }); - return cacheEntry?.content?.access_token; + const content = await response.json(); + return { + accessToken: content?.access_token, + validUntil: Date.now() + (content.expires_in * 1000), + }; } } From 7b9d527adf0310c315362de4a9d79b16c426d862 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 6 Jun 2024 07:44:06 +0200 Subject: [PATCH 09/12] fix(Tidal): ensure update cache time after track list fetch --- providers/Tidal/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index 101e2c25..420524de 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -200,12 +200,12 @@ export class TidalReleaseLookup extends ReleaseLookup { 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)); - this.updateCacheTime(timestamp); } return tracklist; From d00a0c5d7ec39ebbe9bc1cd66fb7a34ccc6c212f Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 6 Jun 2024 07:57:15 +0200 Subject: [PATCH 10/12] feat(Tidal): set thumbnail URL to smalles cover image with a width > 200px --- providers/Tidal/mod.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index 420524de..181579e3 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -304,16 +304,23 @@ export class TidalReleaseLookup extends ReleaseLookup { private getLargestCoverImage(images: Image[]): Artwork[] { let largestImage: Image | undefined; - let maxSize = 0; + let thumbnail: Image | undefined; images.forEach((i) => { - if (i.width > maxSize) { + if (!largestImage || i.width > largestImage.width) { largestImage = i; - maxSize = i.width; + } + 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'], }]; } From 201e11181b01dc8f09f4449d65df01e97841964b Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 6 Jun 2024 15:48:50 +0200 Subject: [PATCH 11/12] fix(Tidal): "browse/" in tidal.com paths is optional --- providers/Tidal/mod.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index 181579e3..f6153f33 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -35,7 +35,7 @@ export default class TidalProvider extends MetadataProvider { readonly supportedUrls = new URLPattern({ hostname: '{www.}?tidal.com', - pathname: String.raw`/browse/:type(album|artist)/:id(\d+)`, + pathname: String.raw`(/browse)?/:type(album|artist)/:id(\d+)`, }); readonly features: FeatureQualityMap = { @@ -64,7 +64,7 @@ export default class TidalProvider extends MetadataProvider { readonly apiBaseUrl = 'https://openapi.tidal.com'; constructUrl(entity: EntityId): URL { - return new URL([entity.type, entity.id].join('/'), 'https://www.tidal.com/browse/'); + return new URL([entity.type, entity.id].join('/'), 'https://tidal.com/'); } async query(apiUrl: URL, maxTimestamp?: number): Promise> { From 0e7d07938889cf7f06f2a24a077cb50eb88fdfdb Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 6 Jun 2024 15:57:03 +0200 Subject: [PATCH 12/12] fix(Tidal): ensure default region 'US' is used as a fallback --- providers/Tidal/mod.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/providers/Tidal/mod.ts b/providers/Tidal/mod.ts index f6153f33..e6143c12 100644 --- a/providers/Tidal/mod.ts +++ b/providers/Tidal/mod.ts @@ -9,6 +9,7 @@ import type { Album, AlbumItem, ApiError, Image, Resource, Result, SimpleArtist import type { ArtistCreditName, Artwork, + CountryCode, EntityId, HarmonyMedium, HarmonyRelease, @@ -35,7 +36,7 @@ export default class TidalProvider extends MetadataProvider { readonly supportedUrls = new URLPattern({ hostname: '{www.}?tidal.com', - pathname: String.raw`(/browse)?/:type(album|artist)/:id(\d+)`, + pathname: String.raw`{/browse}?/:type(album|artist)/:id(\d+)`, }); readonly features: FeatureQualityMap = { @@ -51,6 +52,8 @@ export default class TidalProvider extends MetadataProvider { release: 'album', }; + readonly defaultRegion: CountryCode = 'US'; + readonly availableRegions = new Set(availableRegions); readonly releaseLookup = TidalReleaseLookup; @@ -125,7 +128,7 @@ export class TidalReleaseLookup extends ReleaseLookup { const { method, value, region } = this.lookup; let lookupUrl: URL; const query = new URLSearchParams({ - countryCode: region || 'US', + countryCode: region || this.provider.defaultRegion, }); if (method === 'gtin') { lookupUrl = new URL(`/albums/byBarcodeId`, this.provider.apiBaseUrl); @@ -142,7 +145,7 @@ export class TidalReleaseLookup extends ReleaseLookup { let cacheEntry, release; // Try querying all regions - for (const region of this.options.regions || []) { + for (const region of this.options.regions || [this.provider.defaultRegion]) { this.lookup.region = region; const apiUrl = this.constructReleaseApiUrl(); if (this.lookup.method === 'gtin') { @@ -188,7 +191,7 @@ export class TidalReleaseLookup extends ReleaseLookup { const limit = 100; let offset = 0; const query = new URLSearchParams({ - countryCode: this.lookup.region || 'US', + countryCode: this.lookup.region || this.provider.defaultRegion, limit: String(limit), offset: String(offset), });