From b33d3c6c2fd00e31960acf88b696c8ea0a736dfd Mon Sep 17 00:00:00 2001 From: Lioncat6 Date: Thu, 2 Apr 2026 22:34:54 -0500 Subject: [PATCH 01/29] feat(qobuz): Implement Qobuz provider --- .vscode/settings.json | 1 + providers/Qobuz/api_types.ts | 257 +++++++++++++++++++++++++++++ providers/Qobuz/mod.test.ts | 95 +++++++++++ providers/Qobuz/mod.ts | 234 ++++++++++++++++++++++++++ providers/mod.ts | 2 + server/components/ProviderIcon.tsx | 1 + server/icons/QobuzOutline.tsx | 42 +++++ server/routes/icon-sprite.svg.tsx | 2 + server/static/harmony.css | 4 + 9 files changed, 638 insertions(+) create mode 100644 providers/Qobuz/api_types.ts create mode 100644 providers/Qobuz/mod.test.ts create mode 100644 providers/Qobuz/mod.ts create mode 100644 server/icons/QobuzOutline.tsx 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/providers/Qobuz/api_types.ts b/providers/Qobuz/api_types.ts new file mode 100644 index 00000000..dc25deef --- /dev/null +++ b/providers/Qobuz/api_types.ts @@ -0,0 +1,257 @@ +// 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[] +} + +//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..c9cfe850 --- /dev/null +++ b/providers/Qobuz/mod.test.ts @@ -0,0 +1,95 @@ +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 } 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 region and slug', + url: new URL('https://www.qobuz.com/fr-fr/album/let-me-battle-9lana/n288n588k0dza'), + id: { type: 'album', id: 'n288n588k0dza', region: 'FR-FR', slug: 'let-me-battle-9lana' }, + }, { + 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 region and slug', + url: new URL('https://www.qobuz.com/us-en/interpreter/9lana/19452726'), + id: { type: 'interpreter', id: '19452726', region: 'US-EN', slug: '9lana' }, + }, { + 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: 'label page', + url: new URL('https://play.qobuz.com/label/97377'), + id: { type: 'label', id: '97377' }, + isCanonical: true, + }, { + description: '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://play.qobuz.com/album/rjrikcvbggy0b'), + options: releaseOptions, + assert: async (release, ctx) => { + await assertSnapshot(ctx, release); + const allTracks = release.media.flatMap((medium) => medium.tracklist); + assert(allTracks[0].artists?.length === 2, 'Main track should have two artists'); + assert(allTracks.every((track) => track.isrc), 'All tracks should have an ISRC'); + }, + }, { + 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 === 'https://open.qobuz.com/album/jkfpv4xzc6zyc'), + 'GTIN search did not return the expected release', + ); + }, + }], + }); + + afterAll(() => { + lookupStub.restore(); + }); +}); diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts new file mode 100644 index 00000000..52ba612c --- /dev/null +++ b/providers/Qobuz/mod.ts @@ -0,0 +1,234 @@ +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 { + ArtistCreditName, + EntityId, + HarmonyMedium, + HarmonyRelease, + HarmonyTrack, + LinkType, +} from '@/harmonizer/types.ts'; +import { + ApiError, + QobuzAlbum, + QobuzExtendedAlbum, + QobuzPartialTrack, + QobuzSearchResponse, +} from '@/providers/Qobuz/api_types.ts'; + +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?/:type(artist|album|track|interpreter|label)/:slug?/:id', + }); + + override readonly features: FeatureQualityMap = { + 'cover size': 3000, + 'duration precision': DurationPrecision.SECONDS, + 'GTIN lookup': FeatureQuality.GOOD, + 'MBID resolving': FeatureQuality.GOOD, + 'release label': FeatureQuality.PRESENT, + }; + + readonly entityTypeMap = { + artist: ['artist', 'interpreter'], + release: 'album', + recording: 'track', + label: 'label', + }; + + readonly releaseLookup = QobuzReleaseLookup; + + override readonly launchDate: PartialDate = { + year: 2007, + month: 8, + }; + + readonly apiBaseUrl = 'https://www.qobuz.com/api.json/0.2/'; + + constructUrl(entity: EntityId): URL { + if (entity.type == 'label') { + return new URL([entity.type, entity.id].join('/'), 'https://play.qobuz.com'); //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://open.qobuz.com'); + } + + override getLinkTypesForEntity(): LinkType[] { + return ['paid streaming', 'paid download']; + } + + async query(apiUrl: URL, options: ApiQueryOptions): Promise> { + 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 || error.code) { + throw new QobuzResponseError(error, apiUrl); + } + return cacheEntry; + } +} + +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'); + } + + private removeLeadingZeros(barcode: string): string { + return barcode.replace(/^0+/, ''); + } + + constructReleaseApiUrl(): URL { + if (this.lookup.method === 'gtin') { + return new URL(`album/search?query=${this.padBarcode(String(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 { + const 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) => + this.removeLeadingZeros(album.upc) == this.removeLeadingZeros(this.lookup.value) + ); + if (!matchingAlbum) { + throw new ResponseError(this.provider.name, 'API returned no matching results', this.constructReleaseApiUrl()); + } + this.lookup.method = 'id'; + this.lookup.value = matchingAlbum.id; + + const { content: release, timestamp: timestamp2 } = await this.provider.query( + this.constructReleaseApiUrl(), + { + snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, + }, + ); + this.updateCacheTime(timestamp2); + return release; + } else { + const { content: release, timestamp } = await this.provider.query(apiUrl, { + snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, + }); + this.updateCacheTime(timestamp); + + return release; + } + } + + protected convertRawRelease(rawRelease: QobuzAlbum): HarmonyRelease { + this.entity = { + id: rawRelease.id, + type: 'album', + }; + + 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: [capitalizeReleaseType(rawRelease.release_type || (rawRelease.tracks_count > 1 ? 'album' : 'single'))], + packaging: 'None', + images: [{ url: this.getMaxImage(rawRelease.image.small), types: ['front'] }], + labels: [{ + name: rawRelease.label.name, + externalIds: this.provider.makeExternalIds({ type: 'label', id: String(rawRelease.label.id) }), + }], + externalLinks: [{ + url: rawRelease.url, + types: this.provider.getLinkTypesForEntity(), + }], + info: this.generateReleaseInfo(), + gtin: rawRelease.upc, + }; + } + + private getMaxImage(url: string): string { + return url.replace(/_\d+/, '_org'); + } + + private getAlbumCredits(album: QobuzExtendedAlbum): ArtistCreditName[] { + const artists: ArtistCreditName[] = []; + const artistIds: number[] = []; + [album.artist, ...album.artists].forEach((artist) => { + if (!artistIds.includes(artist.id)) { + artists.push({ + name: artist.name, + creditedName: artist.name, + externalIds: this.provider.makeExternalIds({ type: 'artist', id: String(album.artist.id) }), + }); + 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: [], + }; + } + track.album = album; // pass album info to track for artist credits + medium.tracklist.push(this.convertRawTrack(track)); + }); + result.push(medium); + return result; + } + + private convertRawTrack(rawTrack: QobuzPartialTrack): HarmonyTrack { + return { + title: rawTrack.title, + artists: rawTrack.album ? this.getAlbumCredits(rawTrack.album) : [], + number: rawTrack.track_number, + length: rawTrack.duration * 1000, + isrc: rawTrack.isrc, + recording: { + externalIds: this.provider.makeExternalIds({ type: 'track', id: String(rawTrack.id) }), + }, + }; + } +} + +class QobuzResponseError extends ResponseError { + constructor(readonly details: ApiError, url: URL) { + super('Qobuz', `${details.message} (code ${details.code})`, url); + } +} diff --git a/providers/mod.ts b/providers/mod.ts index a32d4184..b2664e6d 100644 --- a/providers/mod.ts +++ b/providers/mod.ts @@ -12,6 +12,7 @@ import OtotoyProvider from './Ototoy/mod.ts'; import SpotifyProvider from './Spotify/mod.ts'; import TidalProvider from './Tidal/mod.ts'; import MoraProvider from './Mora/mod.ts'; +import QobuzProvider from './Qobuz/mod.ts'; /** Registry with all supported providers. */ export const providers = new ProviderRegistry({ @@ -30,6 +31,7 @@ providers.addMultiple( BeatportProvider, MoraProvider, OtotoyProvider, + QobuzProvider, ); /** Internal names of providers which are enabled by default (for GTIN lookups). */ diff --git a/server/components/ProviderIcon.tsx b/server/components/ProviderIcon.tsx index 4a059e2d..4c943576 100644 --- a/server/components/ProviderIcon.tsx +++ b/server/components/ProviderIcon.tsx @@ -12,6 +12,7 @@ const providerIconMap: Record = { ototoy: 'brand-ototoy', spotify: 'brand-spotify', tidal: 'brand-tidal', + qobuz: 'brand-qobuz', }; export type ProviderIconProps = Omit & { 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..e32ab9f9 100644 --- a/server/routes/icon-sprite.svg.tsx +++ b/server/routes/icon-sprite.svg.tsx @@ -27,6 +27,7 @@ import IconSearch from 'tabler-icons/search.tsx'; import IconVideo from 'tabler-icons/video.tsx'; import IconWorldPin from 'tabler-icons/world-pin.tsx'; import IconWorldWww from 'tabler-icons/world-www.tsx'; +import IconBrandQobuz from '@/server/icons/QobuzOutline.tsx'; import type { Handlers } from 'fresh/server.ts'; import type { JSX } from 'preact'; @@ -65,6 +66,7 @@ const icons: Icon[] = [ IconBrandOtotoy, IconBrandSpotify, IconBrandTidal, + IconBrandQobuz, IconPuzzle, ]; diff --git a/server/static/harmony.css b/server/static/harmony.css index 67c1522b..d1bfd6e0 100644 --- a/server/static/harmony.css +++ b/server/static/harmony.css @@ -36,6 +36,7 @@ --ototoy: #e07e01; --spotify: #1db954; --tidal: #000000; + --qobuz: #000000; } @media (prefers-color-scheme: dark) { @@ -478,6 +479,9 @@ label.spotify, td.spotify { label.tidal, td.tidal { background-color: var(--tidal); } +label.qobuz, td.qobuz { + background-color: var(--qobuz); +} /* ProviderIcon.tsx */ From d1aee75c9ac5df25534c9f6046780d519320f0a5 Mon Sep 17 00:00:00 2001 From: Lioncat6 Date: Thu, 2 Apr 2026 22:47:36 -0500 Subject: [PATCH 02/29] fix(qobuz): Add ETI to track names --- providers/Qobuz/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index 52ba612c..cb511d43 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -215,7 +215,7 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Thu, 2 Apr 2026 23:41:16 -0500 Subject: [PATCH 03/29] fix(qobuz): Add provider testdata --- .../Qobuz/__snapshots__/mod.test.ts.snap | 177 ++++++++++++++++++ providers/Qobuz/mod.test.ts | 2 +- providers/Qobuz/mod.ts | 2 - .../0.2/album/get!album_id=jkfpv4xzc6zyc | 1 + .../0.2/album/get!album_id=rjrikcvbggy0b | 1 + .../0.2/album/search!query=0198884774947 | 1 + 6 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 providers/Qobuz/__snapshots__/mod.test.ts.snap create mode 100644 testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=jkfpv4xzc6zyc create mode 100644 testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=rjrikcvbggy0b create mode 100644 testdata/https!/com.qobuz.www/api.json/0.2/album/search!query=0198884774947 diff --git a/providers/Qobuz/__snapshots__/mod.test.ts.snap b/providers/Qobuz/__snapshots__/mod.test.ts.snap new file mode 100644 index 00000000..1eaf0af8 --- /dev/null +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -0,0 +1,177 @@ +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: "11054867", + provider: "qobuz", + type: "artist", + }, + ], + name: "Stephanafro", + }, + ], + copyright: "2024 Honeycomb Records 2024 Honeycomb Records", + externalLinks: [ + { + types: [ + "paid streaming", + "paid download", + ], + url: "https://www.qobuz.com/fr-fr/album/suspend-my-belief-ivycomb-stephanafro/rjrikcvbggy0b", + }, + ], + gtin: "0748777112495", + images: [ + { + 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: { + method: "id", + value: "rjrikcvbggy0b", + }, + name: "Qobuz", + url: "https://open.qobuz.com/album/rjrikcvbggy0b", + }, + ], + }, + labels: [ + { + externalIds: [ + { + id: "6464867", + provider: "qobuz", + 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", + externalIds: [ + { + id: "11054867", + provider: "qobuz", + type: "artist", + }, + ], + 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", + }, + { + creditedName: "Stephanafro", + externalIds: [ + { + id: "11054867", + provider: "qobuz", + type: "artist", + }, + ], + name: "Stephanafro", + }, + ], + 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: [ + "Epmini", + ], +} +`; diff --git a/providers/Qobuz/mod.test.ts b/providers/Qobuz/mod.test.ts index c9cfe850..4353c973 100644 --- a/providers/Qobuz/mod.test.ts +++ b/providers/Qobuz/mod.test.ts @@ -82,7 +82,7 @@ describe('Qobuz provider', () => { assert: (release) => { assertEquals(release.gtin, '0198884774947', 'Qobuz GTIN should be zero-padded'); assert( - release.externalLinks.find((link) => link.url === 'https://open.qobuz.com/album/jkfpv4xzc6zyc'), + release.externalLinks.find((link) => link.url.includes('jkfpv4xzc6zyc')), 'GTIN search did not return the expected release', ); }, diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index cb511d43..dd4e3989 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -24,8 +24,6 @@ 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?/:type(artist|album|track|interpreter|label)/:slug?/:id', 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..a31d678f --- /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,"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 From 6d8c718fe174dec64fa7ee545eba72a1f4c3d1b4 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:24:20 -0500 Subject: [PATCH 04/29] fix(qobuz): Implement new authentication requirements --- .env.example | 4 ++++ providers/Qobuz/mod.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.env.example b/.env.example index 2cd4d650..97cf61ba 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,7 @@ 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= +HARMONY_QOBUZ_AUTH_TOKEN= diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index dd4e3989..bd48a00c 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -21,6 +21,8 @@ import { } from '@/providers/Qobuz/api_types.ts'; const qobuzAppId = getFromEnv('HARMONY_QOBUZ_APP_ID') || ''; +const qobuzAuthToken = getFromEnv('HARMONY_QOBUZ_AUTH_TOKEN') || ''; + export default class QobuzProvider extends MetadataApiProvider { readonly name = 'Qobuz'; @@ -70,6 +72,7 @@ export default class QobuzProvider extends MetadataApiProvider { requestInit: { headers: { 'X-App-Id': qobuzAppId, + 'X-User-Auth-Token': qobuzAuthToken }, }, }); From 3f468ae32ba17a51a206fa0ee814acc2f53b0eb5 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:24:45 -0500 Subject: [PATCH 05/29] chore(qobuz): Fix issues with testing --- providers/Qobuz/mod.test.ts | 6 +----- .../api.json/0.2/album/search!query=0198884774947 | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/providers/Qobuz/mod.test.ts b/providers/Qobuz/mod.test.ts index 4353c973..21034100 100644 --- a/providers/Qobuz/mod.test.ts +++ b/providers/Qobuz/mod.test.ts @@ -1,3 +1,4 @@ +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'; @@ -55,11 +56,6 @@ describe('Qobuz provider', () => { url: new URL('https://play.qobuz.com/label/97377'), id: { type: 'label', id: '97377' }, isCanonical: true, - }, { - description: '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'), 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 index a31d678f..27689840 100644 --- 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 @@ -1 +1 @@ -{"query":"0198884774947","albums":{"limit":50,"offset":0,"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 +{"query":"0198884774947","albums":{"limit":50,"offset":0,"analytics":{"search_external_id":"24aa9ee59f6eada22bfac45c991ab3ba"},"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 From b687db733e5d02c4c9a8703b98fe242e4fcbda19 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:26:57 -0500 Subject: [PATCH 06/29] chore(qobuz): Fix line formatting --- providers/Qobuz/api_types.ts | 376 +++++++++++++++++------------------ providers/Qobuz/mod.ts | 2 +- 2 files changed, 189 insertions(+), 189 deletions(-) diff --git a/providers/Qobuz/api_types.ts b/providers/Qobuz/api_types.ts index dc25deef..9102c984 100644 --- a/providers/Qobuz/api_types.ts +++ b/providers/Qobuz/api_types.ts @@ -2,256 +2,256 @@ // License TBD export interface QobuzSearchResponse { - query: string - albums?: QobuzPagingResult - tracks?: QobuzPagingResult - artists?: QobuzPagingResult - playlists?: QobuzPagingResult - stories?: QobuzPagingResult + query: string; + albums?: QobuzPagingResult; + tracks?: QobuzPagingResult; + artists?: QobuzPagingResult; + playlists?: QobuzPagingResult; + stories?: QobuzPagingResult; } export interface QobuzPagingResult { - limit: number - offset: number - total: number - items: T[] + 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 + 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 + 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 + 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 + 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 + 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 + 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 + summary: string; + content: string; + source?: string; + language?: string; } export interface QobuzArtistRole { - id: number - name: string - roles: string[] + 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[] + 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; + 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 + 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 + path: number[]; + color?: string; + name: string; + id: number; + slug: string; } export interface AudioInfo { - replaygain_track_peak: number - replaygain_track_gain: number + replaygain_track_peak: number; + replaygain_track_gain: number; } export interface QobuzPerformer { - name: string - id: number + name: string; + id: number; } export interface QobuzPartialComposer { - name: string - id: number + name: string; + id: number; } export interface QobuzComposer extends QobuzPartialComposer { - slug: string - albums_count: number - picture: unknown - image: unknown + slug: string; + albums_count: number; + picture: unknown; + image: unknown; } export interface QobuzOwner { - id: number - name: string + id: number; + name: string; } export interface QobuzAlbumsSameArtist { - items: unknown[] + items: unknown[]; } //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 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 + status: string; message: string; }; diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index bd48a00c..2556a49c 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -72,7 +72,7 @@ export default class QobuzProvider extends MetadataApiProvider { requestInit: { headers: { 'X-App-Id': qobuzAppId, - 'X-User-Auth-Token': qobuzAuthToken + 'X-User-Auth-Token': qobuzAuthToken, }, }, }); From 3635c094aa74fe4349623ec2dc0806d4c9baf216 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:47:21 -0500 Subject: [PATCH 07/29] fix(qobuz): Implement suggestions --- providers/Qobuz/mod.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index 2556a49c..f8c4d763 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -6,6 +6,7 @@ import { parseHyphenatedDate, PartialDate } from '@/utils/date.ts'; import { ResponseError } from '@/utils/errors.ts'; import { ArtistCreditName, + Artwork, EntityId, HarmonyMedium, HarmonyRelease, @@ -19,6 +20,7 @@ import { QobuzPartialTrack, QobuzSearchResponse, } from '@/providers/Qobuz/api_types.ts'; +import { isEqualGTIN } from '../../utils/gtin.ts'; const qobuzAppId = getFromEnv('HARMONY_QOBUZ_APP_ID') || ''; const qobuzAuthToken = getFromEnv('HARMONY_QOBUZ_AUTH_TOKEN') || ''; @@ -94,10 +96,6 @@ export class QobuzReleaseLookup extends ReleaseApiLookup - this.removeLeadingZeros(album.upc) == this.removeLeadingZeros(this.lookup.value) - ); + 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', this.constructReleaseApiUrl()); } @@ -153,9 +149,9 @@ export class QobuzReleaseLookup extends ReleaseApiLookup 1 ? 'album' : 'single'))], + types: rawRelease.release_type ? [capitalizeReleaseType(rawRelease.release_type)] : undefined, packaging: 'None', - images: [{ url: this.getMaxImage(rawRelease.image.small), types: ['front'] }], + images: this.getAlbumImage(rawRelease.image.small), labels: [{ name: rawRelease.label.name, externalIds: this.provider.makeExternalIds({ type: 'label', id: String(rawRelease.label.id) }), @@ -169,8 +165,13 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Fri, 3 Apr 2026 15:48:18 -0500 Subject: [PATCH 08/29] test(qobuz): Update tests for track artists change --- .../Qobuz/__snapshots__/mod.test.ts.snap | 51 ++----------------- providers/Qobuz/mod.test.ts | 2 +- .../0.2/album/search!query=0198884774947 | 2 +- 3 files changed, 5 insertions(+), 50 deletions(-) diff --git a/providers/Qobuz/__snapshots__/mod.test.ts.snap b/providers/Qobuz/__snapshots__/mod.test.ts.snap index 1eaf0af8..b62b7961 100644 --- a/providers/Qobuz/__snapshots__/mod.test.ts.snap +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -39,6 +39,7 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` gtin: "0748777112495", images: [ { + thumbUrl: "https://static.qobuz.com/images/covers/0b/gy/rjrikcvbggy0b_230.jpg", types: [ "front", ], @@ -79,30 +80,7 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` number: 1, tracklist: [ { - artists: [ - { - creditedName: "ivycomb", - externalIds: [ - { - id: "11054867", - provider: "qobuz", - type: "artist", - }, - ], - name: "ivycomb", - }, - { - creditedName: "Stephanafro", - externalIds: [ - { - id: "11054867", - provider: "qobuz", - type: "artist", - }, - ], - name: "Stephanafro", - }, - ], + artists: undefined, isrc: "QZPEW2452614", length: 181000, number: 1, @@ -118,30 +96,7 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` title: "Suspend My Belief", }, { - artists: [ - { - creditedName: "ivycomb", - externalIds: [ - { - id: "11054867", - provider: "qobuz", - type: "artist", - }, - ], - name: "ivycomb", - }, - { - creditedName: "Stephanafro", - externalIds: [ - { - id: "11054867", - provider: "qobuz", - type: "artist", - }, - ], - name: "Stephanafro", - }, - ], + artists: undefined, isrc: "QZPEW2452615", length: 181000, number: 2, diff --git a/providers/Qobuz/mod.test.ts b/providers/Qobuz/mod.test.ts index 21034100..9b9f52fb 100644 --- a/providers/Qobuz/mod.test.ts +++ b/providers/Qobuz/mod.test.ts @@ -68,8 +68,8 @@ describe('Qobuz provider', () => { 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[0].artists?.length === 2, 'Main track should have two artists'); assert(allTracks.every((track) => track.isrc), 'All tracks should have an ISRC'); }, }, { 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 index 27689840..c48ce220 100644 --- 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 @@ -1 +1 @@ -{"query":"0198884774947","albums":{"limit":50,"offset":0,"analytics":{"search_external_id":"24aa9ee59f6eada22bfac45c991ab3ba"},"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 +{"query":"0198884774947","albums":{"limit":50,"offset":0,"analytics":{"search_external_id":"8c151d58b480508f5873da525a2efc20"},"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 From d956b5d8ff0abfa1619b479a4fe6aaee71ef1963 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:00:27 -0500 Subject: [PATCH 09/29] chore(qobuz): Change provider ordering --- providers/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/mod.ts b/providers/mod.ts index b2664e6d..b05a71e1 100644 --- a/providers/mod.ts +++ b/providers/mod.ts @@ -28,10 +28,10 @@ providers.addMultiple( SpotifyProvider, TidalProvider, BandcampProvider, + QobuzProvider, BeatportProvider, MoraProvider, OtotoyProvider, - QobuzProvider, ); /** Internal names of providers which are enabled by default (for GTIN lookups). */ From 3781ecb8378cf70206d3d32339ca94382b718123 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Sat, 4 Apr 2026 20:48:59 -0500 Subject: [PATCH 10/29] chore(qobuz): Alphabetize 'qobuz' usage --- server/components/ProviderIcon.tsx | 2 +- server/routes/icon-sprite.svg.tsx | 4 ++-- server/static/harmony.css | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/components/ProviderIcon.tsx b/server/components/ProviderIcon.tsx index 4c943576..a7840159 100644 --- a/server/components/ProviderIcon.tsx +++ b/server/components/ProviderIcon.tsx @@ -10,9 +10,9 @@ const providerIconMap: Record = { musicbrainz: 'brand-metabrainz', mora: 'brand-mora', ototoy: 'brand-ototoy', + qobuz: 'brand-qobuz', spotify: 'brand-spotify', tidal: 'brand-tidal', - qobuz: 'brand-qobuz', }; export type ProviderIconProps = Omit & { diff --git a/server/routes/icon-sprite.svg.tsx b/server/routes/icon-sprite.svg.tsx index e32ab9f9..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'; @@ -27,7 +28,6 @@ import IconSearch from 'tabler-icons/search.tsx'; import IconVideo from 'tabler-icons/video.tsx'; import IconWorldPin from 'tabler-icons/world-pin.tsx'; import IconWorldWww from 'tabler-icons/world-www.tsx'; -import IconBrandQobuz from '@/server/icons/QobuzOutline.tsx'; import type { Handlers } from 'fresh/server.ts'; import type { JSX } from 'preact'; @@ -64,9 +64,9 @@ const icons: Icon[] = [ IconBrandMetaBrainz, IconBrandMora, IconBrandOtotoy, + IconBrandQobuz, IconBrandSpotify, IconBrandTidal, - IconBrandQobuz, IconPuzzle, ]; diff --git a/server/static/harmony.css b/server/static/harmony.css index d1bfd6e0..2358c076 100644 --- a/server/static/harmony.css +++ b/server/static/harmony.css @@ -34,9 +34,9 @@ --mora: #02082a; --musicbrainz: #ba478f; --ototoy: #e07e01; + --qobuz: #000000; --spotify: #1db954; --tidal: #000000; - --qobuz: #000000; } @media (prefers-color-scheme: dark) { @@ -473,15 +473,15 @@ 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); } label.tidal, td.tidal { background-color: var(--tidal); } -label.qobuz, td.qobuz { - background-color: var(--qobuz); -} /* ProviderIcon.tsx */ From 9c09a1ae0de13ccd80d2f015e22fca02d3a1e98c Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:24:48 -0500 Subject: [PATCH 11/29] chore(qobuz): Add mapping function for release types --- providers/Qobuz/__snapshots__/mod.test.ts.snap | 2 +- providers/Qobuz/mod.ts | 13 ++++++++++++- .../api.json/0.2/album/search!query=0198884774947 | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/providers/Qobuz/__snapshots__/mod.test.ts.snap b/providers/Qobuz/__snapshots__/mod.test.ts.snap index b62b7961..1d2b85d7 100644 --- a/providers/Qobuz/__snapshots__/mod.test.ts.snap +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -126,7 +126,7 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` status: "Official", title: "Suspend My Belief", types: [ - "Epmini", + "EP", ], } `; diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index f8c4d763..bc4267ae 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -12,6 +12,7 @@ import { HarmonyRelease, HarmonyTrack, LinkType, + ReleaseGroupType, } from '@/harmonizer/types.ts'; import { ApiError, @@ -149,7 +150,7 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Sat, 4 Apr 2026 21:28:55 -0500 Subject: [PATCH 12/29] qobuz(chore): Switch `region` to `locale` for URLPattern parsing --- providers/Qobuz/mod.test.ts | 8 ++++---- providers/Qobuz/mod.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/providers/Qobuz/mod.test.ts b/providers/Qobuz/mod.test.ts index 9b9f52fb..6ea89c82 100644 --- a/providers/Qobuz/mod.test.ts +++ b/providers/Qobuz/mod.test.ts @@ -30,18 +30,18 @@ describe('Qobuz provider', () => { url: new URL('https://play.qobuz.com/album/n288n588k0dza'), id: { type: 'album', id: 'n288n588k0dza' }, }, { - description: 'www.qobuz release page with region and slug', + 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-FR', slug: 'let-me-battle-9lana' }, + id: { type: 'album', id: 'n288n588k0dza', slug: 'let-me-battle-9lana' }, }, { 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 region and slug', + 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-EN', slug: '9lana' }, + id: { type: 'interpreter', id: '19452726', slug: '9lana' }, }, { description: 'open.qobuz track page', url: new URL('https://open.qobuz.com/track/285222652'), diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index bc4267ae..c28ac66a 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -31,7 +31,7 @@ export default class QobuzProvider extends MetadataApiProvider { readonly supportedUrls = new URLPattern({ hostname: '(play|www|open).qobuz.com', - pathname: '/:region?/:type(artist|album|track|interpreter|label)/:slug?/:id', + pathname: '/:locale?/:type(artist|album|track|interpreter|label)/:slug?/:id', }); override readonly features: FeatureQualityMap = { From b37c01e95d86b356c7b4d678bcd2814038e5bf54 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Sun, 5 Apr 2026 11:52:11 -0500 Subject: [PATCH 13/29] chore(qobuz): Fix some incosistencies --- providers/Qobuz/mod.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index c28ac66a..baa0ccdb 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -38,7 +38,7 @@ export default class QobuzProvider extends MetadataApiProvider { 'cover size': 3000, 'duration precision': DurationPrecision.SECONDS, 'GTIN lookup': FeatureQuality.GOOD, - 'MBID resolving': FeatureQuality.GOOD, + 'MBID resolving': FeatureQuality.EXPENSIVE, 'release label': FeatureQuality.PRESENT, }; @@ -53,7 +53,8 @@ export default class QobuzProvider extends MetadataApiProvider { override readonly launchDate: PartialDate = { year: 2007, - month: 8, + month: 9, + day: 18 }; readonly apiBaseUrl = 'https://www.qobuz.com/api.json/0.2/'; From 62f8edae894d3ec8fc5ac2c82343c9740d43509a Mon Sep 17 00:00:00 2001 From: Lioncat6 Date: Wed, 8 Apr 2026 12:57:27 -0500 Subject: [PATCH 14/29] chore(qobuz): Standardize release URL and assign correct types --- providers/Qobuz/mod.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index baa0ccdb..0b142611 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -54,7 +54,7 @@ export default class QobuzProvider extends MetadataApiProvider { override readonly launchDate: PartialDate = { year: 2007, month: 9, - day: 18 + day: 18, }; readonly apiBaseUrl = 'https://www.qobuz.com/api.json/0.2/'; @@ -144,6 +144,16 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Wed, 8 Apr 2026 12:57:39 -0500 Subject: [PATCH 15/29] test(qobuz): Test for URL types --- .../Qobuz/__snapshots__/mod.test.ts.snap | 265 +++++++++++++++++- providers/Qobuz/mod.test.ts | 19 ++ .../0.2/album/get!album_id=fyg86ag6jm8db | 1 + .../0.2/album/search!query=0198884774947 | 2 +- 4 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 testdata/https!/com.qobuz.www/api.json/0.2/album/get!album_id=fyg86ag6jm8db diff --git a/providers/Qobuz/__snapshots__/mod.test.ts.snap b/providers/Qobuz/__snapshots__/mod.test.ts.snap index 1d2b85d7..60a4f4eb 100644 --- a/providers/Qobuz/__snapshots__/mod.test.ts.snap +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -33,7 +33,7 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` "paid streaming", "paid download", ], - url: "https://www.qobuz.com/fr-fr/album/suspend-my-belief-ivycomb-stephanafro/rjrikcvbggy0b", + url: "https://open.qobuz.com/album/rjrikcvbggy0b", }, ], gtin: "0748777112495", @@ -130,3 +130,266 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` ], } `; + +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", + type: "label", + }, + ], + name: "NBCUniversal Entertainment", + }, + ], + media: [ + { + format: "Digital Media", + number: 1, + tracklist: [ + { + artists: undefined, + isrc: "JPPI01900110", + length: 37000, + number: 1, + recording: { + externalIds: [ + { + id: "322082950", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "Reunion", + }, + { + artists: undefined, + isrc: "JPPI01900120", + length: 210000, + number: 2, + recording: { + externalIds: [ + { + id: "322082951", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "アンドロイドガール", + }, + { + artists: undefined, + isrc: "JPPI01900130", + length: 183000, + number: 3, + recording: { + externalIds: [ + { + id: "322082952", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "スクランブル交際", + }, + { + artists: undefined, + isrc: "JPPI01900140", + length: 229000, + number: 4, + recording: { + externalIds: [ + { + id: "322082953", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "モスキート", + }, + { + artists: undefined, + isrc: "JPPI01900150", + length: 223000, + number: 5, + recording: { + externalIds: [ + { + id: "322082954", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "乙女解剖", + }, + { + artists: undefined, + isrc: "JPPI01900160", + length: 186000, + number: 6, + recording: { + externalIds: [ + { + id: "322082955", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "人質交換", + }, + { + artists: undefined, + isrc: "JPPI01900220", + length: 257000, + number: 7, + recording: { + externalIds: [ + { + id: "322082956", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "シンセカイ案内所", + }, + { + artists: undefined, + isrc: "JPPI01900170", + length: 224000, + number: 8, + recording: { + externalIds: [ + { + id: "322082957", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "サイコグラム", + }, + { + artists: undefined, + isrc: "JPPI01900180", + length: 235000, + number: 9, + recording: { + externalIds: [ + { + id: "322082958", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "夜行性ハイズ", + }, + { + artists: undefined, + isrc: "JPPI01900190", + length: 206000, + number: 10, + recording: { + externalIds: [ + { + id: "322082959", + provider: "qobuz", + type: "track", + }, + ], + }, + title: "ヒバナ -Reloaded-", + }, + { + artists: undefined, + 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/mod.test.ts b/providers/Qobuz/mod.test.ts index 6ea89c82..6ad6bd94 100644 --- a/providers/Qobuz/mod.test.ts +++ b/providers/Qobuz/mod.test.ts @@ -71,6 +71,12 @@ describe('Qobuz provider', () => { 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', @@ -82,6 +88,19 @@ describe('Qobuz provider', () => { 'GTIN search did not return the expected release', ); }, + }, { + description: 'non-downloadable album', + release: new URL('https://play.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', + ); + }, }], }); 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/search!query=0198884774947 b/testdata/https!/com.qobuz.www/api.json/0.2/album/search!query=0198884774947 index e8eeded4..4f673ff3 100644 --- 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 @@ -1 +1 @@ -{"query":"0198884774947","albums":{"limit":50,"offset":0,"analytics":{"search_external_id":"b99cab4fc9778739eb61591b030000de"},"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 +{"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 From a5762831899c17bd3767791f0d008d680776ba23 Mon Sep 17 00:00:00 2001 From: Lioncat6 Date: Thu, 16 Apr 2026 07:34:49 -0500 Subject: [PATCH 16/29] fix(qobuz): Fix artist ID assignment --- providers/Qobuz/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index 0b142611..04670aed 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -204,7 +204,7 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Sun, 17 May 2026 15:11:55 +0200 Subject: [PATCH 17/29] feat(Qobuz): Support www.qobuz.com label URLs --- providers/Qobuz/mod.test.ts | 6 +++++- providers/Qobuz/mod.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/providers/Qobuz/mod.test.ts b/providers/Qobuz/mod.test.ts index 6ad6bd94..ef8ba1ce 100644 --- a/providers/Qobuz/mod.test.ts +++ b/providers/Qobuz/mod.test.ts @@ -52,7 +52,11 @@ describe('Qobuz provider', () => { url: new URL('https://play.qobuz.com/track/285222652'), id: { type: 'track', id: '285222652' }, }, { - description: 'label page', + 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', slug: 'honeycomb-records-4' }, + }, { + description: 'play.qobuz label page', url: new URL('https://play.qobuz.com/label/97377'), id: { type: 'label', id: '97377' }, isCanonical: true, diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index 04670aed..ad2655a7 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -31,7 +31,7 @@ export default class QobuzProvider extends MetadataApiProvider { readonly supportedUrls = new URLPattern({ hostname: '(play|www|open).qobuz.com', - pathname: '/:locale?/:type(artist|album|track|interpreter|label)/:slug?/:id', + pathname: '/:locale?/:type(artist|album|track|interpreter|label)/:slug?{/download-streaming-albums}?/:id', }); override readonly features: FeatureQualityMap = { From 5fd1a462a6a12e01d1dd4514f7174f3e1b3f8608 Mon Sep 17 00:00:00 2001 From: David Kellner <52860029+kellnerd@users.noreply.github.com> Date: Sun, 17 May 2026 16:33:47 +0200 Subject: [PATCH 18/29] feat(Qobuz): Extract country and language from URLs --- harmonizer/types.ts | 11 +++++++++++ providers/Qobuz/__snapshots__/mod.test.ts.snap | 4 +++- providers/Qobuz/mod.test.ts | 10 +++++----- providers/Qobuz/mod.ts | 14 +++++++++++++- providers/base.ts | 3 +++ 5 files changed, 35 insertions(+), 7 deletions(-) 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 index 60a4f4eb..66f0c906 100644 --- a/providers/Qobuz/__snapshots__/mod.test.ts.snap +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -18,7 +18,7 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` creditedName: "Stephanafro", externalIds: [ { - id: "11054867", + id: "12651905", provider: "qobuz", type: "artist", }, @@ -54,7 +54,9 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` id: "rjrikcvbggy0b", internalName: "qobuz", lookup: { + language: "en", method: "id", + region: "US", value: "rjrikcvbggy0b", }, name: "Qobuz", diff --git a/providers/Qobuz/mod.test.ts b/providers/Qobuz/mod.test.ts index ef8ba1ce..723e307b 100644 --- a/providers/Qobuz/mod.test.ts +++ b/providers/Qobuz/mod.test.ts @@ -32,7 +32,7 @@ describe('Qobuz provider', () => { }, { 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', slug: 'let-me-battle-9lana' }, + id: { type: 'album', id: 'n288n588k0dza', region: 'FR', language: 'fr', slug: 'let-me-battle-9lana' }, }, { description: 'open.qobuz artist page', url: new URL('https://open.qobuz.com/artist/19452726'), @@ -41,7 +41,7 @@ describe('Qobuz provider', () => { }, { 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', slug: '9lana' }, + id: { type: 'interpreter', id: '19452726', region: 'US', language: 'en', slug: '9lana' }, }, { description: 'open.qobuz track page', url: new URL('https://open.qobuz.com/track/285222652'), @@ -54,7 +54,7 @@ describe('Qobuz provider', () => { }, { 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', slug: 'honeycomb-records-4' }, + id: { type: 'label', id: '6464867', region: 'US', language: 'en', slug: 'honeycomb-records-4' }, }, { description: 'play.qobuz label page', url: new URL('https://play.qobuz.com/label/97377'), @@ -68,7 +68,7 @@ describe('Qobuz provider', () => { invalidIds: [], releaseLookup: [{ description: 'single by two artists', - release: new URL('https://play.qobuz.com/album/rjrikcvbggy0b'), + 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); @@ -94,7 +94,7 @@ describe('Qobuz provider', () => { }, }, { description: 'non-downloadable album', - release: new URL('https://play.qobuz.com/album/fyg86ag6jm8db'), + release: new URL('https://open.qobuz.com/album/fyg86ag6jm8db'), options: releaseOptions, assert: async (release, ctx) => { await assertSnapshot(ctx, release); diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index ad2655a7..9d4c17c8 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -31,7 +31,8 @@ export default class QobuzProvider extends MetadataApiProvider { readonly supportedUrls = new URLPattern({ hostname: '(play|www|open).qobuz.com', - pathname: '/:locale?/:type(artist|album|track|interpreter|label)/:slug?{/download-streaming-albums}?/:id', + pathname: + '/:region(\\w{2}-\\w{2})?/:type(artist|album|track|interpreter|label)/:slug?{/download-streaming-albums}?/:id', }); override readonly features: FeatureQualityMap = { @@ -66,6 +67,17 @@ export default class QobuzProvider extends MetadataApiProvider { 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']; } diff --git a/providers/base.ts b/providers/base.ts index 69c4005c..69ec9ff9 100644 --- a/providers/base.ts +++ b/providers/base.ts @@ -367,6 +367,9 @@ export abstract class ReleaseLookup Date: Sun, 17 May 2026 17:08:34 +0200 Subject: [PATCH 19/29] refactor(Qobuz): Make lookup by GTIN more DRY --- providers/Qobuz/mod.ts | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index 9d4c17c8..a94b0f1a 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -112,14 +112,14 @@ export class QobuzReleaseLookup extends ReleaseApiLookup { - const apiUrl = this.constructReleaseApiUrl(); + let apiUrl = this.constructReleaseApiUrl(); if (this.lookup.method === 'gtin') { const { content: searchResponse, timestamp } = await this.provider.query(apiUrl, { snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, @@ -131,23 +131,15 @@ export class QobuzReleaseLookup extends ReleaseApiLookup( - this.constructReleaseApiUrl(), - { - snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, - }, - ); - this.updateCacheTime(timestamp2); - return release; - } else { - const { content: release, timestamp } = await this.provider.query(apiUrl, { - snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, - }); - this.updateCacheTime(timestamp); + const { content: release, timestamp } = await this.provider.query(apiUrl, { + snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, + }); + this.updateCacheTime(timestamp); - return release; - } + return release; } protected convertRawRelease(rawRelease: QobuzAlbum): HarmonyRelease { From 7b75de5aa339ddae17e9093abd1758c8a81a699f Mon Sep 17 00:00:00 2001 From: David Kellner <52860029+kellnerd@users.noreply.github.com> Date: Sun, 17 May 2026 17:17:39 +0200 Subject: [PATCH 20/29] feat(Qobuz): Extract track artist from `track.performer` object --- .../Qobuz/__snapshots__/mod.test.ts.snap | 182 ++++++++++++++++-- providers/Qobuz/mod.ts | 7 +- 2 files changed, 175 insertions(+), 14 deletions(-) diff --git a/providers/Qobuz/__snapshots__/mod.test.ts.snap b/providers/Qobuz/__snapshots__/mod.test.ts.snap index 66f0c906..8bcbfcea 100644 --- a/providers/Qobuz/__snapshots__/mod.test.ts.snap +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -82,7 +82,19 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` number: 1, tracklist: [ { - artists: undefined, + artists: [ + { + creditedName: "ivycomb", + externalIds: [ + { + id: "11054867", + provider: "qobuz", + type: "artist", + }, + ], + name: "ivycomb", + }, + ], isrc: "QZPEW2452614", length: 181000, number: 1, @@ -98,7 +110,19 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` title: "Suspend My Belief", }, { - artists: undefined, + artists: [ + { + creditedName: "ivycomb", + externalIds: [ + { + id: "11054867", + provider: "qobuz", + type: "artist", + }, + ], + name: "ivycomb", + }, + ], isrc: "QZPEW2452615", length: 181000, number: 2, @@ -201,7 +225,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` number: 1, tracklist: [ { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900110", length: 37000, number: 1, @@ -217,7 +253,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "Reunion", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900120", length: 210000, number: 2, @@ -233,7 +281,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "アンドロイドガール", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900130", length: 183000, number: 3, @@ -249,7 +309,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "スクランブル交際", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900140", length: 229000, number: 4, @@ -265,7 +337,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "モスキート", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900150", length: 223000, number: 5, @@ -281,7 +365,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "乙女解剖", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900160", length: 186000, number: 6, @@ -297,7 +393,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "人質交換", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900220", length: 257000, number: 7, @@ -313,7 +421,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "シンセカイ案内所", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900170", length: 224000, number: 8, @@ -329,7 +449,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "サイコグラム", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900180", length: 235000, number: 9, @@ -345,7 +477,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "夜行性ハイズ", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900190", length: 206000, number: 10, @@ -361,7 +505,19 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` title: "ヒバナ -Reloaded-", }, { - artists: undefined, + artists: [ + { + creditedName: "DECO*27", + externalIds: [ + { + id: "3284769", + provider: "qobuz", + type: "artist", + }, + ], + name: "DECO*27", + }, + ], isrc: "JPPI01900200", length: 246000, number: 11, diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index a94b0f1a..c65e3094 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -241,9 +241,14 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Sun, 17 May 2026 18:03:02 +0200 Subject: [PATCH 21/29] feat(Qobuz): Parse `track.performers` string for additional track artists --- .../Qobuz/__snapshots__/mod.test.ts.snap | 4 ++ providers/Qobuz/api_types.ts | 5 +++ providers/Qobuz/mod.ts | 39 +++++++++++++------ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/providers/Qobuz/__snapshots__/mod.test.ts.snap b/providers/Qobuz/__snapshots__/mod.test.ts.snap index 8bcbfcea..be733466 100644 --- a/providers/Qobuz/__snapshots__/mod.test.ts.snap +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -94,6 +94,10 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` ], name: "ivycomb", }, + { + creditedName: "Stephanafro", + name: "Stephanafro", + }, ], isrc: "QZPEW2452614", length: 181000, diff --git a/providers/Qobuz/api_types.ts b/providers/Qobuz/api_types.ts index 9102c984..a22b44ac 100644 --- a/providers/Qobuz/api_types.ts +++ b/providers/Qobuz/api_types.ts @@ -243,6 +243,11 @@ export interface QobuzAlbumsSameArtist { items: unknown[]; } +export interface QobuzMinimalArtist { + name: string; + id?: number; +} + //Extended Types export interface QobuzExtendedArtist extends QobuzPartialArtist, Partial> {} export interface QobuzExtendedArtistRole diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index c65e3094..ca8bbb11 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -18,6 +18,7 @@ import { ApiError, QobuzAlbum, QobuzExtendedAlbum, + QobuzMinimalArtist, QobuzPartialTrack, QobuzSearchResponse, } from '@/providers/Qobuz/api_types.ts'; @@ -200,16 +201,23 @@ export class QobuzReleaseLookup extends ReleaseApiLookup { if (!artistIds.includes(artist.id)) { - artists.push({ - name: artist.name, - creditedName: artist.name, - externalIds: this.provider.makeExternalIds({ type: 'artist', id: String(artist.id) }), - }); + artists.push(this.convertRawArtist(artist)); artistIds.push(artist.id); } }); @@ -241,14 +249,13 @@ export class QobuzReleaseLookup extends ReleaseApiLookup + name !== mainArtist.name && roles.some((role) => role === 'MainArtist' || role === 'FeaturedArtist') + ); return { title: `${rawTrack.title}${rawTrack.version ? ` (${rawTrack.version})` : ''}`, - artists: [{ - name: performer.name, - creditedName: performer.name, - externalIds: this.provider.makeExternalIds({ type: 'artist', id: String(performer.id) }), - }], + artists: [mainArtist, ...additionalArtists].map(this.convertRawArtist.bind(this)), number: rawTrack.track_number, length: rawTrack.duration * 1000, isrc: rawTrack.isrc, @@ -257,6 +264,16 @@ export class QobuzReleaseLookup extends ReleaseApiLookup { + const [performer, ...roles] = performerAndRoles.split(', '); + return { + name: performer, + roles, + }; + }); + } } class QobuzResponseError extends ResponseError { From a4c2859f1bc7be85732a35f2746016a7b424b293 Mon Sep 17 00:00:00 2001 From: David Kellner <52860029+kellnerd@users.noreply.github.com> Date: Sun, 17 May 2026 19:26:21 +0200 Subject: [PATCH 22/29] fix(Qobuz): Catch 404 errors and improve error message "No result matching given argument" for a valid release ID of a region- restricted release is not a helpful error message. --- providers/Qobuz/mod.ts | 57 +++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index ca8bbb11..b67d89e7 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -4,7 +4,8 @@ import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/provider import { getFromEnv } from '@/utils/config.ts'; import { parseHyphenatedDate, PartialDate } from '@/utils/date.ts'; import { ResponseError } from '@/utils/errors.ts'; -import { +import { isEqualGTIN } from '@/utils/gtin.ts'; +import type { ArtistCreditName, Artwork, EntityId, @@ -21,8 +22,8 @@ import { QobuzMinimalArtist, QobuzPartialTrack, QobuzSearchResponse, -} from '@/providers/Qobuz/api_types.ts'; -import { isEqualGTIN } from '../../utils/gtin.ts'; +} from './api_types.ts'; +import { ResponseError as SnapResponseError } from 'snap-storage'; const qobuzAppId = getFromEnv('HARMONY_QOBUZ_APP_ID') || ''; const qobuzAuthToken = getFromEnv('HARMONY_QOBUZ_AUTH_TOKEN') || ''; @@ -84,20 +85,38 @@ export default class QobuzProvider extends MetadataApiProvider { } async query(apiUrl: URL, options: ApiQueryOptions): Promise> { - const cacheEntry = await this.fetchJSON(apiUrl, { - policy: { maxTimestamp: options.snapshotMaxTimestamp }, - requestInit: { - headers: { - 'X-App-Id': qobuzAppId, - 'X-User-Auth-Token': qobuzAuthToken, + try { + const cacheEntry = await this.fetchJSON(apiUrl, { + policy: { maxTimestamp: options.snapshotMaxTimestamp }, + requestInit: { + headers: { + 'X-App-Id': qobuzAppId, + 'X-User-Auth-Token': qobuzAuthToken, + }, }, - }, - }); - const error = cacheEntry.content as ApiError; - if (error.message || error.code) { - throw new QobuzResponseError(error, apiUrl); + }); + 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; + } } - return cacheEntry; } } @@ -128,7 +147,7 @@ export class QobuzReleaseLookup extends ReleaseApiLookup isEqualGTIN(album.upc, this.lookup.value)); if (!matchingAlbum) { - throw new ResponseError(this.provider.name, 'API returned no matching results', this.constructReleaseApiUrl()); + throw new ResponseError(this.provider.name, 'API returned no matching results', apiUrl); } this.lookup.method = 'id'; this.lookup.value = matchingAlbum.id; @@ -278,6 +297,10 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Sun, 17 May 2026 20:18:58 +0200 Subject: [PATCH 23/29] feat(Qobuz): Prefer more reliable, localized www URL if we know it --- .../Qobuz/__snapshots__/mod.test.ts.snap | 4 +- providers/Qobuz/mod.test.ts | 3 ++ providers/Qobuz/mod.ts | 38 ++++++++++++++++--- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/providers/Qobuz/__snapshots__/mod.test.ts.snap b/providers/Qobuz/__snapshots__/mod.test.ts.snap index be733466..25d94f77 100644 --- a/providers/Qobuz/__snapshots__/mod.test.ts.snap +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -33,7 +33,7 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` "paid streaming", "paid download", ], - url: "https://open.qobuz.com/album/rjrikcvbggy0b", + url: "https://www.qobuz.com/us-en/album/suspend-my-belief-ivycomb-stephanafro/rjrikcvbggy0b", }, ], gtin: "0748777112495", @@ -60,7 +60,7 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` value: "rjrikcvbggy0b", }, name: "Qobuz", - url: "https://open.qobuz.com/album/rjrikcvbggy0b", + url: "https://www.qobuz.com/us-en/album/suspend-my-belief-ivycomb-stephanafro/rjrikcvbggy0b", }, ], }, diff --git a/providers/Qobuz/mod.test.ts b/providers/Qobuz/mod.test.ts index 723e307b..cf00509b 100644 --- a/providers/Qobuz/mod.test.ts +++ b/providers/Qobuz/mod.test.ts @@ -33,6 +33,7 @@ describe('Qobuz provider', () => { 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'), @@ -42,6 +43,7 @@ describe('Qobuz provider', () => { 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'), @@ -55,6 +57,7 @@ describe('Qobuz provider', () => { 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'), diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index b67d89e7..5f5d51e0 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -63,8 +63,29 @@ export default class QobuzProvider extends MetadataApiProvider { readonly apiBaseUrl = 'https://www.qobuz.com/api.json/0.2/'; constructUrl(entity: EntityId): URL { - if (entity.type == 'label') { - return new URL([entity.type, entity.id].join('/'), 'https://play.qobuz.com'); //Qobuz doesn't have label pages on open.qobuz.com, but they do on play.qobuz.com + // Prefer the more reliable, localized www.qobuz.com URL if we know the region and language. + if (entity.region && entity.language) { + const locale = [entity.region.toLowerCase(), entity.language].join('-'); + let { type, slug } = entity; + 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 (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'); } @@ -163,10 +184,15 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Sun, 17 May 2026 21:53:47 +0200 Subject: [PATCH 24/29] feat(Qobuz): Derive missing language from region --- providers/Qobuz/mod.ts | 9 ++--- providers/Qobuz/regions.ts | 73 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 providers/Qobuz/regions.ts diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index 5f5d51e0..7fdc4c37 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -15,7 +15,7 @@ import type { LinkType, ReleaseGroupType, } from '@/harmonizer/types.ts'; -import { +import type { ApiError, QobuzAlbum, QobuzExtendedAlbum, @@ -23,6 +23,7 @@ import { QobuzPartialTrack, QobuzSearchResponse, } from './api_types.ts'; +import { makeLocale } from './regions.ts'; import { ResponseError as SnapResponseError } from 'snap-storage'; const qobuzAppId = getFromEnv('HARMONY_QOBUZ_APP_ID') || ''; @@ -63,9 +64,9 @@ export default class QobuzProvider extends MetadataApiProvider { readonly apiBaseUrl = 'https://www.qobuz.com/api.json/0.2/'; constructUrl(entity: EntityId): URL { - // Prefer the more reliable, localized www.qobuz.com URL if we know the region and language. - if (entity.region && entity.language) { - const locale = [entity.region.toLowerCase(), entity.language].join('-'); + // Prefer the more reliable, localized www.qobuz.com URL if we know the region. + if (entity.region) { + const locale = makeLocale(entity.region, entity.language); let { type, slug } = entity; if (type === 'artist') { // For some reason www.qobuz.com artist URLs are invalid. diff --git a/providers/Qobuz/regions.ts b/providers/Qobuz/regions.ts new file mode 100644 index 00000000..79a43f5a --- /dev/null +++ b/providers/Qobuz/regions.ts @@ -0,0 +1,73 @@ +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. + */ +export function makeLocale(region: CountryCode, language?: LanguageCode): string { + if (!language) { + language = availableRegionsAndLanguages[region.toUpperCase()]?.[0]; + } + return [region.toLowerCase(), language ?? 'en'].join('-'); +} From 8a205db8e09f2c81911c5da51b221573d3878e26 Mon Sep 17 00:00:00 2001 From: David Kellner <52860029+kellnerd@users.noreply.github.com> Date: Mon, 25 May 2026 12:04:15 +0200 Subject: [PATCH 25/29] feat(Qobuz): Use valid region input as fallback to construct www URLs When the region input is the only possible source, we check whether it is a valid Qobuz region and derive a locale. This is currently only used for release links, where localized www URLs are much more useful and reliable than the other formats which may 404. By defining `provider.availableRegions` we have a second safety net, although `makeQobuzLocale` already acts as a filter for `options.regions`. --- providers/Qobuz/mod.ts | 20 ++++++++++++++++---- providers/Qobuz/regions.ts | 8 ++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index 7fdc4c37..df823824 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -23,7 +23,7 @@ import type { QobuzPartialTrack, QobuzSearchResponse, } from './api_types.ts'; -import { makeLocale } from './regions.ts'; +import { availableRegionsAndLanguages, makeQobuzLocale } from './regions.ts'; import { ResponseError as SnapResponseError } from 'snap-storage'; const qobuzAppId = getFromEnv('HARMONY_QOBUZ_APP_ID') || ''; @@ -53,6 +53,8 @@ export default class QobuzProvider extends MetadataApiProvider { label: 'label', }; + override readonly availableRegions = new Set(Object.keys(availableRegionsAndLanguages)); + readonly releaseLookup = QobuzReleaseLookup; override readonly launchDate: PartialDate = { @@ -66,7 +68,7 @@ export default class QobuzProvider extends MetadataApiProvider { constructUrl(entity: EntityId): URL { // Prefer the more reliable, localized www.qobuz.com URL if we know the region. if (entity.region) { - const locale = makeLocale(entity.region, entity.language); + const locale = makeQobuzLocale(entity.region, entity.language); let { type, slug } = entity; if (type === 'artist') { // For some reason www.qobuz.com artist URLs are invalid. @@ -76,11 +78,11 @@ export default class QobuzProvider extends MetadataApiProvider { // Use a placeholder slug, except for labels which would become invalid. slug = '-'; } - if (slug) { + if (locale && slug) { if (type === 'label') { slug += '/download-streaming-albums'; } - return new URL([locale, type, slug ?? '-', entity.id].join('/'), 'https://www.qobuz.com'); + return new URL([locale, type, slug, entity.id].join('/'), 'https://www.qobuz.com'); } } // Fallback to open.qobuz.com URL without region and slug. @@ -181,6 +183,16 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Mon, 25 May 2026 12:24:49 +0200 Subject: [PATCH 26/29] fix(Qobuz): Prevent creation of invalid localized track URLs --- providers/Qobuz/mod.test.ts | 20 +++++++++++++++++++- providers/Qobuz/mod.ts | 6 +++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/providers/Qobuz/mod.test.ts b/providers/Qobuz/mod.test.ts index cf00509b..fef74344 100644 --- a/providers/Qobuz/mod.test.ts +++ b/providers/Qobuz/mod.test.ts @@ -3,7 +3,7 @@ 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 } from '@std/testing/bdd'; +import { afterAll, describe, it } from '@std/testing/bdd'; import { assertSnapshot } from '@std/testing/snapshot'; import QobuzProvider from './mod.ts'; @@ -111,6 +111,24 @@ describe('Qobuz provider', () => { }], }); + 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 index df823824..ef098d10 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -66,10 +66,10 @@ export default class QobuzProvider extends MetadataApiProvider { 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 (entity.region) { - const locale = makeQobuzLocale(entity.region, entity.language); - let { type, slug } = entity; + 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'; From 298e0c9d5b4881427e86f138af0120699a55864d Mon Sep 17 00:00:00 2001 From: David Kellner <52860029+kellnerd@users.noreply.github.com> Date: Mon, 25 May 2026 12:37:18 +0200 Subject: [PATCH 27/29] feat(Qobuz): Prefer localized label URLs which are not behind a login The same locale inference could also be used for artists, but since we only know the slug for the main release artist, this would lead to a mix with many localized www artist URLs having a "-" placeholder slug. --- providers/Qobuz/mod.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index ef098d10..5715d13d 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -229,7 +229,15 @@ export class QobuzReleaseLookup extends ReleaseApiLookup Date: Mon, 25 May 2026 21:21:16 +0200 Subject: [PATCH 28/29] chore(provider): Filter out undefined external ID properties Now we can update the Qobuz snapshots to account for the last commit. --- providers/Qobuz/__snapshots__/mod.test.ts.snap | 4 ++++ providers/base.ts | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/providers/Qobuz/__snapshots__/mod.test.ts.snap b/providers/Qobuz/__snapshots__/mod.test.ts.snap index 25d94f77..3f924bff 100644 --- a/providers/Qobuz/__snapshots__/mod.test.ts.snap +++ b/providers/Qobuz/__snapshots__/mod.test.ts.snap @@ -69,7 +69,10 @@ snapshot[`Qobuz provider > release lookup > single by two artists 1`] = ` externalIds: [ { id: "6464867", + language: "en", provider: "qobuz", + region: "US", + slug: "honeycomb-records-4", type: "label", }, ], @@ -217,6 +220,7 @@ snapshot[`Qobuz provider > release lookup > non-downloadable album 1`] = ` { id: "7642777", provider: "qobuz", + slug: "nbcuniversal-entertainment-1", type: "label", }, ], diff --git a/providers/base.ts b/providers/base.ts index 69ec9ff9..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. */ From 216bd84cfce8b7da264c67813006632b70ed1443 Mon Sep 17 00:00:00 2001 From: David Kellner <52860029+kellnerd@users.noreply.github.com> Date: Mon, 25 May 2026 21:22:26 +0200 Subject: [PATCH 29/29] chore(Qobuz): Remove unnecessary user auth token header again Specifying a valid app ID should be sufficient, the auth token should only be needed to access user data. Apparently it can also override invalid app IDs, that is how it got added. --- .env.example | 4 ++-- providers/Qobuz/mod.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 97cf61ba..741f6264 100644 --- a/.env.example +++ b/.env.example @@ -20,10 +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 +# Qobuz app config. HARMONY_QOBUZ_APP_ID= -HARMONY_QOBUZ_AUTH_TOKEN= diff --git a/providers/Qobuz/mod.ts b/providers/Qobuz/mod.ts index 5715d13d..c0f25e9b 100644 --- a/providers/Qobuz/mod.ts +++ b/providers/Qobuz/mod.ts @@ -27,7 +27,6 @@ import { availableRegionsAndLanguages, makeQobuzLocale } from './regions.ts'; import { ResponseError as SnapResponseError } from 'snap-storage'; const qobuzAppId = getFromEnv('HARMONY_QOBUZ_APP_ID') || ''; -const qobuzAuthToken = getFromEnv('HARMONY_QOBUZ_AUTH_TOKEN') || ''; export default class QobuzProvider extends MetadataApiProvider { readonly name = 'Qobuz'; @@ -115,7 +114,6 @@ export default class QobuzProvider extends MetadataApiProvider { requestInit: { headers: { 'X-App-Id': qobuzAppId, - 'X-User-Auth-Token': qobuzAuthToken, }, }, });