diff --git a/.env.example b/.env.example index 2cd4d650..741f6264 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,10 @@ PORT= # Spotify app config. See https://developer.spotify.com/documentation/web-api HARMONY_SPOTIFY_CLIENT_ID= HARMONY_SPOTIFY_CLIENT_SECRET= + # Tidal app config. See https://developer.tidal.com/reference/web-api HARMONY_TIDAL_CLIENT_ID= HARMONY_TIDAL_CLIENT_SECRET= + +# Qobuz app config. +HARMONY_QOBUZ_APP_ID= diff --git a/.vscode/settings.json b/.vscode/settings.json index f280afa4..9fb1fcc7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -37,6 +37,7 @@ "nums", "preact", "preorder", + "Qobuz", "runtimes", "secondhandsongs", "smartradio", diff --git a/harmonizer/types.ts b/harmonizer/types.ts index af10b07d..52f2d657 100644 --- a/harmonizer/types.ts +++ b/harmonizer/types.ts @@ -22,6 +22,12 @@ export interface EntityId { * Only used by providers which have region-specific API endpoints or pages. */ region?: CountryCode; + /** + * Language of the entity page or release metadata. + * + * Only used by providers which have language-specific API endpoints or pages. + */ + language?: LanguageCode; /** Preserved slug from the original entity URL. */ slug?: string; } @@ -154,6 +160,9 @@ export type Language = { /** ISO 3166-1 alpha-2 two letter country code (upper case). */ export type CountryCode = string; +/** ISO 639-1 two letter country code (lower case). */ +export type LanguageCode = string; + /** Release lookup options. */ export type ReleaseOptions = Partial<{ withSeparateMedia: boolean; @@ -184,6 +193,8 @@ export type ReleaseLookupParameters = { value: string; /** Release region which was specified with the lookup method. */ region?: CountryCode; + /** Release language which was specified with the lookup method. */ + language?: LanguageCode; }; /** diff --git a/providers/Qobuz/__snapshots__/mod.test.ts.snap b/providers/Qobuz/__snapshots__/mod.test.ts.snap new file mode 100644 index 00000000..3f924bff --- /dev/null +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -0,0 +1,561 @@ +export const snapshot = {}; + +snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` +{ + artists: [ + { + creditedName: "ivycomb", + externalIds: [ + { + id: "11054867", + provider: "qobuz", + type: "artist", + }, + ], + name: "ivycomb", + }, + { + creditedName: "Stephanafro", + externalIds: [ + { + id: "12651905", + provider: "qobuz", + type: "artist", + }, + ], + name: "Stephanafro", + }, + ], + copyright: "2024 Honeycomb Records 2024 Honeycomb Records", + externalLinks: [ + { + types: [ + "paid streaming", + "paid download", + ], + url: "https://www.qobuz.com/us-en/album/suspend-my-belief-ivycomb-stephanafro/rjrikcvbggy0b", + }, + ], + gtin: "0748777112495", + images: [ + { + thumbUrl: "https://static.qobuz.com/images/covers/0b/gy/rjrikcvbggy0b_230.jpg", + types: [ + "front", + ], + url: "https://static.qobuz.com/images/covers/0b/gy/rjrikcvbggy0b_org.jpg", + }, + ], + info: { + messages: [], + providers: [ + { + apiUrl: "https://www.qobuz.com/api.json/0.2/album/get?album_id=rjrikcvbggy0b", + id: "rjrikcvbggy0b", + internalName: "qobuz", + lookup: { + language: "en", + method: "id", + region: "US", + value: "rjrikcvbggy0b", + }, + name: "Qobuz", + url: "https://www.qobuz.com/us-en/album/suspend-my-belief-ivycomb-stephanafro/rjrikcvbggy0b", + }, + ], + }, + labels: [ + { + externalIds: [ + { + id: "6464867", + language: "en", + provider: "qobuz", + region: "US", + slug: "honeycomb-records-4", + type: "label", + }, + ], + name: "Honeycomb Records", + }, + ], + media: [ + { + format: "Digital Media", + number: 1, + tracklist: [ + { + artists: [ + { + creditedName: "ivycomb", + externalIds: [ + { + id: "11054867", + provider: "qobuz", + type: "artist", + }, + ], + name: "ivycomb", + }, + { + creditedName: "Stephanafro", + name: "Stephanafro", + }, + ], + isrc: "QZPEW2452614", + length: 181000, + number: 1, + recording: { + externalIds: [ + { + id: "266726922", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "Suspend My Belief", + }, + { + artists: [ + { + creditedName: "ivycomb", + externalIds: [ + { + id: "11054867", + provider: "qobuz", + type: "artist", + }, + ], + name: "ivycomb", + }, + ], + isrc: "QZPEW2452615", + length: 181000, + number: 2, + recording: { + externalIds: [ + { + id: "266726923", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "Suspend My Belief (Instrumental)", + }, + ], + }, + ], + packaging: "None", + releaseDate: { + date: { + day: 13, + month: 5, + year: 2024, + }, + quality: "assumed-valid", + }, + status: "Official", + title: "Suspend My Belief", + types: [ + "EP", + ], +} +`; + +snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` +{ + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + copyright: "© 2019 NBCUniversal Entertainment. ℗ 2019 NBCUniversal Entertainment.", + externalLinks: [ + { + types: [ + "paid streaming", + ], + url: "https://open.qobuz.com/album/fyg86ag6jm8db", + }, + ], + gtin: "5021732654168", + images: [ + { + thumbUrl: "https://static.qobuz.com/images/covers/db/m8/fyg86ag6jm8db_230.jpg", + types: [ + "front", + ], + url: "https://static.qobuz.com/images/covers/db/m8/fyg86ag6jm8db_org.jpg", + }, + ], + info: { + messages: [], + providers: [ + { + apiUrl: "https://www.qobuz.com/api.json/0.2/album/get?album_id=fyg86ag6jm8db", + id: "fyg86ag6jm8db", + internalName: "qobuz", + lookup: { + method: "id", + value: "fyg86ag6jm8db", + }, + name: "Qobuz", + url: "https://open.qobuz.com/album/fyg86ag6jm8db", + }, + ], + }, + labels: [ + { + externalIds: [ + { + id: "7642777", + provider: "qobuz", + slug: "nbcuniversal-entertainment-1", + type: "label", + }, + ], + name: "NBCUniversal Entertainment", + }, + ], + media: [ + { + format: "Digital Media", + number: 1, + tracklist: [ + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900110", + length: 37000, + number: 1, + recording: { + externalIds: [ + { + id: "322082950", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "Reunion", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900120", + length: 210000, + number: 2, + recording: { + externalIds: [ + { + id: "322082951", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "アンドロイドガール", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900130", + length: 183000, + number: 3, + recording: { + externalIds: [ + { + id: "322082952", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "スクランブル交際", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900140", + length: 229000, + number: 4, + recording: { + externalIds: [ + { + id: "322082953", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "モスキート", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900150", + length: 223000, + number: 5, + recording: { + externalIds: [ + { + id: "322082954", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "乙女解剖", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900160", + length: 186000, + number: 6, + recording: { + externalIds: [ + { + id: "322082955", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "人質交換", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900220", + length: 257000, + number: 7, + recording: { + externalIds: [ + { + id: "322082956", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "シンセカイ案内所", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900170", + length: 224000, + number: 8, + recording: { + externalIds: [ + { + id: "322082957", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "サイコグラム", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900180", + length: 235000, + number: 9, + recording: { + externalIds: [ + { + id: "322082958", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "夜行性ハイズ", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900190", + length: 206000, + number: 10, + recording: { + externalIds: [ + { + id: "322082959", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "ヒバナ -Reloaded-", + }, + { + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], + isrc: "JPPI01900200", + length: 246000, + number: 11, + recording: { + externalIds: [ + { + id: "322082960", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "愛言葉III", + }, + ], + }, + ], + packaging: "None", + releaseDate: { + date: { + day: 22, + month: 5, + year: 2019, + }, + quality: "assumed-valid", + }, + status: "Official", + title: "アンドロイドガール", + types: [ + "Album", + ], +} +`; diff --git a/providers/Qobuz/api_types.ts b/providers/Qobuz/api_types.ts new file mode 100644 index 00000000..a22b44ac --- /dev/null +++ b/providers/Qobuz/api_types.ts @@ -0,0 +1,262 @@ +// Borrowed from https://github.com/Lioncat6/SAMBL-React/blob/2e35c1e9b45831db1cc935d1e646ec5e9f9adb63/lib/providers/qobuz.ts +// License TBD + +export interface QobuzSearchResponse { + query: string; + albums?: QobuzPagingResult; + tracks?: QobuzPagingResult; + artists?: QobuzPagingResult; + playlists?: QobuzPagingResult; + stories?: QobuzPagingResult; +} + +export interface QobuzPagingResult { + limit: number; + offset: number; + total: number; + items: T[]; +} + +export interface QobuzPartialAlbum { + maximum_bit_depth: number; + image: QobuzImage; + media_count: number; + artist: QobuzPartialArtist; + artists: QobuzArtistRole[]; + upc: string; + released_at: number; + label: QobuzLabel; + title: string; + qobuz_id: number; + version: unknown; + url: string; + slug: string; + duration: number; + parental_warning: boolean; + popularity: number; + tracks_count: number; + genre: QobuzGenre; + maximum_channel_count: number; + id: string; + maximum_sampling_rate: number; + articles: unknown[]; + release_date_original: string; + release_date_download: string; + release_date_stream: string; + purchasable: boolean; + streamable: boolean; + previewable: boolean; + sampleable: boolean; + downloadable: boolean; + displayable: boolean; + purchasable_at: number; + streamable_at: number; + hires: boolean; + hires_streamable: boolean; +} + +export interface QobuzAlbum extends QobuzPartialAlbum { + awards: unknown[]; + goodies: unknown[]; + area: unknown; + catchline: string; + composer: QobuzComposer; + created_at: number; + genres_list: string[]; + period: unknown; + copyright: string; + is_official: boolean; + maximum_technical_specifications: string; + product_sales_factors_monthly: number; + product_sales_factors_weekly: number; + product_sales_factors_yearly: number; + product_type: string; + product_url: string; + recording_information: string; + relative_url: string; + release_tags: unknown[]; + release_type: string; + subtitle: string; + track_ids: number[]; + tracks?: QobuzPagingResult; + albums_same_artist: QobuzAlbumsSameArtist; + description: string; + description_language?: string; +} + +export interface QobuzPartialTrack { + maximum_bit_depth: number; + copyright: string; + performers: string; + audio_info: AudioInfo; + performer: QobuzPerformer; + album?: QobuzPartialAlbum; + work: unknown; + isrc: string; + title: string; + version: unknown; + duration: number; + parental_warning: boolean; + track_number: number; + maximum_channel_count: number; + id: number; + media_number: number; + maximum_sampling_rate: number; + release_date_original: string; + release_date_download: string; + release_date_stream: string; + release_date_purchase: string; + purchasable: boolean; + streamable: boolean; + previewable: boolean; + sampleable: boolean; + downloadable: boolean; + displayable: boolean; + purchasable_at: number; + streamable_at: number; + hires: boolean; + hires_streamable: boolean; + maximum_technical_specifications?: string; + composer?: QobuzPartialComposer; +} + +export interface QobuzTrack extends QobuzPartialTrack { + album: QobuzAlbum; + created_at: number; + indexed_at: number; + articles: unknown[]; + has_lyrics: boolean; +} + +export interface QobuzPartialArtist { + picture: null; // Literally always null, like *always* + image: QobuzImage | null; + name: string; + slug: string; + albums_count: number; + id: number; +} + +export interface QobuzArtist extends QobuzPartialArtist { + albums_as_primary_artist_count: number; + albums_as_primary_composer_count: number; + similar_artist_ids: number[]; + biography?: QobuzBiography; + information: unknown; + tracks?: QobuzPagingResult; + tracks_appears_on?: QobuzPagingResult; + albums?: QobuzPagingResult; + albums_without_last_release?: QobuzPagingResult; +} + +export interface QobuzBiography { + summary: string; + content: string; + source?: string; + language?: string; +} + +export interface QobuzArtistRole { + id: number; + name: string; + roles: string[]; +} + +export interface QobuzPlaylist { + id: number; + name: string; + description: string; + tracks_count: number; + users_count: number; + duration: number; + public_at: number; + created_at: number; + updated_at: number; + is_public: boolean; + is_collaborative: boolean; + owner: QobuzOwner; + indexed_at: number; + slug: string; + genres: unknown[]; + images: string[]; + is_published: boolean; + is_featured: boolean; + published_from: unknown; + published_to: unknown; + images150: string[]; + images300: string[]; +} + +export interface QobuzImage { + small: string; + thumbnail: string; + large: string; + extralarge?: string; + mega?: string; + back?: string | null; +} + +export interface QobuzLabel { + name: string; + id: number; + albums_count: number; + supplier_id: number; + slug: string; +} + +export interface QobuzGenre { + path: number[]; + color?: string; + name: string; + id: number; + slug: string; +} + +export interface AudioInfo { + replaygain_track_peak: number; + replaygain_track_gain: number; +} + +export interface QobuzPerformer { + name: string; + id: number; +} + +export interface QobuzPartialComposer { + name: string; + id: number; +} + +export interface QobuzComposer extends QobuzPartialComposer { + slug: string; + albums_count: number; + picture: unknown; + image: unknown; +} + +export interface QobuzOwner { + id: number; + name: string; +} + +export interface QobuzAlbumsSameArtist { + items: unknown[]; +} + +export interface QobuzMinimalArtist { + name: string; + id?: number; +} + +//Extended Types +export interface QobuzExtendedArtist extends QobuzPartialArtist, Partial> {} +export interface QobuzExtendedArtistRole + extends QobuzArtistRole, Partial> {} +export interface QobuzExtendedAlbum extends QobuzPartialAlbum, Partial> {} +export interface QobuzExtendedTrack extends QobuzPartialTrack, Partial> {} + +export type ApiError = { + code: number; + status: string; + message: string; +}; diff --git a/providers/Qobuz/mod.test.ts b/providers/Qobuz/mod.test.ts new file mode 100644 index 00000000..fef74344 --- /dev/null +++ b/providers/Qobuz/mod.test.ts @@ -0,0 +1,135 @@ +import '@std/dotenv/load'; +import type { ReleaseOptions } from '@/harmonizer/types.ts'; +import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts'; +import { stubProviderLookups } from '@/providers/test_stubs.ts'; +import { assert } from 'std/assert/assert.ts'; +import { afterAll, describe, it } from '@std/testing/bdd'; +import { assertSnapshot } from '@std/testing/snapshot'; + +import QobuzProvider from './mod.ts'; +import { assertEquals } from 'std/assert/assert_equals.ts'; + +describe('Qobuz provider', () => { + const qobuz = new QobuzProvider(makeProviderOptions()); + const lookupStub = stubProviderLookups(qobuz); + + // Standard options which have an effect for Qobuz. + const releaseOptions: ReleaseOptions = { + withISRC: true, + withAllTrackArtists: true, + }; + + describeProvider(qobuz, { + urls: [{ + description: 'open.qobuz release page', + url: new URL('https://open.qobuz.com/album/n288n588k0dza'), + id: { type: 'album', id: 'n288n588k0dza' }, + isCanonical: true, + }, { + description: 'play.qobuz release page', + url: new URL('https://play.qobuz.com/album/n288n588k0dza'), + id: { type: 'album', id: 'n288n588k0dza' }, + }, { + description: 'www.qobuz release page with locale and slug', + url: new URL('https://www.qobuz.com/fr-fr/album/let-me-battle-9lana/n288n588k0dza'), + id: { type: 'album', id: 'n288n588k0dza', region: 'FR', language: 'fr', slug: 'let-me-battle-9lana' }, + isCanonical: true, + }, { + description: 'open.qobuz artist page', + url: new URL('https://open.qobuz.com/artist/19452726'), + id: { type: 'artist', id: '19452726' }, + isCanonical: true, + }, { + description: 'www.qobuz artist page with locale and slug', + url: new URL('https://www.qobuz.com/us-en/interpreter/9lana/19452726'), + id: { type: 'interpreter', id: '19452726', region: 'US', language: 'en', slug: '9lana' }, + isCanonical: true, + }, { + description: 'open.qobuz track page', + url: new URL('https://open.qobuz.com/track/285222652'), + id: { type: 'track', id: '285222652' }, + isCanonical: true, + }, { + description: 'play.qobuz track page', + url: new URL('https://play.qobuz.com/track/285222652'), + id: { type: 'track', id: '285222652' }, + }, { + description: 'www.qobuz label page with locale and slug', + url: new URL('https://www.qobuz.com/us-en/label/honeycomb-records-4/download-streaming-albums/6464867'), + id: { type: 'label', id: '6464867', region: 'US', language: 'en', slug: 'honeycomb-records-4' }, + isCanonical: true, + }, { + description: 'play.qobuz label page', + url: new URL('https://play.qobuz.com/label/97377'), + id: { type: 'label', id: '97377' }, + isCanonical: true, + }, { + description: 'playlist page', + url: new URL('https://play.qobuz.com/playlist/56730990'), + id: undefined, + }], + invalidIds: [], + releaseLookup: [{ + description: 'single by two artists', + release: new URL('https://www.qobuz.com/us-en/album/suspend-my-belief-ivycomb-stephanafro/rjrikcvbggy0b'), + options: releaseOptions, + assert: async (release, ctx) => { + await assertSnapshot(ctx, release); + assert(release.artists?.length === 2, 'Release should have two artists'); + const allTracks = release.media.flatMap((medium) => medium.tracklist); + assert(allTracks.every((track) => track.isrc), 'All tracks should have an ISRC'); + assert( + release.externalLinks.find((link) => + link.types?.includes('paid streaming') && link.types.includes('paid download') + ), + 'Release should be streamable and downloadable', + ); + }, + }, { + description: 'find release by (zero-padded) GTIN', + release: 198884774947, + assert: (release) => { + assertEquals(release.gtin, '0198884774947', 'Qobuz GTIN should be zero-padded'); + assert( + release.externalLinks.find((link) => link.url.includes('jkfpv4xzc6zyc')), + 'GTIN search did not return the expected release', + ); + }, + }, { + description: 'non-downloadable album', + release: new URL('https://open.qobuz.com/album/fyg86ag6jm8db'), + options: releaseOptions, + assert: async (release, ctx) => { + await assertSnapshot(ctx, release); + assert( + release.externalLinks.find((link) => + link.types?.includes('paid streaming') && !link.types.includes('paid download') + ), + 'Release should be streamable but not downloadable', + ); + }, + }], + }); + + it('QobuzProvider.constructUrl does not create invalid URLs', () => { + assertEquals( + qobuz.constructUrl({ type: 'artist', id: '28025284', region: 'DE' }).href, + 'https://www.qobuz.com/de-de/interpreter/-/28025284', + 'Localized www artist URLs use the entity type "interpreter"', + ); + assertEquals( + qobuz.constructUrl({ type: 'track', id: '285222652', region: 'US' }).href, + 'https://open.qobuz.com/track/285222652', + 'Region should be ignored for track, www track URLs are generally invalid', + ); + assertEquals( + qobuz.constructUrl({ type: 'label', id: '97377', region: 'FR' }).href, + 'https://play.qobuz.com/label/97377', + 'Region should be ignored for label without slug, URL would be invalid', + ); + }); + + afterAll(() => { + lookupStub.restore(); + }); +}); diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts new file mode 100644 index 00000000..c0f25e9b --- /dev/null +++ b/providers/Qobuz/mod.ts @@ -0,0 +1,351 @@ +import { capitalizeReleaseType } from '@/harmonizer/release_types.ts'; +import { type ApiQueryOptions, type CacheEntry, MetadataApiProvider, ReleaseApiLookup } from '@/providers/base.ts'; +import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts'; +import { getFromEnv } from '@/utils/config.ts'; +import { parseHyphenatedDate, PartialDate } from '@/utils/date.ts'; +import { ResponseError } from '@/utils/errors.ts'; +import { isEqualGTIN } from '@/utils/gtin.ts'; +import type { + ArtistCreditName, + Artwork, + EntityId, + HarmonyMedium, + HarmonyRelease, + HarmonyTrack, + LinkType, + ReleaseGroupType, +} from '@/harmonizer/types.ts'; +import type { + ApiError, + QobuzAlbum, + QobuzExtendedAlbum, + QobuzMinimalArtist, + QobuzPartialTrack, + QobuzSearchResponse, +} from './api_types.ts'; +import { availableRegionsAndLanguages, makeQobuzLocale } from './regions.ts'; +import { ResponseError as SnapResponseError } from 'snap-storage'; + +const qobuzAppId = getFromEnv('HARMONY_QOBUZ_APP_ID') || ''; + +export default class QobuzProvider extends MetadataApiProvider { + readonly name = 'Qobuz'; + + readonly supportedUrls = new URLPattern({ + hostname: '(play|www|open).qobuz.com', + pathname: + '/:region(\\w{2}-\\w{2})?/:type(artist|album|track|interpreter|label)/:slug?{/download-streaming-albums}?/:id', + }); + + override readonly features: FeatureQualityMap = { + 'cover size': 3000, + 'duration precision': DurationPrecision.SECONDS, + 'GTIN lookup': FeatureQuality.GOOD, + 'MBID resolving': FeatureQuality.EXPENSIVE, + 'release label': FeatureQuality.PRESENT, + }; + + readonly entityTypeMap = { + artist: ['artist', 'interpreter'], + release: 'album', + recording: 'track', + label: 'label', + }; + + override readonly availableRegions = new Set(Object.keys(availableRegionsAndLanguages)); + + readonly releaseLookup = QobuzReleaseLookup; + + override readonly launchDate: PartialDate = { + year: 2007, + month: 9, + day: 18, + }; + + readonly apiBaseUrl = 'https://www.qobuz.com/api.json/0.2/'; + + constructUrl(entity: EntityId): URL { + let { type, region, slug } = entity; + // Prefer the more reliable, localized www.qobuz.com URL if we know the region. + if (region && type !== 'track') { + const locale = makeQobuzLocale(region, entity.language); + if (type === 'artist') { + // For some reason www.qobuz.com artist URLs are invalid. + type = 'interpreter'; + } + if (!slug && type !== 'label') { + // Use a placeholder slug, except for labels which would become invalid. + slug = '-'; + } + if (locale && slug) { + if (type === 'label') { + slug += '/download-streaming-albums'; + } + return new URL([locale, type, slug, entity.id].join('/'), 'https://www.qobuz.com'); + } + } + // Fallback to open.qobuz.com URL without region and slug. + if (entity.type === 'label') { + // Qobuz doesn't have label pages on open.qobuz.com, but they do on play.qobuz.com + return new URL([entity.type, entity.id].join('/'), 'https://play.qobuz.com'); + } + return new URL([entity.type, entity.id].join('/'), 'https://open.qobuz.com'); + } + + override extractEntityFromUrl(url: URL): EntityId | undefined { + const entityId = super.extractEntityFromUrl(url); + if (entityId?.region) { + // Split Qobuz locale into country and language. + const [country, language] = entityId.region.split('-', 2); + entityId.region = country; + entityId.language = language.toLowerCase(); + } + return entityId; + } + + override getLinkTypesForEntity(): LinkType[] { + return ['paid streaming', 'paid download']; + } + + async query(apiUrl: URL, options: ApiQueryOptions): Promise> { + try { + const cacheEntry = await this.fetchJSON(apiUrl, { + policy: { maxTimestamp: options.snapshotMaxTimestamp }, + requestInit: { + headers: { + 'X-App-Id': qobuzAppId, + }, + }, + }); + const error = cacheEntry.content as ApiError; + if (error.message) { + throw new QobuzResponseError(error, apiUrl); + } + return cacheEntry; + } catch (error) { + let apiError: ApiError | undefined; + if (error instanceof SnapResponseError) { + try { + // Clone the response so the body of the original response can be + // consumed later if the error gets re-thrown. + apiError = await error.response.clone().json(); + } catch { + // Ignore secondary JSON parsing error, rethrow original error. + } + } + if (apiError?.message) { + throw new QobuzResponseError(apiError, apiUrl); + } else { + throw error; + } + } + } +} + +export class QobuzReleaseLookup extends ReleaseApiLookup { + /** + * Pads barcodes to 13 digits with leading zeros + * @param barcode Barcode to pad + * @returns Padded barcode + */ + private padBarcode(barcode: string): string { + return barcode.padStart(13, '0'); + } + + constructReleaseApiUrl(): URL { + if (this.lookup.method === 'gtin') { + return new URL(`album/search?query=${this.padBarcode(this.lookup.value)}`, this.provider.apiBaseUrl); + } else { // if (this.lookup.method === 'id') + return new URL(`album/get?album_id=${this.lookup.value}`, this.provider.apiBaseUrl); + } + } + + protected async getRawRelease(): Promise { + let apiUrl = this.constructReleaseApiUrl(); + if (this.lookup.method === 'gtin') { + const { content: searchResponse, timestamp } = await this.provider.query(apiUrl, { + snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, + }); + this.updateCacheTime(timestamp); + const matchingAlbum = searchResponse.albums?.items.find((album) => isEqualGTIN(album.upc, this.lookup.value)); + if (!matchingAlbum) { + throw new ResponseError(this.provider.name, 'API returned no matching results', apiUrl); + } + this.lookup.method = 'id'; + this.lookup.value = matchingAlbum.id; + apiUrl = this.constructReleaseApiUrl(); + } + + const { content: release, timestamp } = await this.provider.query(apiUrl, { + snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, + }); + this.updateCacheTime(timestamp); + + // If the lookup region is not known (through the release URL), try to find a valid region in the options. + if (!this.lookup.region && this.options.regions) { + for (const region of this.options.regions) { + if (makeQobuzLocale(region)) { + this.lookup.region = region; + break; + } + } + } + + return release; + } + + protected convertRawRelease(rawRelease: QobuzAlbum): HarmonyRelease { + if (!this.entity) { + this.entity = { + id: rawRelease.id, + type: 'album', + slug: rawRelease.slug, + region: this.lookup.region, + language: this.lookup.language, + }; + } + + const linkTypes: LinkType[] = []; + + if (rawRelease.streamable) { + linkTypes.push('paid streaming'); + } + + if (rawRelease.downloadable) { + linkTypes.push('paid download'); + } + + return { + title: rawRelease.title, + artists: this.getAlbumCredits(rawRelease), + media: this.getAlbumMedium(rawRelease), + releaseDate: this.convertReleaseDate(parseHyphenatedDate(rawRelease.release_date_stream)), + copyright: rawRelease.copyright || undefined, + status: 'Official', + types: this.mapAlbumType(rawRelease.release_type), + packaging: 'None', + images: this.getAlbumImage(rawRelease.image.small), + labels: [{ + name: rawRelease.label.name, + externalIds: this.provider.makeExternalIds({ + type: 'label', + id: String(rawRelease.label.id), + slug: rawRelease.label.slug, + // Prefer localized www URL as play.qobuz.com label URLs are only available for logged in users. + // Infer label locale from release as there is no better source. + region: this.lookup.region, + language: this.lookup.language, + }), + }], + externalLinks: [{ + url: this.provider.constructUrl(this.entity).toString(), + types: linkTypes, + }], + info: this.generateReleaseInfo(), + gtin: rawRelease.upc, + }; + } + + private mapAlbumType(rawType: string | undefined): ReleaseGroupType[] | undefined { + if (!rawType) return undefined; + switch (rawType.toLocaleLowerCase()) { + case 'epmini': + return ['EP']; + default: + return [capitalizeReleaseType(rawType)]; + } + } + + private getAlbumImage(url: string | undefined): Artwork[] | undefined { + if (!url) return undefined; + return [{ + url: url.replace(/_\d+/, '_org'), + thumbUrl: url, + types: ['front'], + }]; + } + + private convertRawArtist(rawArtist: QobuzMinimalArtist): ArtistCreditName { + const artist: ArtistCreditName = { + name: rawArtist.name, + creditedName: rawArtist.name, + }; + if (rawArtist.id) { + artist.externalIds = this.provider.makeExternalIds({ type: 'artist', id: String(rawArtist.id) }); + } + return artist; + } + + private getAlbumCredits(album: QobuzExtendedAlbum): ArtistCreditName[] { + const artists: ArtistCreditName[] = []; + const artistIds: number[] = []; + [album.artist, ...album.artists].forEach((artist) => { + if (!artistIds.includes(artist.id)) { + artists.push(this.convertRawArtist(artist)); + artistIds.push(artist.id); + } + }); + return artists; + } + + private getAlbumMedium(album: QobuzAlbum): HarmonyMedium[] { + const result: HarmonyMedium[] = []; + let medium: HarmonyMedium = { + number: 1, + format: 'Digital Media', + tracklist: [], + }; + + const tracks = album.tracks?.items || []; + tracks.forEach((track) => { + if (track.media_number !== medium.number) { + result.push(medium); + medium = { + number: track.media_number, + format: 'Digital Media', + tracklist: [], + }; + } + medium.tracklist.push(this.convertRawTrack(track)); + }); + result.push(medium); + return result; + } + + private convertRawTrack(rawTrack: QobuzPartialTrack): HarmonyTrack { + const mainArtist = rawTrack.performer; + const additionalArtists = this.parsePerformers(rawTrack.performers).filter(({ name, roles }) => + name !== mainArtist.name && roles.some((role) => role === 'MainArtist' || role === 'FeaturedArtist') + ); + return { + title: `${rawTrack.title}${rawTrack.version ? ` (${rawTrack.version})` : ''}`, + artists: [mainArtist, ...additionalArtists].map(this.convertRawArtist.bind(this)), + number: rawTrack.track_number, + length: rawTrack.duration * 1000, + isrc: rawTrack.isrc, + recording: { + externalIds: this.provider.makeExternalIds({ type: 'track', id: String(rawTrack.id) }), + }, + }; + } + + private parsePerformers(performers: string) { + return performers.split(' - ').map((performerAndRoles) => { + const [performer, ...roles] = performerAndRoles.split(', '); + return { + name: performer, + roles, + }; + }); + } +} + +class QobuzResponseError extends ResponseError { + constructor(readonly details: ApiError, url: URL) { + let message = `${details.message} (code ${details.code})`; + if (details.code === 404) { + message += ' / API only returns results which are available in Harmony’s region'; + } + super('Qobuz', message, url); + } +} diff --git a/providers/Qobuz/regions.ts b/providers/Qobuz/regions.ts new file mode 100644 index 00000000..cfe8d552 --- /dev/null +++ b/providers/Qobuz/regions.ts @@ -0,0 +1,77 @@ +import type { CountryCode, LanguageCode } from '@/harmonizer/types.ts'; + +/** + * Maps available regions to their available languages. + * + * @see https://help.qobuz.com/en/articles/10128-where-is-qobuz-available + */ +export const availableRegionsAndLanguages: Record = { + // Argentina + AR: ['es'], + // Australia + AU: ['en'], + // Austria + AT: ['de'], + // Belgium + BE: ['fr', 'nl'], + // Brazil + BR: ['pt'], + // Canada + CA: ['en', 'fr'], + // Chile + CL: ['es'], + // Colombia + CO: ['es'], + // Denmark + DK: ['en'], + // Finland + FI: ['en'], + // France + FR: ['fr'], + // Germany + DE: ['de'], + // Ireland + IE: ['en'], + // Italy + IT: ['it'], + // Japan + JP: ['ja'], + // Luxembourg + LU: ['de', 'fr'], + // Netherlands + NL: ['nl'], + // Mexico + MX: ['es'], + // New Zealand + NZ: ['en'], + // Norway + NO: ['en'], + // Portugal + PT: ['pt'], + // Spain + ES: ['es'], + // Sweden + SE: ['en'], + // Switzerland + CH: ['de', 'fr'], + // United Kingdom + GB: ['en'], + // United States + US: ['en'], +}; + +/** + * Constructs a Qobuz URL locale from the given region and language. + * + * If no language is given, a default language for the region is used. + * + * @returns A valid locale or `undefined` if Qobuz is not available in the given region. + */ +export function makeQobuzLocale(region: CountryCode, language?: LanguageCode): string | undefined { + if (!language) { + language = availableRegionsAndLanguages[region.toUpperCase()]?.[0]; + } + if (language) { + return [region.toLowerCase(), language].join('-'); + } +} diff --git a/providers/base.ts b/providers/base.ts index 69c4005c..a7b1c5f9 100644 --- a/providers/base.ts +++ b/providers/base.ts @@ -1,7 +1,9 @@ import { FeatureQuality, type FeatureQualityMap, type ProviderFeature } from './features.ts'; import { CacheMissError, ProviderError, ResponseError } from '@/utils/errors.ts'; import { pluralWithCount } from '@/utils/plural.ts'; +import { isDefined } from '@/utils/predicate.ts'; import { delay } from 'std/async/delay.ts'; +import { filterValues } from '@std/collections/filter-values'; import { getLogger } from 'std/log/get_logger.ts'; import { rateLimit } from 'utils/async/rateLimit.js'; import { simplifyName } from 'utils/string/simplify.js'; @@ -193,7 +195,11 @@ export abstract class MetadataProvider { /** Creates external entity IDs from the given provider-specific IDs. */ makeExternalIds(...entityIds: Array): ExternalEntityId[] { - return entityIds.map((entityId) => ({ ...entityId, provider: this.internalName })); + return entityIds.map((entityId) => { + // @ts-ignore-error -- `EntityId` is a valid `Record` + entityId = filterValues(entityId, isDefined); + return { ...entityId, provider: this.internalName }; + }); } /** Creates external entity IDs from the given provider entity URL. */ @@ -367,6 +373,9 @@ export abstract class ReleaseLookup = { musicbrainz: 'brand-metabrainz', mora: 'brand-mora', ototoy: 'brand-ototoy', + qobuz: 'brand-qobuz', spotify: 'brand-spotify', tidal: 'brand-tidal', }; diff --git a/server/icons/QobuzOutline.tsx b/server/icons/QobuzOutline.tsx new file mode 100644 index 00000000..585faf9e --- /dev/null +++ b/server/icons/QobuzOutline.tsx @@ -0,0 +1,42 @@ +export default function IconBrandQobuz({ + size = 24, + color = 'currentColor', + stroke = 2, + ...props +}) { + return ( + + + + + + + + + ); +} diff --git a/server/routes/icon-sprite.svg.tsx b/server/routes/icon-sprite.svg.tsx index a53ac988..ae2618f1 100644 --- a/server/routes/icon-sprite.svg.tsx +++ b/server/routes/icon-sprite.svg.tsx @@ -7,6 +7,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 IconBrandQobuz from '@/server/icons/QobuzOutline.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'; @@ -63,6 +64,7 @@ const icons: Icon[] = [ IconBrandMetaBrainz, IconBrandMora, IconBrandOtotoy, + IconBrandQobuz, IconBrandSpotify, IconBrandTidal, IconPuzzle, diff --git a/server/static/harmony.css b/server/static/harmony.css index 67c1522b..2358c076 100644 --- a/server/static/harmony.css +++ b/server/static/harmony.css @@ -34,6 +34,7 @@ --mora: #02082a; --musicbrainz: #ba478f; --ototoy: #e07e01; + --qobuz: #000000; --spotify: #1db954; --tidal: #000000; } @@ -472,6 +473,9 @@ label.musicbrainz, td.musicbrainz { label.ototoy, td.ototoy { background-color: var(--ototoy); } +label.qobuz, td.qobuz { + background-color: var(--qobuz); +} label.spotify, td.spotify { background-color: var(--spotify); } diff --git a/testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=fyg86ag6jm8db b/testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=fyg86ag6jm8db new file mode 100644 index 00000000..2a8b4125 --- /dev/null +++ b/testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=fyg86ag6jm8db @@ -0,0 +1 @@ +{"maximum_bit_depth":16,"image":{"small":"https:\/\/static.qobuz.com\/images\/covers\/db\/m8\/fyg86ag6jm8db_230.jpg","thumbnail":"https:\/\/static.qobuz.com\/images\/covers\/db\/m8\/fyg86ag6jm8db_50.jpg","large":"https:\/\/static.qobuz.com\/images\/covers\/db\/m8\/fyg86ag6jm8db_600.jpg","back":null},"media_count":1,"artist":{"image":null,"name":"DECO*27","id":3284769,"albums_count":165,"slug":"deco27-10003284769","picture":null},"artists":[{"id":3284769,"name":"DECO*27","roles":["main-artist"]}],"upc":"5021732654168","released_at":1558476000,"label":{"name":"NBCUniversal Entertainment","id":7642777,"albums_count":1588,"supplier_id":5,"slug":"nbcuniversal-entertainment-1"},"title":"\u30a2\u30f3\u30c9\u30ed\u30a4\u30c9\u30ac\u30fc\u30eb","qobuz_id":322082949,"version":null,"url":"https:\/\/www.qobuz.com\/fr-fr\/album\/deco27\/fyg86ag6jm8db","slug":"deco27","duration":2236,"parental_warning":false,"popularity":0,"tracks_count":11,"genre":{"path":[91,196,197],"color":"#0070ef","name":"Anime","id":197,"slug":"anime"},"maximum_channel_count":2,"id":"fyg86ag6jm8db","maximum_sampling_rate":44.1,"articles":[],"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"awards":[],"goodies":[],"area":null,"catchline":"","composer":{"id":302857,"name":"Rockwell","slug":"rockwell","albums_count":124,"picture":null,"image":null},"created_at":0,"genres_list":["Film","Film\u2192Anime\/Jeux vid\u00e9o","Film\u2192Anime\/Jeux vid\u00e9o\u2192Anime"],"period":null,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","is_official":true,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo ","product_sales_factors_monthly":0,"product_sales_factors_weekly":0,"product_sales_factors_yearly":0,"product_type":"album","product_url":"\/fr-fr\/album\/deco27\/fyg86ag6jm8db","recording_information":"","relative_url":"\/album\/deco27\/fyg86ag6jm8db","release_tags":[],"release_type":"album","subtitle":"DECO*27","tracks":{"offset":0,"limit":500,"total":11,"items":[{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Composer, Arranger - DECO*27, Arranger, MainArtist","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-6.14},"performer":{"name":"DECO*27","id":3284769},"work":null,"composer":{"name":"Rockwell","id":302857},"isrc":"JPPI01900110","title":"Reunion","version":null,"duration":37,"parental_warning":false,"track_number":1,"maximum_channel_count":2,"id":322082950,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Arranger - DECO*27, MainArtist","audio_info":{"replaygain_track_peak":1,"replaygain_track_gain":-11.54},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900120","title":"\u30a2\u30f3\u30c9\u30ed\u30a4\u30c9\u30ac\u30fc\u30eb","version":null,"duration":210,"parental_warning":false,"track_number":2,"maximum_channel_count":2,"id":322082951,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Arranger - DECO*27, MainArtist","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-10.51},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900130","title":"\u30b9\u30af\u30e9\u30f3\u30d6\u30eb\u4ea4\u969b","version":null,"duration":183,"parental_warning":false,"track_number":3,"maximum_channel_count":2,"id":322082952,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Arranger - DECO*27, MainArtist","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-11.5},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900140","title":"\u30e2\u30b9\u30ad\u30fc\u30c8","version":null,"duration":229,"parental_warning":false,"track_number":4,"maximum_channel_count":2,"id":322082953,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Arranger - DECO*27, MainArtist - emon(Tes.), Arranger","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-10.74},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900150","title":"\u4e59\u5973\u89e3\u5256","version":null,"duration":223,"parental_warning":false,"track_number":5,"maximum_channel_count":2,"id":322082954,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"DECO*27, MainArtist - emon(Tes.), Arranger","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-10.9},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900160","title":"\u4eba\u8cea\u4ea4\u63db","version":null,"duration":186,"parental_warning":false,"track_number":6,"maximum_channel_count":2,"id":322082955,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Arranger - DECO*27, MainArtist","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-11.33},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900220","title":"\u30b7\u30f3\u30bb\u30ab\u30a4\u6848\u5185\u6240","version":null,"duration":257,"parental_warning":false,"track_number":7,"maximum_channel_count":2,"id":322082956,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Arranger - DECO*27, MainArtist","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-11.38},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900170","title":"\u30b5\u30a4\u30b3\u30b0\u30e9\u30e0","version":null,"duration":224,"parental_warning":false,"track_number":8,"maximum_channel_count":2,"id":322082957,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Arranger - DECO*27, MainArtist","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-11.04},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900180","title":"\u591c\u884c\u6027\u30cf\u30a4\u30ba","version":null,"duration":235,"parental_warning":false,"track_number":9,"maximum_channel_count":2,"id":322082958,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Arranger - DECO*27, MainArtist","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-11.75},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900190","title":"\u30d2\u30d0\u30ca -Reloaded-","version":null,"duration":206,"parental_warning":false,"track_number":10,"maximum_channel_count":2,"id":322082959,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"\u00a9 2019 NBCUniversal Entertainment. \u2117 2019 NBCUniversal Entertainment.","performers":"Rockwell, Arranger - DECO*27, MainArtist","audio_info":{"replaygain_track_peak":0.966064,"replaygain_track_gain":-11.42},"performer":{"name":"DECO*27","id":3284769},"work":null,"isrc":"JPPI01900200","title":"\u611b\u8a00\u8449III","version":null,"duration":246,"parental_warning":false,"track_number":11,"maximum_channel_count":2,"id":322082960,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2019-05-22","release_date_download":"2019-05-22","release_date_stream":"2019-05-22","release_date_purchase":"2019-05-22","purchasable":false,"streamable":true,"previewable":true,"sampleable":true,"downloadable":false,"displayable":true,"purchasable_at":null,"streamable_at":1749884400,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "}]},"description":""} \ No newline at end of file diff --git a/testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=jkfpv4xzc6zyc b/testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=jkfpv4xzc6zyc new file mode 100644 index 00000000..7ed1ce75 --- /dev/null +++ b/testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=jkfpv4xzc6zyc @@ -0,0 +1 @@ +{"maximum_bit_depth":24,"image":{"small":"https:\/\/static.qobuz.com\/images\/covers\/yc\/6z\/jkfpv4xzc6zyc_230.jpg","thumbnail":"https:\/\/static.qobuz.com\/images\/covers\/yc\/6z\/jkfpv4xzc6zyc_50.jpg","large":"https:\/\/static.qobuz.com\/images\/covers\/yc\/6z\/jkfpv4xzc6zyc_600.jpg","back":null},"media_count":1,"artist":{"image":null,"name":"The Vanished People","id":12514171,"albums_count":32,"slug":"the-vanished-people","picture":null},"artists":[{"id":12514171,"name":"The Vanished People","roles":["main-artist"]},{"id":2260398,"name":"Trickle","roles":["featured-artist"]}],"upc":"0198884774947","released_at":1728597600,"label":{"name":"The Vanished People","id":2717179,"albums_count":32,"supplier_id":128,"slug":"the-vanished-people"},"title":"CLUTCH","qobuz_id":293468238,"version":null,"url":"https:\/\/www.qobuz.com\/fr-fr\/album\/clutch-the-vanished-people\/jkfpv4xzc6zyc","slug":"clutch-the-vanished-people","duration":394,"parental_warning":false,"popularity":0,"tracks_count":2,"genre":{"path":[64,129],"color":"#0070ef","name":"Dance","id":129,"slug":"dance"},"maximum_channel_count":2,"id":"jkfpv4xzc6zyc","maximum_sampling_rate":44.1,"articles":[],"release_date_original":"2024-10-11","release_date_download":"2024-10-11","release_date_stream":"2024-10-11","purchasable":true,"streamable":true,"previewable":true,"sampleable":true,"downloadable":true,"displayable":true,"purchasable_at":1729321200,"streamable_at":1729321200,"hires":true,"hires_streamable":true,"awards":[],"goodies":[],"area":null,"catchline":"","composer":{"id":573076,"name":"Various Composers","slug":"various-composers","albums_count":12699356,"picture":null,"image":null},"created_at":0,"genres_list":["\u00c9lectronique","\u00c9lectronique\u2192Dance"],"period":null,"copyright":"2024 The Vanished People","is_official":true,"maximum_technical_specifications":"24 bits \/ 44.1 kHz - Stereo","product_sales_factors_monthly":0,"product_sales_factors_weekly":0,"product_sales_factors_yearly":0,"product_type":"single","product_url":"\/fr-fr\/album\/clutch-the-vanished-people\/jkfpv4xzc6zyc","recording_information":"","relative_url":"\/album\/clutch-the-vanished-people\/jkfpv4xzc6zyc","release_tags":[],"release_type":"single","subtitle":"The Vanished People","tracks":{"offset":0,"limit":500,"total":2,"items":[{"maximum_bit_depth":24,"copyright":"2024 The Vanished People","performers":"Trickle, FeaturedArtist - Jacopo Maria Gaeta, Composer, Lyricist - The Vanished People, MainArtist - Piero Bonanni, Composer, Lyricist - Trickle \u200e, Composer, Lyricist","audio_info":{"replaygain_track_peak":0.88681,"replaygain_track_gain":-3.52},"performer":{"name":"The Vanished People","id":12514171},"work":null,"composer":{"name":"Jacopo Maria Gaeta","id":11345646},"isrc":"QZWFL2429860","title":"CLUTCH","version":null,"duration":197,"parental_warning":false,"track_number":1,"maximum_channel_count":2,"id":293468239,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2024-10-11","release_date_download":"2024-10-11","release_date_stream":"2024-10-11","release_date_purchase":"2024-10-11","purchasable":true,"streamable":true,"previewable":true,"sampleable":true,"downloadable":true,"displayable":true,"purchasable_at":1729321200,"streamable_at":1729321200,"hires":true,"hires_streamable":true},{"maximum_bit_depth":24,"copyright":"2024 The Vanished People","performers":"Trickle, FeaturedArtist - Jacopo Maria Gaeta, Composer, Lyricist - The Vanished People, MainArtist - Piero Bonanni, Composer, Lyricist - Trickle \u200e, Composer, Lyricist","audio_info":{"replaygain_track_peak":0.88678,"replaygain_track_gain":-2.78},"performer":{"name":"The Vanished People","id":12514171},"work":null,"composer":{"name":"Jacopo Maria Gaeta","id":11345646},"isrc":"QZWFL2429861","title":"CLUTCH","version":"Instrumental","duration":197,"parental_warning":false,"track_number":2,"maximum_channel_count":2,"id":293468240,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2024-10-11","release_date_download":"2024-10-11","release_date_stream":"2024-10-11","release_date_purchase":"2024-10-11","purchasable":true,"streamable":true,"previewable":true,"sampleable":true,"downloadable":true,"displayable":true,"purchasable_at":1729321200,"streamable_at":1729321200,"hires":true,"hires_streamable":true}]},"description":""} \ No newline at end of file diff --git a/testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=rjrikcvbggy0b b/testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=rjrikcvbggy0b new file mode 100644 index 00000000..f0915f1a --- /dev/null +++ b/testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=rjrikcvbggy0b @@ -0,0 +1 @@ +{"maximum_bit_depth":16,"image":{"small":"https:\/\/static.qobuz.com\/images\/covers\/0b\/gy\/rjrikcvbggy0b_230.jpg","thumbnail":"https:\/\/static.qobuz.com\/images\/covers\/0b\/gy\/rjrikcvbggy0b_50.jpg","large":"https:\/\/static.qobuz.com\/images\/covers\/0b\/gy\/rjrikcvbggy0b_600.jpg","back":null},"media_count":1,"artist":{"image":null,"name":"ivycomb","id":11054867,"albums_count":88,"slug":"ivycomb","picture":null},"artists":[{"id":11054867,"name":"ivycomb","roles":["main-artist"]},{"id":12651905,"name":"Stephanafro","roles":["main-artist"]}],"upc":"0748777112495","released_at":1715551200,"label":{"name":"Honeycomb Records","id":6464867,"albums_count":50,"supplier_id":122,"slug":"honeycomb-records-4"},"title":"Suspend My Belief","qobuz_id":266726921,"version":null,"url":"https:\/\/www.qobuz.com\/fr-fr\/album\/suspend-my-belief-ivycomb-stephanafro\/rjrikcvbggy0b","slug":"suspend-my-belief-ivycomb-stephanafro","duration":362,"parental_warning":false,"popularity":0,"tracks_count":2,"genre":{"path":[112,119,113],"color":"#0070ef","name":"Alternative & Indie","id":113,"slug":"alternatif-et-inde"},"maximum_channel_count":2,"id":"rjrikcvbggy0b","maximum_sampling_rate":44.1,"articles":[],"release_date_original":"2024-05-13","release_date_download":"2024-05-13","release_date_stream":"2024-05-13","purchasable":true,"streamable":true,"previewable":true,"sampleable":true,"downloadable":true,"displayable":true,"purchasable_at":1748934000,"streamable_at":1748934000,"hires":false,"hires_streamable":false,"awards":[],"goodies":[],"area":null,"catchline":"","created_at":0,"genres_list":["Pop\/Rock","Pop\/Rock\u2192Rock","Pop\/Rock\u2192Rock\u2192Alternatif et Ind\u00e9"],"period":null,"copyright":"2024 Honeycomb Records 2024 Honeycomb Records","is_official":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo ","product_sales_factors_monthly":0,"product_sales_factors_weekly":0,"product_sales_factors_yearly":0,"product_type":"single","product_url":"\/fr-fr\/album\/suspend-my-belief-ivycomb-stephanafro\/rjrikcvbggy0b","recording_information":"","relative_url":"\/album\/suspend-my-belief-ivycomb-stephanafro\/rjrikcvbggy0b","release_tags":[],"release_type":"epmini","subtitle":"ivycomb & Stephanafro","tracks":{"offset":0,"limit":500,"total":2,"items":[{"maximum_bit_depth":16,"copyright":"2024 Honeycomb Records 2024 Honeycomb Records","performers":"ivycomb, MainArtist - Stephanafro, MainArtist - Vivian Graves, Lyricist","audio_info":{"replaygain_track_peak":0.891235,"replaygain_track_gain":-9.78},"performer":{"name":"ivycomb","id":11054867},"work":null,"isrc":"QZPEW2452614","title":"Suspend My Belief","version":null,"duration":181,"parental_warning":false,"track_number":1,"maximum_channel_count":2,"id":266726922,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2024-05-13","release_date_download":"2024-05-13","release_date_stream":"2024-05-13","release_date_purchase":"2024-05-13","purchasable":true,"streamable":true,"previewable":true,"sampleable":true,"downloadable":true,"displayable":true,"purchasable_at":1748934000,"streamable_at":1748934000,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "},{"maximum_bit_depth":16,"copyright":"2024 Honeycomb Records 2024 Honeycomb Records","performers":"ivycomb, MainArtist - Vivian Graves, Lyricist","audio_info":{"replaygain_track_peak":0.891235,"replaygain_track_gain":-6.98},"performer":{"name":"ivycomb","id":11054867},"work":null,"isrc":"QZPEW2452615","title":"Suspend My Belief","version":"Instrumental","duration":181,"parental_warning":false,"track_number":2,"maximum_channel_count":2,"id":266726923,"media_number":1,"maximum_sampling_rate":44.1,"release_date_original":"2024-05-13","release_date_download":"2024-05-13","release_date_stream":"2024-05-13","release_date_purchase":"2024-05-13","purchasable":true,"streamable":true,"previewable":true,"sampleable":true,"downloadable":true,"displayable":true,"purchasable_at":1748934000,"streamable_at":1748934000,"hires":false,"hires_streamable":false,"maximum_technical_specifications":"16 bits \/ 44.1 kHz - Stereo "}]},"description":""} \ No newline at end of file diff --git a/testdata/https!/com.qobuz.www/api.json/0.2/album/search!query=0198884774947 b/testdata/https!/com.qobuz.www/api.json/0.2/album/search!query=0198884774947 new file mode 100644 index 00000000..4f673ff3 --- /dev/null +++ b/testdata/https!/com.qobuz.www/api.json/0.2/album/search!query=0198884774947 @@ -0,0 +1 @@ +{"query":"0198884774947","albums":{"limit":50,"offset":0,"analytics":{"search_external_id":"62bd1290708bc08e9184350348e67294"},"total":1,"items":[{"maximum_bit_depth":24,"image":{"small":"https:\/\/static.qobuz.com\/images\/covers\/yc\/6z\/jkfpv4xzc6zyc_230.jpg","thumbnail":"https:\/\/static.qobuz.com\/images\/covers\/yc\/6z\/jkfpv4xzc6zyc_50.jpg","large":"https:\/\/static.qobuz.com\/images\/covers\/yc\/6z\/jkfpv4xzc6zyc_600.jpg","back":null},"media_count":1,"artist":{"image":null,"name":"The Vanished People","id":12514171,"albums_count":32,"slug":"the-vanished-people","picture":null},"artists":[{"id":12514171,"name":"The Vanished People","roles":["main-artist"]},{"id":2260398,"name":"Trickle","roles":["featured-artist"]}],"upc":"0198884774947","released_at":1728597600,"label":{"name":"The Vanished People","id":2717179,"albums_count":32,"supplier_id":128,"slug":"the-vanished-people"},"title":"CLUTCH","qobuz_id":293468238,"version":null,"url":"https:\/\/www.qobuz.com\/fr-fr\/album\/clutch-the-vanished-people\/jkfpv4xzc6zyc","slug":"clutch-the-vanished-people","duration":394,"parental_warning":false,"popularity":0,"tracks_count":2,"genre":{"path":[64,129],"color":"#0070ef","name":"Dance","id":129,"slug":"dance"},"maximum_channel_count":2,"id":"jkfpv4xzc6zyc","maximum_sampling_rate":44.1,"articles":[],"release_date_original":"2024-10-11","release_date_download":"2024-10-11","release_date_stream":"2024-10-11","purchasable":true,"streamable":true,"previewable":true,"sampleable":true,"downloadable":true,"displayable":true,"purchasable_at":1729321200,"streamable_at":1729321200,"hires":true,"hires_streamable":true}]}} \ No newline at end of file