Skip to content
Draft
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
468 changes: 421 additions & 47 deletions music_assistant/providers/bandcamp/__init__.py

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions music_assistant/providers/bandcamp/_ids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""
Composite artist ID utilities for the Bandcamp provider.

Bandcamp's data model treats labels as bands: an album published on a
label's page (e.g. ``audiophob``) reports ``band_id`` = the label, while the
actual performer (e.g. ``Mortaja``) lives in a separate ``tralbum_artist``
(or ``artist_name`` / autocomplete-``band_name``) field. Performers without
their own Bandcamp page exist only as a per-album credit on someone else's
page — there is no real ``band_id`` to link to.

We model these performers as *synthetic artists scoped to a band page*. The
artist ``item_id`` stored on Music Assistant media items is one of:

* ``"{band_id}"`` — a real Bandcamp band/label page (existing behavior).
* ``"{band_id}:{slug}"`` — a performer credit on the band page identified
by ``{band_id}``. The ``slug`` is derived from the performer name.

Two performers with the same name on different band pages produce different
synthetic IDs (e.g. Mortaja-on-audiophob vs. Mortaja-on-AdiósMundoCruel),
so the cross-label collision problem ALERTua flagged in
music-assistant/support#5389 doesn't apply.
"""

from __future__ import annotations

import re

# Slugs are lowercase alphanumerics joined by single hyphens.
# The colon separator between band_id and slug is unambiguous because
# band_ids are pure digits and slugs contain no colons.
_SLUG_NON_ALNUM = re.compile(r"[^a-z0-9]+")


def slugify_performer(name: str) -> str:
"""
Reduce a performer name to a stable lowercase slug.

:param name: Raw performer name from the Bandcamp API.
:returns: A slug containing only ``[a-z0-9-]`` with no leading or
trailing hyphens. Empty string if ``name`` has no slug-eligible
characters.
"""
return _SLUG_NON_ALNUM.sub("-", name.lower()).strip("-")


def make_artist_id(band_id: int | str, performer: str | None = None) -> str:
"""
Build the artist ``item_id`` used on Music Assistant media items.

:param band_id: The Bandcamp ``band_id`` (page owner).
:param performer: Optional performer name. If provided and produces a
non-empty slug, the result is the synthetic form
``"{band_id}:{slug}"``. Otherwise the plain ``"{band_id}"`` form
is returned (the page itself).
"""
if not performer:
return str(band_id)
slug = slugify_performer(performer)
if not slug:
return str(band_id)
return f"{band_id}:{slug}"


def parse_artist_id(artist_id: str) -> tuple[int, str | None]:
"""
Split an artist ``item_id`` into ``(band_id, performer_slug)``.

