Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 57 additions & 20 deletions providers/Bandcamp/json_types.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -99,17 +114,39 @@ 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. */
upc: string | null;
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 {
Expand All @@ -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. */
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down
62 changes: 39 additions & 23 deletions providers/Bandcamp/mod.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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({
Expand All @@ -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('/'),
};
}
}
Expand All @@ -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<Data>(webUrl: URL, maxTimestamp?: number): Promise<CacheEntry<Data>> {
Expand Down Expand Up @@ -117,18 +129,18 @@ export default class BandcampProvider extends MetadataProvider {
}
}

export class BandcampReleaseLookup extends ReleaseLookup<BandcampProvider, AlbumPage> {
export class BandcampReleaseLookup extends ReleaseLookup<BandcampProvider, ReleasePage> {
constructReleaseApiUrl(): URL | undefined {
return undefined;
}

async getRawRelease(): Promise<AlbumPage> {
async getRawRelease(): Promise<ReleasePage> {
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<AlbumPage>(
const webUrl = this.constructReleaseUrl(this.lookup.value, this.lookup);
const { content: release, timestamp } = await this.provider.extractEmbeddedJson<ReleasePage>(
webUrl,
this.options.snapshotMaxTimestamp,
);
Expand All @@ -137,7 +149,7 @@ export class BandcampReleaseLookup extends ReleaseLookup<BandcampProvider, Album
return release;
}

async convertRawRelease(albumPage: AlbumPage): Promise<HarmonyRelease> {
async convertRawRelease(albumPage: ReleasePage): Promise<HarmonyRelease> {
const { tralbum: rawRelease } = albumPage;
const { current, packages } = rawRelease;

Expand All @@ -147,9 +159,9 @@ export class BandcampReleaseLookup extends ReleaseLookup<BandcampProvider, Album
releaseUrl = new URL(packages[0].url);
}

this.id = this.provider.extractEntityFromUrl(releaseUrl)?.id;
if (!this.id) {
throw new ProviderError(this.provider.name, `Failed to extract ID from ${releaseUrl}`);
if (rawRelease.item_type === 'track' && rawRelease.album_url) {
const albumUrl = new URL(rawRelease.album_url, releaseUrl);
this.addMessage(`Please import the full release from ${albumUrl}`, 'warning');
}

// The "band" can be the artist or a label.
Expand Down Expand Up @@ -192,7 +204,7 @@ export class BandcampReleaseLookup extends ReleaseLookup<BandcampProvider, Album

const images = [this.getArtwork(rawRelease.art_id, ['front'])];
let tracks: Array<TrackInfo | PlayerTrack> = 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;
Expand All @@ -213,6 +225,10 @@ export class BandcampReleaseLookup extends ReleaseLookup<BandcampProvider, Album
}
const tracklist = tracks.map(this.convertRawTrack.bind(this));

if (current.type === 'track' && current.isrc) {
tracklist[0].isrc = current.isrc;
}

const realTrackCount = albumPage['og:description']?.match(/(\d+) track/i)?.[1];
if (realTrackCount) {
const hiddenTrackCount = parseInt(realTrackCount) - tracks.length;
Expand Down Expand Up @@ -247,8 +263,8 @@ export class BandcampReleaseLookup extends ReleaseLookup<BandcampProvider, Album
title: current.title,
artists: [artist],
labels: label ? [label] : undefined,
gtin: current.upc ?? undefined,
releaseDate: parseISODateTime(current.release_date),
gtin: (current as AlbumCurrent).upc ?? undefined,
releaseDate: current.release_date ? parseISODateTime(current.release_date) : undefined,
availableIn: ['XW'],
media: [{
format: 'Digital Media',
Expand All @@ -268,13 +284,13 @@ export class BandcampReleaseLookup extends ReleaseLookup<BandcampProvider, Album
return release;
}

convertRawTrack(rawTrack: TrackInfo | PlayerTrack): HarmonyTrack {
convertRawTrack(rawTrack: TrackInfo | PlayerTrack, index: number): HarmonyTrack {
const { artist } = rawTrack;
let { title } = rawTrack;
let trackNumber: number;

if ('track_num' in rawTrack) {
trackNumber = rawTrack.track_num;
trackNumber = rawTrack.track_num ?? index + 1;
if (artist) {
// Track title is prefixed by the track artist (if filled).
title = title.replace(`${artist} - `, '');
Expand Down
23 changes: 16 additions & 7 deletions providers/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export abstract class MetadataProvider {
readonly features: FeatureQualityMap = {};

/** Maps MusicBrainz entity types to the corresponding entity types of the provider. */
abstract readonly entityTypeMap: Record<HarmonyEntityType, string>;
abstract readonly entityTypeMap: Record<HarmonyEntityType, string | string[]>;

abstract readonly releaseLookup: ReleaseLookupConstructor;

Expand Down Expand Up @@ -216,9 +216,12 @@ export abstract class ReleaseLookup<Provider extends MetadataProvider, RawReleas
}

const releaseType = this.provider.entityTypeMap['release'];
if (entity.type !== releaseType) {
if (
Array.isArray(releaseType) ? !releaseType.includes(entity.type) : entity.type !== releaseType
) {
throw new ProviderError(this.provider.name, `${specifier} is not a release URL`);
}
this.id = entity.id;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assigned the id here so that I could access it in getRawRelease. Is the assignment in convertRawRelease still needed?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, no idea why I haven't made this assignment in the constructor right from the start 😇
Looks like this is worth to be changed in all provider implementations.

this.lookup = { method: 'id', value: entity.id };

// Prefer region of the given release URL over the standard preferences.
Expand All @@ -227,6 +230,7 @@ export abstract class ReleaseLookup<Provider extends MetadataProvider, RawReleas
this.options.regions = new Set([entity.region]);
}
} else if (typeof specifier === 'string') {
this.id = specifier;
this.lookup = { method: 'id', value: specifier };
} else if (typeof specifier === 'number') {
this.lookup = { method: 'gtin', value: specifier.toString() };
Expand Down Expand Up @@ -255,13 +259,18 @@ export abstract class ReleaseLookup<Provider extends MetadataProvider, RawReleas
}

/**
* Constructs a canonical release URL for the given provider ID (and optional region).
* Constructs a canonical release URL for the given provider ID and lookup parameters.
*
* This is implemented using {@linkcode MetadataProvider.constructUrl} by default.
*/
constructReleaseUrl(id: string, region?: CountryCode): URL {
const type = this.provider.entityTypeMap['release'];
return this.provider.constructUrl({ id, type, region });
constructReleaseUrl(id: string, lookup: ReleaseLookupParameters): URL {
let type = this.provider.entityTypeMap['release'];
if (Array.isArray(type)) {
// Use the first mapped type as the default `release` type of the provider.
// This should mean the actual type is encoded in the ID, but we'll default to this if not.
type = type[0];
}
return this.provider.constructUrl({ id, type, region: lookup.region });
}

/** Constructs an optional API URL for a release using the specified lookup options. */
Expand Down Expand Up @@ -312,7 +321,7 @@ export abstract class ReleaseLookup<Provider extends MetadataProvider, RawReleas
name: this.provider.name,
internalName: this.provider.internalName,
id: this.id,
url: this.constructReleaseUrl(this.id, this.lookup.region),
url: this.constructReleaseUrl(this.id, this.lookup),
apiUrl: this.constructReleaseApiUrl(),
lookup: this.lookup,
cacheTime: this.cacheTime,
Expand Down
3 changes: 1 addition & 2 deletions server/routes/release.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | undefined = undefined;
Expand Down