Your Bandcamp collection, on the TV. An unofficial Android TV client for the Subsonic endpoint Bandcamp shipped in July 2026 — no server in the middle, no Bandcamp account. Radio is the one scraped exception; Last.fm is optional.
Working, on a TCL Google TV (Android 14, G08_4K_GB), sideloaded. The app itself is not published; candiamp.tv describes it.
| Works | Notes |
|---|---|
| Browse collection | Artists → records → tracks, cached on disk |
| Search | search3 over your own collection, with an on-screen keyboard, artist/album details on track results, and playback from results |
| Recently added / shuffled rows | From the album list, newest first; records without audio stay off the home feed |
| Queue | The live player timeline: play, move or remove any track; play next / add to queue from a record page or by long-pressing a track |
| Play | 256 kbps VBR MP3, seeking works (server answers 206 Partial Content), with Previous / Pause / Next buttons in the player |
| TV remote | OK / play-pause / next / previous / 10-second seek |
| Background audio | Keeps playing when you leave the app (MediaSession) |
| Pairing from a phone | Local HTTP page served by the TV itself |
| Radio | Scraped from bandcamp.com/radio; whole episode as one stream, timestamped tracklist |
| Last.fm | Linking, scrobbling with a durable queue, and a stats screen (top artists/albums/tracks, recent) |
| Sync | ~4 s for 17 records / 190 tracks, library cached as JSON with a schema version |
| Missing, and why |
|---|
Playlists — Bandcamp's API exposes getPlaylists but not getPlaylist, so a playlist's tracks cannot be read |
Favourites / like — star accepts requests and does nothing, unstar is absent |
Offline downloads — download returns an error; the API has no download role |
Quality choice — maxBitRate is ignored, one encode only |
| Lyrics, similar artists, song info — not implemented by Bandcamp |
The queue is the player's timeline, not a wish list: it is the media items ExoPlayer is holding, so played tracks stay above the current one and upcoming tracks below it.
- Play next inserts the selection directly after the current track; Add to queue appends it. Both are on a record page (for the whole record) and in the long-press sheet on any track, including search results.
- In the Queue screen, OK jumps to a row. Long-press offers Play now, Move up, Move down and Remove from queue; removing what is playing moves on to the next track.
- The sidebar entry carries the number queued, and the full player shows the previous and next entries plus a Queue button.
- Played tracks are dimmed and the current one is marked Playing, so the list reads as history and intent at once.
- The player's Previous / Pause / Next buttons mean the remote's media keys are optional; OK still plays and pauses, Left and Right still seek ten seconds. Radio is a single compiled stream and deliberately does not join the queue.
Needs JDK 21+ and the Android SDK (platform 37). On a TV with network debugging on:
adb connect <tv-ip>:5555
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkThe TV has no keyboard worth using, so the TV serves a small page on your own network:
- candiamp shows
http://<tv-ip>:8080and a four-character code. - Open that address on your phone, on the same Wi-Fi.
- Paste the Subsonic credentials from
bandcamp.com → Settings → Fan → Subsonic, type the code, press Connect.
Nothing leaves the LAN: the TV receives the form, stores the password in the Android keystore, and talks to bandcamp.com directly from then on.
No Wi-Fi on the TV? Push a file instead:
adb push candiamp.txt /sdcard/Download/candiamp.txt # three lines: server, username, passwordthen choose Import a file instead on the pairing screen.
The pairing code is also written to logcat for development:
adb logcat -s candiampLast.fm support is opt-in. The TV needs the API key and shared secret from a Last.fm API account, and they are entered from a phone rather than with a remote:
- Create an API account at
last.fm/api/account/createand set its callback URL tohttps://candiamp.tv/lastfm. The callback is never reached — the TV completes the flow by pollingauth.getSession. - On the TV: Settings → Set up Last.fm. It shows a LAN address such as
http://192.168.1.20:8080and a four-character code. - On the phone, open that address, paste the API key and shared secret, then authorize candiamp on Last.fm.
The API key stays in preferences; the shared secret and the session key are sealed with the Android keystore. The setup page only runs while that screen is open. Nothing is sent to Last.fm until an account is linked.
Once linked, scrobbling is on and can be turned off from Settings:
- Now-playing is sent when a track starts.
- A play is scrobbled once it is longer than 30 seconds and has been listened to for half its length or four minutes, whichever comes first. Seeking does not fake listening time; a scrobble is queued the moment the threshold is met, not at the end of the track.
- The queue is a JSON file in app-private storage and is written before sending. Entries older than 13 days are dropped (Last.fm rejects two weeks), and batches go out 50 at a time. Offline plays survive restarts and flush when the network returns.
- Radio songs are scrobbled from the episode's timestamped tracklist with
chosenByUser=0, because the host chose them. The episode itself is never scrobbled. - Failed writes retry on Last.fm errors 11/16/29; a revoked session (error 9) shows "Reconnect Last.fm" in Settings instead of silently dropping plays.
Metadata comes from Bandcamp (there are no MusicBrainz IDs), so scrobbles match on artist, title and album.
The Last.fm sidebar entry shows the linked profile and top artists, albums and tracks for 7 days, 1 month, 3 months, 12 months or all time, plus recently played. Responses are cached for ten minutes. Rows that exist in the Bandcamp collection carry an in collection badge and open the local artist or record; the rest are informational. Stats are read-only and attributed "Powered by Last.fm" with the profile URL. Recommendation rows are not built: Last.fm no longer exposes a recommendations endpoint, and the in-collection artist.getSimilar idea is a follow-up.
Screenshots come from the app running against a demonstration collection. The artists, records and artwork are invented for the page; no real Bandcamp library appears.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Layout is built for a 960×540 dp canvas: the sidebar is 168 dp, records use GridCells.Adaptive, and rows carry focus-ring padding — a fixed four-across row silently squashed its last card on a 1080p TV.
Bandcamp's Subsonic endpoint sends no CORS headers — not on the response, and not on the preflight. Every browser-based client (Feishin in a tab, SonicLair's PWA, and a Capacitor webview on the TV itself) fails at login because Chromium refuses to read the response. A native client sends no Origin header and is not subject to the policy. That is the whole reason this exists as Kotlin rather than a wrapper.
A WebView around bandcamp.com is the other road, and it is a dead end on a TV: no tvOS browser at all, no Roku browser, no focus order for a D-pad, and no media session.
There is no radio in the API: getInternetRadioStations exists but returns an empty station list, so Bandcamp Radio is not exposed the way the collection is. The website is scrapeable though, and its shape is friendly:
/radiocarriesdata-blobJSON withappData.shows[]:itemUrl,title,franchiseName,date,description,imageId. Cover art ishttps://f4.bcbits.com/img/<imageId>_170.jpg.- Each
/radio/<franchise>/<episode>page carriesappData.showwithcompiledTrack { streamUrl, duration }— the whole episode as one signed MP3 (≈90 min) — plustracks[]where each entry hasalbum { title },title,artistName,duration,timecode, andtracksSummary { totalCount, totalDuration }.
What the app does with it:
- Radio in the sidebar lists the current episodes. The index does not carry episode durations; a duration appears once that episode has been opened and cached.
- Opening an episode plays the compiled stream through the same
Playback/MediaSessionpath as everything else: one media item, remote play/pause and 10-second seek work, and audio keeps going when you leave the app. - The episode page shows the timestamped tracklist. The current track is highlighted from
Playback.positionMs, with a "You're hearing …" line. Selecting any track starts the episode at its timecode (or seeks, if it is already playing). - The index and each parsed episode are cached on disk. A changed page shape produces a readable error on the Radio screen and never affects collection playback. Signed URLs are never written to the cache — they carry
ts/t— so they are re-resolved at play time and kept in memory for ten minutes. - Radio audio is
mp3-128; owned collection streams aremp3-v0(~256 kbps).
This is a scraper, not an API: the HTML can change without notice. data/BandcampRadio.kt holds both the fetch and the parse, data/RadioLibrary.kt holds the cache and the URL policy, and BandcampRadioTest pins the field names measured in November 2026.
Four different failure shapes, all of which the client normalises:
| Situation | Response |
|---|---|
| Wrong credentials | HTTP 500, empty body |
| Endpoint that does not exist | HTTP 200, {"error":true,"error_message":"bad version"} |
| Stub endpoint | HTTP 200, Subsonic envelope with an empty payload |
Bad song id on stream |
HTTP 200, XML |
And four rules learned the hard way:
- Calls live under
/rest/. The published server URL ishttps://bandcamp.com/api/subsonic, but every request is…/api/subsonic/rest/<endpoint>.view. Without/restthe router answers"bad version", which reads like a version problem and is not one. pingnever validates credentials — it saysokfor anything. A "test connection" must callgetArtistsorgetAlbumList2.- Dedupe by album id, and take names from
getArtists. One album appears under both the label ("Blang Records") and the actual artist, and a label's ownartist.namefromgetArtistis whatever its first album's artist is called — reading it renamed Blang Records to "David Cronenberg's Wife". - Artist artwork is the artist's own when Bandcamp has one.
getArtistscarries aci:picture for every artist, but the same id turns up for a whole label's roster: Blang's logo arrived for the label and four of its artists, and two Stanley Brinks projects shared a picture belonging to neither. The app keeps the picture when no other artist in the collection claims it, and otherwise falls back to a record credited to that artist — an album cover is never a stranger's face. The label page falls back too, because the API gives no reliable way to say which sharer owns the picture. Four of the eleven artists use their own image; the rest get their music.
Other quirks: no discNumber (long albums flatten to track 1..N), no year on albums (created only), type=starred returns the entire collection, and getCoverArt serves one fixed ~16 KB JPEG and ignores size.
Zero-track records are vinyl-only. Three of the seventeen have songCount: 0: Bandcamp sells them but has no audio to stream, exactly as the website shows them. The app lists them on the records and artist pages (counting only playable records would make it disagree with the site), marks the card no audio, hides Play on the record page, and leaves them out of the home feed — the home rows are for things you can actually play.
Single tracks collected on their own are listed but unreachable. Fourth case: a:2067464040 "Under A Black Moon" appears in getAlbumList2 with songCount: 1, yet getAlbum answers {"status":"failed","error":{"code":70,"message":"not found"}}, getSong knows neither t: nor a: with that number, search3 finds nothing and stream returns XML. The website plays it from the release page; the API cannot. Sync therefore probes every one-track entry once and flags it the same way, so the card carries no audio before you open it and the record page explains which case it is.
Labels sit in the same list as artists. getArtists returns Blang Records alongside its own artists and offers no flag to tell them apart. The app infers it: a page whose records are credited to two or more different artists is a label, so it gets a Label crumb and says "8 records from the artists on this label" — which is more legible than Bandcamp's own page, where the releases are simply mixed together.
data/SubsonicClient.kt auth, the four error shapes, endpoint parsing
data/MusicLibrary.kt sync (artists + album cards), track lists on demand, JSON cache
data/BandcampRadio.kt the radio scraper: index + data-blob episode parsing
data/RadioLibrary.kt radio index/episode cache; signed stream URLs stay in memory
data/LastFmClient.kt Last.fm signing, auth, scrobbling, stats calls and error codes
data/LastFmCredentials.kt Last.fm API key, sealed secret and session key
data/LastFmStats.kt profile, top lists, recent tracks and the ten-minute cache
data/Scrobble.kt scrobble rules and the durable queue
data/Scrobbler.kt playback → now-playing/scrobble orchestration
data/KeystoreCrypto.kt AES-GCM helper for values held by the Android keystore
data/Credentials.kt server/user in prefs, password sealed with the Android keystore
playback/Playback.kt one ExoPlayer + MediaSession + the queue; stream URLs resolved at play time
pairing/PairingServer.kt a ~200-line local HTTP server serving the setup form
pairing/LastFmServer.kt the LAN page that collects the Last.fm key and links the account
ui/ Compose for TV: sidebar, rows, record page, queue, radio, player, settings
ui/QueueScreen.kt the player timeline: jump to a row, move it, remove it
ui/ActionSheet.kt the long-press sheet for tracks and queue rows
MusicSource seam: everything the UI needs for the collection is MusicLibrary. Radio deliberately sits beside it as its own RadioLibrary, because it is scraped and must be able to fail without touching collection playback. A future web build would need a proxy, not a rewrite.
Bandcamp is a trademark of Bandcamp, Inc. The name is used here only to say what the app connects to.
MIT — see LICENSE.









