diff --git a/providers/Bandcamp/json_types.ts b/providers/Bandcamp/json_types.ts index bd7993ef..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,10 +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; + 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; + 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 { @@ -131,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. */ @@ -150,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; @@ -165,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 4713303a..4d2f6efb 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, @@ -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> { @@ -117,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); - const { content: release, timestamp } = await this.provider.extractEmbeddedJson( + const webUrl = this.constructReleaseUrl(this.lookup.value, this.lookup); + const { content: release, timestamp } = await this.provider.extractEmbeddedJson( webUrl, this.options.snapshotMaxTimestamp, ); @@ -137,7 +149,7 @@ export class BandcampReleaseLookup extends ReleaseLookup { + async convertRawRelease(albumPage: ReleasePage): Promise { const { tralbum: rawRelease } = albumPage; const { current, packages } = rawRelease; @@ -147,9 +159,9 @@ 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; @@ -213,6 +225,10 @@ export class BandcampReleaseLookup extends ReleaseLookup; + abstract readonly entityTypeMap: Record; abstract readonly releaseLookup: ReleaseLookupConstructor; @@ -216,9 +216,12 @@ export abstract class ReleaseLookup { - // 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;