:returns: A tuple where the second element is ``None`` for plain
``"{band_id}"`` IDs and a non-empty slug for synthetic ones.
:raises ValueError: If the band-id portion is not a valid integer.
"""
band, _, slug = artist_id.partition(":")
return int(band), (slug or None)
167 changes: 144 additions & 23 deletions music_assistant/providers/bandcamp/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
)
from music_assistant_models.media_items import Track as MATrack

from ._ids import make_artist_id, slugify_performer


class DiscographyItem(TypedDict, total=False):
"""Raw discography item dict from the band_details API."""
Expand Down Expand Up @@ -51,7 +53,8 @@ def __init__(self, domain: str, instance_id: str):
def streaming_url_from_api(
streaming_info: dict[str, str],
) -> tuple[str | None, int | None, ContentType]:
"""Parse streaming URL info.
"""
Parse streaming URL info.

:param streaming_info: Dict of format keys to URLs from the Bandcamp API.
"""
Expand All @@ -72,9 +75,17 @@ def streaming_url_from_api(
content_type = ContentType.UNKNOWN
return streaming_url, bitrate, content_type

def track_from_search(self, item: SearchResultTrack) -> MATrack:
"""Create a Track from new API SearchResultTrack."""
def track_from_search(
self, item: SearchResultTrack, *, artist_item_id: str | None = None
) -> MATrack:
"""
Create a Track from new API SearchResultTrack.

:param artist_item_id: Pre-resolved artist item_id; falls back to
slug-based resolution if omitted.
"""
track_id = f"{item.artist_id}-{item.album_id or 0}-{item.id}"
artist_item_id = artist_item_id or make_artist_id(item.artist_id, item.artist_name)
return MATrack(
item_id=track_id,
provider=self.instance_id,
Expand All @@ -83,7 +94,7 @@ def track_from_search(self, item: SearchResultTrack) -> MATrack:
[
ItemMapping(
media_type=MediaType.ARTIST,
item_id=str(item.artist_id),
item_id=artist_item_id,
provider=self.instance_id,
name=item.artist_name,
)
Expand All @@ -109,9 +120,17 @@ def track_from_search(self, item: SearchResultTrack) -> MATrack:
},
)

def album_from_search(self, item: SearchResultAlbum) -> MAAlbum:
"""Create an Album from new API SearchResultAlbum."""
def album_from_search(
self, item: SearchResultAlbum, *, artist_item_id: str | None = None
) -> MAAlbum:
"""
Create an Album from new API SearchResultAlbum.

:param artist_item_id: Pre-resolved artist item_id; falls back to
slug-based resolution if omitted.
"""
album_id = f"{item.artist_id}-{item.id}"
artist_item_id = artist_item_id or make_artist_id(item.artist_id, item.artist_name)
output = MAAlbum(
item_id=album_id,
provider=self.instance_id,
Expand All @@ -121,7 +140,7 @@ def album_from_search(self, item: SearchResultAlbum) -> MAAlbum:
[
ItemMapping(
media_type=MediaType.ARTIST,
item_id=str(item.artist_id),
item_id=artist_item_id,
provider=self.instance_id,
name=item.artist_name,
uri=item.artist_url,
Expand Down Expand Up @@ -182,10 +201,26 @@ def track_from_api(
album_id: str | int | None = None,
album_name: str = "",
album_image_url: str = "",
*,
tralbum_artist: str | None = None,
artist_item_id: str | None = None,
) -> MATrack:
"""Convert a Track object from the API to MA Track format."""
"""
Convert a Track object from the API to MA Track format.

:param tralbum_artist: Per-album performer credit. When set and
different from the band's own name, the displayed artist
name is the performer rather than the band.
:param artist_item_id: Pre-resolved artist item_id; falls back to
slug-based resolution if omitted.
"""
album_id = album_id or 0
_, bitrate, content_type = self.streaming_url_from_api(track.streaming_url or {})
band_name = track.artist.name
display_name = tralbum_artist or band_name
artist_item_id = artist_item_id or _resolve_artist_id(
band_id=track.artist.id, performer=tralbum_artist, band_name=band_name
)
output = MATrack(
item_id=f"{track.artist.id}-{album_id}-{track.id}",
provider=self.instance_id,
Expand All @@ -194,9 +229,9 @@ def track_from_api(
[
ItemMapping(
media_type=MediaType.ARTIST,
item_id=str(track.artist.id),
item_id=artist_item_id,
provider=self.instance_id,
name=track.artist.name,
name=display_name,
)
]
),
Expand Down Expand Up @@ -272,18 +307,26 @@ def artist_from_api(self, artist: APIArtist) -> MAArtist:
)
return output

def album_from_discography_item(self, item: DiscographyItem) -> MAAlbum:
"""Convert a raw discography dict to MA Album format.
def album_from_discography_item(
self, item: DiscographyItem, *, artist_item_id: str | None = None
) -> MAAlbum:
"""
Convert a raw discography dict to MA Album format.

Discography items come from the band_details API and contain summary
data (title, art_id, release_date string) without full album details.
Fields not available from the discography endpoint (url, description)
are omitted and populated later when get_album fetches full details.
:param artist_item_id: Pre-resolved artist item_id; falls back to
slug-based resolution if omitted.
"""
band_id = item.get("band_id", 0)
item_id = item.get("item_id", 0)
album_id = f"{band_id}-{item_id}"
artist_name = item.get("artist_name") or item.get("band_name") or ""
# `artist_name` (when set) is the per-album performer; `band_name`
# is the page owner. They differ on label-released albums.
performer = item.get("artist_name")
band_name = item.get("band_name") or ""
display_name = performer or band_name
artist_item_id = artist_item_id or _resolve_artist_id(
band_id=band_id, performer=performer, band_name=band_name
)

# Build art URL from art_id (matches _build_art_url in parsers.py)
art_id = item.get("art_id")
Expand All @@ -305,9 +348,9 @@ def album_from_discography_item(self, item: DiscographyItem) -> MAAlbum:
[
ItemMapping(
media_type=MediaType.ARTIST,
item_id=str(band_id),
item_id=artist_item_id,
provider=self.instance_id,
name=artist_name,
name=display_name,
)
]
),
Expand All @@ -331,9 +374,19 @@ def album_from_discography_item(self, item: DiscographyItem) -> MAAlbum:
)
return output

def album_from_api(self, album: APIAlbum) -> MAAlbum:
"""Convert an API Album object to MA Album format."""
def album_from_api(self, album: APIAlbum, *, artist_item_id: str | None = None) -> MAAlbum:
"""
Convert an API Album object to MA Album format.

:param artist_item_id: Pre-resolved artist item_id; falls back to
slug-based resolution if omitted.
"""
album_id = f"{album.artist.id}-{album.id}"
band_name = album.artist.name
display_name = album.tralbum_artist or band_name
artist_item_id = artist_item_id or _resolve_artist_id(
band_id=album.artist.id, performer=album.tralbum_artist, band_name=band_name
)
output = MAAlbum(
item_id=album_id,
provider=self.instance_id,
Expand All @@ -342,9 +395,9 @@ def album_from_api(self, album: APIAlbum) -> MAAlbum:
[
ItemMapping(
media_type=MediaType.ARTIST,
item_id=str(album.artist.id),
item_id=artist_item_id,
provider=self.instance_id,
name=album.artist.name,
name=display_name,
image=MediaItemImage(
path=album.art_url,
type=ImageType.THUMB,
Expand Down Expand Up @@ -374,3 +427,71 @@ def album_from_api(self, album: APIAlbum) -> MAAlbum:
)
output.metadata.description = f"{album.url}\n{album.about or ''}".strip()
return output

def synthetic_artist(
self,
band_id: int,
performer_name: str,
*,
url: str | None = None,
image_url: str | None = None,
) -> MAArtist:
"""
Build an Artist for a per-album performer that has no band page.

:param band_id: The hosting Bandcamp band's id.
:param performer_name: The performer credit (display name).
:param url: Optional URL to attach. Usually the band page; the
performer doesn't have their own.
:param image_url: Optional artwork to use as the artist's thumb,
typically borrowed from one of their albums.
"""
item_id = make_artist_id(band_id, performer_name)
output = MAArtist(
item_id=item_id,
provider=self.instance_id,
name=performer_name,
uri=url,
provider_mappings={
ProviderMapping(
item_id=item_id,
provider_domain=self.domain,
provider_instance=self.instance_id,
url=url,
)
},
)
if url:
output.metadata.description = url
if image_url:
output.metadata.add_image(
MediaItemImage(
type=ImageType.THUMB,
path=image_url,
provider=self.instance_id,
remotely_accessible=True,
)
)
return output


def _resolve_artist_id(
*,
band_id: int | str,
performer: str | None,
band_name: str | None,
) -> str:
"""
Choose between a real or synthetic artist item_id.

Returns the synthetic ``{band_id}:{slug(performer)}`` form only when
the performer is set AND its slug differs from the band's own slug;
otherwise returns the plain ``{band_id}``.
"""
if (
not performer
or not band_name
or slugify_performer(performer) == slugify_performer(band_name)
):
return str(band_id)
return make_artist_id(band_id, performer)
2 changes: 1 addition & 1 deletion music_assistant/providers/bandcamp/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"name": "Bandcamp",
"description": "Stream music from Bandcamp's catalog.",
"codeowners": ["@ALERTua", "@teancom"],
"requirements": ["bandcamp-async-api==0.1.1"],
"requirements": ["bandcamp-async-api==0.2.1"],
"documentation": "https://music-assistant.io/music-providers/bandcamp/",
"multi_instance": true
}
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ audible==0.10.0
auntie-sounds==1.1.8
av==16.1.0
awesomeversion>=24.6.0
bandcamp-async-api==0.1.1
bandcamp-async-api==0.2.1
beat-this==1.1.0
bidict==0.23.1
certifi==2025.11.12
Expand Down
Loading
Loading