From 995c3656933acbe19a54a43ac8397c32ccfb2e27 Mon Sep 17 00:00:00 2001 From: Michael Wiencek Date: Mon, 27 May 2024 19:08:21 -0500 Subject: [PATCH 1/5] feat(Bandcamp): Support standalone tracks Some `/track/` URLs on Bandcamp represent singles (often with their own cover art) that aren't otherwise released as part of an album. It seems you can distinguish these from album tracks by checking `TrAlbumCurrent.album_id`, which is `null` for tracks without an associated album. (Perhaps there is another, more obvious way that I missed.) --- providers/Bandcamp/json_types.ts | 2 ++ providers/Bandcamp/mod.ts | 41 ++++++++++++++++++++------------ providers/base.ts | 23 ++++++++++++------ 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/providers/Bandcamp/json_types.ts b/providers/Bandcamp/json_types.ts index bd7993ef..aef2fd05 100644 --- a/providers/Bandcamp/json_types.ts +++ b/providers/Bandcamp/json_types.ts @@ -110,6 +110,8 @@ interface TrAlbumCurrent { id: number; /** Type of the release. */ type: ReleaseType; + /** ID of the release. Can be `null` for standalone tracks. */ + album_id: number | null; } enum DownloadPreference { diff --git a/providers/Bandcamp/mod.ts b/providers/Bandcamp/mod.ts index 4713303a..e7fe0b1c 100644 --- a/providers/Bandcamp/mod.ts +++ b/providers/Bandcamp/mod.ts @@ -25,7 +25,7 @@ export default class BandcampProvider extends MetadataProvider { readonly supportedUrls = new URLPattern({ hostname: ':artist.bandcamp.com', - pathname: '/:type(album)/:album', + pathname: '/:type(album|track)/:title', }); readonly artistUrlPattern = new URLPattern({ @@ -42,20 +42,22 @@ export default class BandcampProvider extends MetadataProvider { readonly entityTypeMap = { artist: 'artist', - release: 'album', + release: ['album', 'track'], }; readonly releaseLookup = BandcampReleaseLookup; - extractEntityFromUrl(url: URL): EntityId | undefined { + extractEntityFromUrl(url: URL): EntityId { const albumResult = this.supportedUrls.exec(url); if (albumResult) { const artist = albumResult.hostname.groups.artist!; - const { type, album } = albumResult.pathname.groups; - if (type && album) { + const { type, title } = albumResult.pathname.groups; + if (type && title) { return { type, - id: [artist, album].join('/'), + // 'album' is assumed by default, so we only encode the `type` for tracks + // to save space. + id: (type === 'album' ? [artist, title] : [artist, type, title]).join('/'), }; } } @@ -67,16 +69,26 @@ export default class BandcampProvider extends MetadataProvider { id: artistResult.hostname.groups.artist!, }; } + + throw new ProviderError(this.name, `Failed to extract ID from ${url}`); } constructUrl(entity: EntityId): URL { - const [artist, album] = entity.id.split('/', 2); + let [artist, type, title] = entity.id.split('/', 3); const artistUrl = new URL(`https://${artist}.bandcamp.com`); if (entity.type === 'artist') return artistUrl; - // else if (entity.type === 'album') - return new URL(['album', album].join('/'), artistUrl); + // else if (type === 'album' || type === 'track') + + // Only tracks include their `type` in the ID; we default to 'album' otherwise. + if (title === undefined) { + title = type; + type = entity.type; + } + // Use the entity type encoded in the ID, which defaults to 'album' if not specified, + // rather than `entity.type`, which is fixed to 'album' as the default Bandcamp release type. + return new URL([type, title].join('/'), artistUrl); } extractEmbeddedJson(webUrl: URL, maxTimestamp?: number): Promise> { @@ -127,7 +139,7 @@ export class BandcampReleaseLookup extends ReleaseLookup( webUrl, this.options.snapshotMaxTimestamp, @@ -141,17 +153,16 @@ export class BandcampReleaseLookup extends ReleaseLookup; + abstract readonly entityTypeMap: Record; abstract readonly releaseLookup: ReleaseLookupConstructor; @@ -216,9 +216,12 @@ export abstract class ReleaseLookup Date: Mon, 27 May 2024 19:35:48 -0500 Subject: [PATCH 2/5] feat(Bandcamp): Extract an ISRC for track lookups Bandcamp `/track/` URLs may include an ISRC in `TrAlbumCurrent`. --- providers/Bandcamp/json_types.ts | 2 ++ providers/Bandcamp/mod.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/providers/Bandcamp/json_types.ts b/providers/Bandcamp/json_types.ts index aef2fd05..79e306a0 100644 --- a/providers/Bandcamp/json_types.ts +++ b/providers/Bandcamp/json_types.ts @@ -112,6 +112,8 @@ interface TrAlbumCurrent { type: ReleaseType; /** ID of the release. Can be `null` for standalone tracks. */ album_id: number | null; + /** ISRC of the track, for track releases. */ + isrc?: string; } enum DownloadPreference { diff --git a/providers/Bandcamp/mod.ts b/providers/Bandcamp/mod.ts index e7fe0b1c..b9e395b9 100644 --- a/providers/Bandcamp/mod.ts +++ b/providers/Bandcamp/mod.ts @@ -224,6 +224,10 @@ export class BandcampReleaseLookup extends ReleaseLookup Date: Tue, 28 May 2024 22:11:48 +0200 Subject: [PATCH 3/5] refactor(Bandcamp): Separate and improve album and track types Handle `null` and missing values for release date, GTIN and track number. --- providers/Bandcamp/json_types.ts | 79 ++++++++++++++++++++++---------- providers/Bandcamp/mod.ts | 20 ++++---- 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/providers/Bandcamp/json_types.ts b/providers/Bandcamp/json_types.ts index 79e306a0..197d86b3 100644 --- a/providers/Bandcamp/json_types.ts +++ b/providers/Bandcamp/json_types.ts @@ -1,6 +1,6 @@ -export interface AlbumPage { +export interface ReleasePage { /** Information about the release. */ - tralbum: TrAlbum; + tralbum: Track | Album; /** Information about the band account (artist/label). */ band: Band; /** OpenGraph description, contains the number of tracks (including hidden tracks). */ @@ -50,22 +50,37 @@ interface TrAlbum { package_associated_license_id: null; has_video: null; tralbum_subscriber_only: boolean; + /** Indicates whether the release is currently available for pre-order (`null` for standalone tracks). */ + album_is_preorder: boolean | null; + /** GMT date string when the release is/was released (`null` for standalone tracks). */ + album_release_date: string | null; + /** Tracklist of the download release. */ + trackinfo: TrackInfo[]; + /** URL of the release page, might be a custom domain. */ + url: string; +} + +interface Album extends TrAlbum { + current: AlbumCurrent; + item_type: 'album'; featured_track_id: number; initial_track_num: null; /** Indicates whether the release is currently available for pre-order. */ is_preorder: boolean; - /** Same as {@linkcode is_preorder}? */ - album_is_preorder: boolean; - /** GMT date string when the release is/was released. */ - album_release_date: string; - /** Tracklist of the download release. */ - trackinfo: TrackInfo[]; playing_from: 'album page'; - /** URL of the release page, might be a custom domain. */ - url: string; use_expando_lyrics: boolean; } +interface Track extends TrAlbum { + current: TrackCurrent; + item_type: 'track'; + playing_from: 'track page'; + /** Relative URL of the release this track is part of (`null` for standalone tracks). */ + album_url: string | null; + /** Same as {@linkcode album_url}? */ + album_upsell_url: string | null; +} + interface TrAlbumCurrent { audit: number; /** Title of the release. */ @@ -99,6 +114,13 @@ interface TrAlbumCurrent { selling_band_id: number; art_id: number; download_desc_id: null; + /** ID of the release. */ + id: number; + /** Type of the release. */ + type: ReleaseType; +} + +export interface AlbumCurrent extends TrAlbumCurrent { /** GMT date string when the release is/was released. */ release_date: string; /** UPC/EAN barcode of the download release. */ @@ -106,14 +128,25 @@ interface TrAlbumCurrent { purchase_url: null; purchase_title: null; featured_track_id: number; - /** ID of the release */ - id: number; - /** Type of the release. */ - type: ReleaseType; - /** ID of the release. Can be `null` for standalone tracks. */ + type: 'album'; +} + +export interface TrackCurrent extends TrAlbumCurrent { + /** Number of the track (`null` for standalone tracks). */ + track_number: number | null; + release_date: null; + file_name: null; + lyrics: string | null; + /** ID of the release this track is part of (`null` for standalone tracks). */ album_id: number | null; - /** ISRC of the track, for track releases. */ - isrc?: string; + encodings_id: number; + pending_encodings_id: null; + license_type: 1; // TODO + /** ISRC of the track. */ + isrc: string | null; + preorder_download: null; + streaming: 1; // = boolean `1 | null`? + type: 'track'; } enum DownloadPreference { @@ -135,8 +168,8 @@ export interface TrackInfo { encodings_id: number; license_type: 1; // TODO private: null; - /** Number of the track. */ - track_num: number; + /** Number of the track (`null` for standalone tracks). */ + track_num: number | null; /** Indicates whether the release is currently available for pre-order. */ album_preorder: boolean; /** Indicates whether the track is still unreleased. */ @@ -154,8 +187,8 @@ export interface TrackInfo { free_album_download: boolean; /** Duration in seconds (floating point, `0.0` for unreleased tracks). */ duration: number; - /** Always `null` on release pages, even if lyrics exist on the track page. */ - lyrics: null; + /** Lyrics of the track. Always `null` on release pages, even if lyrics exist on the track page. */ + lyrics: string | null; /** Size of the lyrics (in bytes). */ sizeof_lyrics: number; is_draft: boolean; @@ -169,8 +202,8 @@ export interface TrackInfo { alt_link: null; encoding_error: null; encoding_pending: null; - play_count: null; - is_capped: null; + play_count: number | null; + is_capped: boolean | null; track_license_id: null; } diff --git a/providers/Bandcamp/mod.ts b/providers/Bandcamp/mod.ts index b9e395b9..ffddbd5c 100644 --- a/providers/Bandcamp/mod.ts +++ b/providers/Bandcamp/mod.ts @@ -1,4 +1,4 @@ -import type { AlbumPage, PlayerData, PlayerTrack, TrackInfo } from './json_types.ts'; +import type { AlbumCurrent, PlayerData, PlayerTrack, ReleasePage, TrackInfo } from './json_types.ts'; import type { ArtistCreditName, Artwork, @@ -129,18 +129,18 @@ export default class BandcampProvider extends MetadataProvider { } } -export class BandcampReleaseLookup extends ReleaseLookup { +export class BandcampReleaseLookup extends ReleaseLookup { constructReleaseApiUrl(): URL | undefined { return undefined; } - async getRawRelease(): Promise { + async getRawRelease(): Promise { if (this.lookup.method === 'gtin') { throw new ProviderError(this.provider.name, 'GTIN lookups are not supported'); } const webUrl = this.constructReleaseUrl(this.lookup.value, this.lookup); - const { content: release, timestamp } = await this.provider.extractEmbeddedJson( + const { content: release, timestamp } = await this.provider.extractEmbeddedJson( webUrl, this.options.snapshotMaxTimestamp, ); @@ -149,7 +149,7 @@ export class BandcampReleaseLookup extends ReleaseLookup { + async convertRawRelease(albumPage: ReleasePage): Promise { const { tralbum: rawRelease } = albumPage; const { current, packages } = rawRelease; @@ -203,7 +203,7 @@ export class BandcampReleaseLookup extends ReleaseLookup = rawRelease.trackinfo; - if (rawRelease.is_preorder) { + if (rawRelease.item_type === 'album' && rawRelease.album_is_preorder) { // Fetch embedded player JSON which already has all track durations for pre-orders. const embeddedPlayerRelease = await this.getEmbeddedPlayerRelease(rawRelease.id); tracks = embeddedPlayerRelease.tracks; @@ -262,8 +262,8 @@ export class BandcampReleaseLookup extends ReleaseLookup Date: Tue, 28 May 2024 22:12:53 +0200 Subject: [PATCH 4/5] feat(Bandcamp): Link to the full release of tracks from an album --- providers/Bandcamp/mod.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/providers/Bandcamp/mod.ts b/providers/Bandcamp/mod.ts index ffddbd5c..4d2f6efb 100644 --- a/providers/Bandcamp/mod.ts +++ b/providers/Bandcamp/mod.ts @@ -153,16 +153,17 @@ export class BandcampReleaseLookup extends ReleaseLookup Date: Tue, 28 May 2024 22:33:06 +0200 Subject: [PATCH 5/5] chore(web): Always generate edit notes with permalinks The original thought was not to seed edit notes with localhost URLs, but this can also happen with a local production server, not only with a development server. It is rather useful being able to check the permalinks in development. --- server/routes/release.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/routes/release.tsx b/server/routes/release.tsx index e2acf433..0fdbc753 100644 --- a/server/routes/release.tsx +++ b/server/routes/release.tsx @@ -18,8 +18,7 @@ import { LookupError, type ProviderError } from '@/utils/errors.ts'; const seederTargetUrl = new URL('release/add', musicbrainzBaseUrl); export default defineRoute(async (req, ctx) => { - // Only set seeder URL (used for permalinks) in production servers. - const seederSourceUrl = ctx.config.dev ? undefined : ctx.url; + const seederSourceUrl = ctx.url; const errors: Error[] = []; let release: HarmonyRelease | undefined; let enabledProviders: Set | undefined = undefined;