bctui 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bctui-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simon Larsen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
bctui-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: bctui
3
+ Version: 0.1.0
4
+ Author-email: "Simon J. Larsen" <simonhffh@gmail.com>
5
+ Maintainer-email: "Simon J. Larsen" <simonhffh@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: textual>=8.2.7
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: pyxdg>=0.28
13
+ Requires-Dist: mpv>=1.0.8
14
+ Dynamic: license-file
15
+
16
+ bctui
17
+ =====
18
+
19
+ A simple text-based Bandcamp music player that allows you to stream your music collection.
20
+
21
+ bctui is built on the new (in beta) [OpenSubsonic API](https://blog.bandcamp.com/2026/07/16/discover-improvements-and-subsonic-implementation/) and uses libmpv for streaming and playback.
22
+ The TUI is implemented in Python using [Textual](https://textual.textualize.io).
23
+
24
+ ![screenshot](https://github.com/user-attachments/assets/9d4825fa-56ae-4a8a-bbfc-10017303f2d5)
25
+
26
+ ## Installation
27
+
28
+ First make sure `libmpv` is installed. On Debian-based systems you can install it with:
29
+ ```sh
30
+ sudo apt install libmpv2
31
+ ```
32
+
33
+ Then clone the repository and install the package, e.g.:
34
+
35
+ ```sh
36
+ git clone https://github.com/SimonLarsen/bctui.git
37
+ cd bctui
38
+ uv run bctui
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ Create a new configuration file in `$XDG_CONFIG_HOME/bctui/bctui.json` and add your Bandcamp Subsonic username and password:
44
+
45
+ ```sh
46
+ mkdir -p ~/.config/bctui
47
+ cat <<EOF> ~/.config/bctui/bctui.json
48
+ {
49
+ "username": "XXXXXXXX",
50
+ "password": "XXXXXXXX"
51
+ }
52
+ EOF
53
+ ```
54
+
55
+ You can obtain your credentials under [Settings > Fan > Subsonic](https://bandcamp.com/settings?pane=fan#subsonic).
bctui-0.1.0/README.md ADDED
@@ -0,0 +1,40 @@
1
+ bctui
2
+ =====
3
+
4
+ A simple text-based Bandcamp music player that allows you to stream your music collection.
5
+
6
+ bctui is built on the new (in beta) [OpenSubsonic API](https://blog.bandcamp.com/2026/07/16/discover-improvements-and-subsonic-implementation/) and uses libmpv for streaming and playback.
7
+ The TUI is implemented in Python using [Textual](https://textual.textualize.io).
8
+
9
+ ![screenshot](https://github.com/user-attachments/assets/9d4825fa-56ae-4a8a-bbfc-10017303f2d5)
10
+
11
+ ## Installation
12
+
13
+ First make sure `libmpv` is installed. On Debian-based systems you can install it with:
14
+ ```sh
15
+ sudo apt install libmpv2
16
+ ```
17
+
18
+ Then clone the repository and install the package, e.g.:
19
+
20
+ ```sh
21
+ git clone https://github.com/SimonLarsen/bctui.git
22
+ cd bctui
23
+ uv run bctui
24
+ ```
25
+
26
+ ## Configuration
27
+
28
+ Create a new configuration file in `$XDG_CONFIG_HOME/bctui/bctui.json` and add your Bandcamp Subsonic username and password:
29
+
30
+ ```sh
31
+ mkdir -p ~/.config/bctui
32
+ cat <<EOF> ~/.config/bctui/bctui.json
33
+ {
34
+ "username": "XXXXXXXX",
35
+ "password": "XXXXXXXX"
36
+ }
37
+ EOF
38
+ ```
39
+
40
+ You can obtain your credentials under [Settings > Fan > Subsonic](https://bandcamp.com/settings?pane=fan#subsonic).
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ def main() -> None:
2
+ from bctui.app import BCTUIApp
3
+
4
+ app = BCTUIApp()
5
+ app.run()
@@ -0,0 +1,405 @@
1
+ from dataclasses import dataclass
2
+
3
+ import mpv
4
+ from textual import events, work
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Horizontal, Vertical
8
+ from textual.message import Message
9
+ from textual.reactive import reactive
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import Footer, Input, Label, OptionList, ProgressBar
12
+
13
+ from bctui.cache import load_collection, save_collection
14
+ from bctui.config import Config
15
+ from bctui.renderables import AlbumRow, TrackRow
16
+ from bctui.subsonic import SubsonicClient
17
+ from bctui.types import CollectionEntry, TrackData
18
+ from bctui.widgets import StatusBar, VimOptionList
19
+
20
+
21
+ class AlbumList(VimOptionList):
22
+ DEFAULT_CSS = """
23
+ AlbumList {
24
+ width: 0.5fr;
25
+ height: 1fr;
26
+ border: round $foreground;
27
+ &:focus { border: round $primary; }
28
+ }
29
+ """
30
+ collection: reactive[list[CollectionEntry]] = reactive([])
31
+ playing_uid: reactive[str | None] = reactive(None)
32
+
33
+ @dataclass
34
+ class AlbumSelected(Message):
35
+ album: CollectionEntry
36
+
37
+ def __init__(self):
38
+ super().__init__()
39
+ self.border_title = "Collection"
40
+
41
+ def _make_row(self, index: int) -> AlbumRow:
42
+ album = self.collection[index]
43
+ return AlbumRow(album.artist, album.title, album.uid == self.playing_uid)
44
+
45
+ def watch_collection(self, collection: list[CollectionEntry]) -> None:
46
+ self.clear_options()
47
+
48
+ for i in range(len(self.collection)):
49
+ self.add_option(self._make_row(i))
50
+
51
+ self.highlighted = 0
52
+ self.focus()
53
+
54
+ def watch_playing_uid(self, old_uid: str | None, new_uid: str | None) -> None:
55
+ for i, album in enumerate(self.collection):
56
+ if album.uid == old_uid or album.uid == new_uid:
57
+ self.replace_option_prompt_at_index(i, self._make_row(i))
58
+
59
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
60
+ index = event.option_index
61
+ album = self.collection[index]
62
+ self.post_message(self.AlbumSelected(album))
63
+
64
+
65
+ class TrackList(VimOptionList):
66
+ DEFAULT_CSS = """
67
+ TrackList {
68
+ width: 0.5fr;
69
+ height: 1fr;
70
+ border: round $foreground;
71
+ &:focus { border: round $primary; }
72
+ }
73
+ """
74
+ album_uid: str | None = None
75
+ tracks: reactive[list[TrackData]] = reactive([])
76
+ playing_uid: reactive[str | None] = reactive(None)
77
+
78
+ @dataclass
79
+ class TrackSelected(Message):
80
+ album_uid: str
81
+ tracks: list[TrackData]
82
+ index: int
83
+
84
+ def __init__(self):
85
+ super().__init__()
86
+ self.border_title = "N/A - N/A"
87
+
88
+ def _make_row(self, index: int) -> TrackRow:
89
+ track = self.tracks[index]
90
+ return TrackRow(
91
+ index,
92
+ len(self.tracks),
93
+ track.title,
94
+ track.duration,
95
+ track.uid == self.playing_uid,
96
+ )
97
+
98
+ def watch_tracks(self, tracks: list[TrackData]) -> None:
99
+ self.clear_options()
100
+
101
+ for i in range(len(self.tracks)):
102
+ self.add_option(self._make_row(i))
103
+
104
+ self.highlighted = 0
105
+
106
+ def watch_playing_uid(self, old_uid: str | None, new_uid: str | None) -> None:
107
+ for i, track in enumerate(self.tracks):
108
+ if track.uid == old_uid or track.uid == new_uid:
109
+ self.replace_option_prompt_at_index(i, self._make_row(i))
110
+
111
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
112
+ if self.album_uid is None:
113
+ return
114
+ self.post_message(
115
+ self.TrackSelected(self.album_uid, self.tracks, event.option_index)
116
+ )
117
+
118
+
119
+ class UpdateCollectionModal(ModalScreen):
120
+ DEFAULT_CSS = """
121
+ UpdateCollectionModal {
122
+ align: center middle;
123
+
124
+ Vertical {
125
+ border: round $foreground;
126
+ width: 30;
127
+ height: 4;
128
+ padding: 0 1 0 1;
129
+
130
+ Label {
131
+ text-align: center;
132
+ }
133
+ }
134
+ }
135
+ """
136
+
137
+ def __init__(self, api: SubsonicClient):
138
+ super().__init__()
139
+ self._api = api
140
+
141
+ def compose(self) -> ComposeResult:
142
+ with Vertical():
143
+ yield Label("Updating collection...", expand=True)
144
+ yield ProgressBar(show_bar=True, show_percentage=False, show_eta=False)
145
+
146
+ @work
147
+ async def _fetch_collection(self) -> None:
148
+ collection = await self._api.get_collection()
149
+ self.dismiss(collection)
150
+
151
+ async def on_show(self) -> None:
152
+ self._fetch_collection()
153
+
154
+
155
+ class SearchModal(ModalScreen):
156
+ DEFAULT_CSS = """
157
+ SearchModal {
158
+ align: center middle;
159
+
160
+ Vertical {
161
+ border: round $foreground;
162
+ width: 80%;
163
+ height: 80%;
164
+ }
165
+ }
166
+ """
167
+
168
+ def __init__(self, collection: list[CollectionEntry]):
169
+ super().__init__()
170
+ self._search_query: str = ""
171
+ self._matched_indices: list[int] = []
172
+ self._collection = collection
173
+
174
+ def compose(self) -> ComposeResult:
175
+ container = Vertical(
176
+ Input(compact=True),
177
+ VimOptionList(compact=True),
178
+ )
179
+ container.border_title = "Search"
180
+ yield container
181
+
182
+ def on_mount(self) -> None:
183
+ self._update_list()
184
+ self.query_exactly_one(Input).focus()
185
+
186
+ def _update_list(self) -> None:
187
+ options = []
188
+ matched_indices = []
189
+ for i, album in enumerate(self._collection):
190
+ key = f"{album.artist.lower()} {album.title.lower()}"
191
+ if self._search_query in key:
192
+ options.append(AlbumRow(album.artist, album.title))
193
+ matched_indices.append(i)
194
+
195
+ search_list = self.query_exactly_one(VimOptionList)
196
+ search_list.clear_options()
197
+ search_list.add_options(options)
198
+ self._matched_indices = matched_indices
199
+
200
+ if len(options) > 0 and search_list.highlighted is None:
201
+ search_list.highlighted = 0
202
+
203
+ def _confirm(self) -> None:
204
+ search_list = self.query_exactly_one(VimOptionList)
205
+ index = search_list.highlighted
206
+ if index is None:
207
+ self.dismiss(None)
208
+ return
209
+ album = self._collection[self._matched_indices[index]]
210
+ self.dismiss(album.uid)
211
+
212
+ def _move_cursor(self, delta: int) -> None:
213
+ search_list = self.query_exactly_one(VimOptionList)
214
+ index = search_list.highlighted
215
+ if index is None:
216
+ return
217
+ index += delta
218
+ search_list.highlighted = index
219
+
220
+ def on_input_changed(self, event: Input.Changed) -> None:
221
+ self._search_query = event.value
222
+ self._update_list()
223
+
224
+ def on_key(self, event: events.Key) -> None:
225
+ if event.key == "escape":
226
+ self.dismiss(None)
227
+ elif event.key == "enter":
228
+ self._confirm()
229
+ event.stop()
230
+ elif event.key == "ctrl+p":
231
+ self._move_cursor(-1)
232
+ elif event.key == "ctrl+n":
233
+ self._move_cursor(1)
234
+
235
+
236
+ class BCTUIApp(App):
237
+ CSS_PATH = "style.tcss"
238
+
239
+ ENABLE_COMMAND_PALETTE = False
240
+ AUTO_FOCUS = None
241
+
242
+ BINDINGS = [
243
+ Binding("f2", "search", "Search"),
244
+ Binding("<", "prev", "Prev"),
245
+ Binding(">", "next", "Next"),
246
+ Binding("p", "pause", "Pause"),
247
+ Binding("h,left", "focus_collection", "Focus collection", show=False),
248
+ Binding("l,right", "focus_track_list", "Focus tracks", show=False),
249
+ Binding("U", "update_collection", "Update collection"),
250
+ ]
251
+
252
+ def __init__(self) -> None:
253
+ super().__init__()
254
+
255
+ self._config = Config.load()
256
+ self._api = SubsonicClient(
257
+ username=self._config.username, password=self._config.password
258
+ )
259
+ self._collection = load_collection()
260
+ self._mpv = mpv.MPV(
261
+ force_seekable=True,
262
+ prefetch_playlist=True,
263
+ )
264
+
265
+ self._mpv.register_event_callback(self._handle_event)
266
+
267
+ self._playlist: list[TrackData] = []
268
+
269
+ def _handle_event(self, event: mpv.MpvEvent) -> None:
270
+ if event.event_id.value == mpv.MpvEventID.START_FILE:
271
+ self._update_track_list_playing()
272
+
273
+ def compose(self) -> ComposeResult:
274
+ with Horizontal():
275
+ yield AlbumList()
276
+ yield TrackList()
277
+ yield StatusBar()
278
+ yield ProgressBar(
279
+ total=1.0,
280
+ show_percentage=False,
281
+ show_eta=False,
282
+ )
283
+ yield Footer(compact=True)
284
+
285
+ def on_mount(self) -> None:
286
+ self.theme = self._config.theme
287
+
288
+ self.progress_timer = self.set_interval(1.0, self.update_progress)
289
+
290
+ album_list = self.query_exactly_one(AlbumList)
291
+ album_list.collection = self._collection
292
+
293
+ def on_unmount(self) -> None:
294
+ self._mpv.terminate()
295
+
296
+ def _update_track_list_playing(self) -> None:
297
+ pos = self._mpv.playlist_pos
298
+ if not isinstance(pos, int) or pos < 0:
299
+ return
300
+
301
+ track = self._playlist[pos]
302
+
303
+ track_list = self.query_exactly_one(TrackList)
304
+ track_list.playing_uid = track.uid
305
+
306
+ status_bar = self.query_exactly_one(StatusBar)
307
+ status_bar.artist = track.artist
308
+ status_bar.title = track.title
309
+ status_bar.album = track.album
310
+
311
+ async def _update_track_list(self, message: AlbumList.AlbumSelected) -> None:
312
+ album_data = await self._api.get_album(message.album.uid)
313
+ track_list = self.query_exactly_one(TrackList)
314
+ track_list.border_title = f"{message.album.artist} - {message.album.title}"
315
+ track_list.album_uid = message.album.uid
316
+ track_list.tracks = list(album_data.songs)
317
+ self._update_track_list_playing()
318
+
319
+ async def on_album_list_album_selected(
320
+ self, message: AlbumList.AlbumSelected
321
+ ) -> None:
322
+ await self._update_track_list(message)
323
+ self.query_exactly_one(TrackList).focus()
324
+
325
+ def on_track_list_track_selected(self, message: TrackList.TrackSelected) -> None:
326
+ self._mpv.stop(keep_playlist=False)
327
+ self._mpv.playlist_clear()
328
+ for track in message.tracks:
329
+ url = self._api.get_stream_url(track.uid)
330
+ self._mpv.playlist_append(str(url))
331
+ self._set_playlist_pos(message.index)
332
+ self.playing_album_uid = message.album_uid
333
+
334
+ album_list = self.query_exactly_one(AlbumList)
335
+ album_list.playing_uid = message.album_uid
336
+ self._playlist = message.tracks
337
+
338
+ def _set_playlist_pos(self, index: int) -> None:
339
+ n = self._mpv.playlist_count
340
+ if not isinstance(n, int) or n < 1:
341
+ return
342
+ self._mpv.playlist_pos = min(max(index, 0), n - 1)
343
+ self._mpv.pause = False
344
+
345
+ @work
346
+ async def action_search(self) -> None:
347
+ uid = await self.push_screen_wait(SearchModal(self._collection))
348
+ if uid is None:
349
+ return
350
+
351
+ album_list = self.query_exactly_one(AlbumList)
352
+ for i, album in enumerate(self._collection):
353
+ if album.uid == uid:
354
+ album_list.highlighted = i
355
+ album_list.action_select()
356
+ return
357
+
358
+ def action_prev(self) -> None:
359
+ pos = self._mpv.playlist_pos
360
+ if not isinstance(pos, int) or pos == -1:
361
+ return
362
+ self._set_playlist_pos(pos - 1)
363
+
364
+ def action_next(self) -> None:
365
+ pos = self._mpv.playlist_pos
366
+ if not isinstance(pos, int) or pos == -1:
367
+ return
368
+ self._set_playlist_pos(pos + 1)
369
+
370
+ def action_pause(self) -> None:
371
+ self._mpv.pause = not self._mpv.pause
372
+
373
+ def action_focus_collection(self) -> None:
374
+ self.query_exactly_one(AlbumList).focus()
375
+
376
+ def action_focus_track_list(self) -> None:
377
+ self.query_exactly_one(TrackList).focus()
378
+
379
+ @work
380
+ async def action_update_collection(self) -> None:
381
+ self._collection = await self.push_screen_wait(UpdateCollectionModal(self._api))
382
+ save_collection(self._collection)
383
+
384
+ album_list = self.query_exactly_one(AlbumList)
385
+ album_list.collection = self._collection
386
+
387
+ def update_progress(self) -> None:
388
+ percent_pos = self._mpv.percent_pos
389
+ if isinstance(percent_pos, float):
390
+ progress_bar = self.query_exactly_one(ProgressBar)
391
+ progress_bar.update(progress=percent_pos / 100)
392
+
393
+ status_bar = self.query_exactly_one(StatusBar)
394
+ time_pos = self._mpv.time_pos
395
+ if isinstance(time_pos, float):
396
+ status_bar.position = time_pos
397
+
398
+ duration = self._mpv.duration
399
+ if isinstance(duration, float):
400
+ status_bar.duration = duration
401
+
402
+
403
+ if __name__ == "__main__":
404
+ app = BCTUIApp()
405
+ app.run()
@@ -0,0 +1,51 @@
1
+ import sqlite3
2
+ from collections.abc import Sequence
3
+ from pathlib import Path
4
+
5
+ import xdg.BaseDirectory
6
+
7
+ from bctui.types import CollectionEntry
8
+
9
+
10
+ def save_collection(collection: Sequence[CollectionEntry]) -> None:
11
+ cache_dir = Path(xdg.BaseDirectory.save_cache_path("bctui"))
12
+ con = sqlite3.connect(cache_dir / "collection.db")
13
+
14
+ try:
15
+ cur = con.cursor()
16
+ cur.execute("DROP TABLE IF EXISTS collection")
17
+ cur.execute("CREATE TABLE collection(uid, artist, title, year, genre)")
18
+
19
+ cur.executemany(
20
+ "INSERT INTO collection VALUES (:uid, :artist, :title, :year, :genre)",
21
+ [e.__dict__ for e in collection],
22
+ )
23
+ finally:
24
+ con.commit()
25
+ con.close()
26
+
27
+
28
+ def load_collection() -> list[CollectionEntry]:
29
+ cache_dir = Path(xdg.BaseDirectory.save_cache_path("bctui"))
30
+ con = sqlite3.connect(cache_dir / "collection.db")
31
+
32
+ try:
33
+ cur = con.cursor()
34
+ table_exists = (
35
+ cur.execute(
36
+ """
37
+ SELECT count(*) FROM sqlite_master
38
+ WHERE type='table' AND name='collection'
39
+ """
40
+ ).fetchall()[0][0]
41
+ == 1
42
+ )
43
+
44
+ if not table_exists:
45
+ return []
46
+
47
+ rows = cur.execute("SELECT * FROM collection").fetchall()
48
+ return [CollectionEntry(*row) for row in rows]
49
+ finally:
50
+ con.commit()
51
+ con.close()
@@ -0,0 +1,32 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+
4
+ import xdg.BaseDirectory
5
+
6
+ DEFAULT_THEME = "catppuccin-mocha"
7
+
8
+
9
+ class ConfigNotFoundError(Exception):
10
+ pass
11
+
12
+
13
+ @dataclass
14
+ class Config:
15
+ username: str
16
+ password: str
17
+ theme: str
18
+
19
+ @classmethod
20
+ def load(cls) -> "Config":
21
+ path = xdg.BaseDirectory.load_first_config("bctui/bctui.json")
22
+ if path is None:
23
+ raise ConfigNotFoundError()
24
+
25
+ with open(path, "r") as fp:
26
+ js = json.load(fp)
27
+
28
+ return cls(
29
+ username=js["username"],
30
+ password=js["password"],
31
+ theme=js.get("theme", DEFAULT_THEME),
32
+ )
@@ -0,0 +1,8 @@
1
+ def duration_to_hhmmss(duration: float) -> str:
2
+ hours = int(duration // 3600)
3
+ minutes = int((duration % 3600) // 60)
4
+ seconds = int(duration % 60)
5
+ if hours > 0:
6
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
7
+ else:
8
+ return f"{minutes:02d}:{seconds:02d}"
@@ -0,0 +1,4 @@
1
+ from .album_row import AlbumRow
2
+ from .track_row import TrackRow
3
+
4
+ __all__ = ["AlbumRow", "TrackRow"]
@@ -0,0 +1,44 @@
1
+ from rich.console import Console, ConsoleOptions, RenderResult
2
+ from rich.text import Text
3
+
4
+
5
+ class AlbumRow:
6
+ def __init__(
7
+ self,
8
+ artist: str,
9
+ title: str,
10
+ playing: bool = False,
11
+ ratio: float = 0.4,
12
+ ):
13
+ self._artist = artist
14
+ self._title = title
15
+ self._playing = playing
16
+ self._ratio = ratio
17
+
18
+ def __rich_console__(
19
+ self,
20
+ console: Console,
21
+ options: ConsoleOptions,
22
+ ) -> RenderResult:
23
+ width = options.max_width
24
+ w1 = round(self._ratio * width)
25
+ w2 = width - w1 - 1
26
+
27
+ t1 = Text(self._artist)
28
+ t1.truncate(w1, overflow="ellipsis", pad=True)
29
+
30
+ t2 = Text(self._title)
31
+ t2.truncate(w2, overflow="ellipsis", pad=True)
32
+
33
+ out = Text(" ").join((t1, t2))
34
+ if self._playing:
35
+ out.stylize("reverse")
36
+ yield out
37
+
38
+ @property
39
+ def playing(self) -> bool:
40
+ return self._playing
41
+
42
+ @playing.setter
43
+ def playing(self, value: bool) -> None:
44
+ self._playing = value
@@ -0,0 +1,46 @@
1
+ import math
2
+
3
+ from rich.console import Console, ConsoleOptions, RenderResult
4
+ from rich.text import Text
5
+
6
+ from bctui.format import duration_to_hhmmss
7
+
8
+
9
+ class TrackRow:
10
+ def __init__(
11
+ self,
12
+ track_no: int,
13
+ num_tracks: int,
14
+ title: str,
15
+ duration: float,
16
+ playing: bool = False,
17
+ ):
18
+ self._track_no = track_no
19
+ self._num_tracks = num_tracks
20
+ self._title = title
21
+ self._duration = duration
22
+ self._playing = playing
23
+
24
+ def __rich_console__(
25
+ self,
26
+ console: Console,
27
+ options: ConsoleOptions,
28
+ ) -> RenderResult:
29
+ width = options.max_width
30
+ no_width = max(math.floor(math.log10(self._num_tracks)) + 2, 2)
31
+ duration_width = 8 if self._duration >= 3600 else 5
32
+ track_width = width - no_width - duration_width - 2
33
+
34
+ t1 = Text(f"{self._track_no + 1}.")
35
+ t1.pad_left(no_width - t1.cell_len)
36
+
37
+ t2 = Text(self._title)
38
+ t2.truncate(track_width, overflow="ellipsis", pad=True)
39
+
40
+ t3 = Text(duration_to_hhmmss(self._duration))
41
+ t3.pad_left(duration_width - t3.cell_len)
42
+
43
+ out = Text(" ").join((t1, t2, t3))
44
+ if self._playing:
45
+ out.stylize("reverse")
46
+ yield out
@@ -0,0 +1,131 @@
1
+ import hashlib
2
+ import random
3
+ import string
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from bctui.types import AlbumData, CollectionEntry, TrackData
9
+
10
+
11
+ class SubsonicConnectionError(Exception):
12
+ pass
13
+
14
+
15
+ class SubsonicCommandFailedError(Exception):
16
+ pass
17
+
18
+
19
+ class SubsonicClient:
20
+ def __init__(
21
+ self,
22
+ username: str,
23
+ password: str,
24
+ client_name: str = "bctui",
25
+ url: str | httpx.URL = "https://bandcamp.com/api/subsonic",
26
+ version: str = "1.16.1",
27
+ ):
28
+ self._username = username
29
+ self._password = password
30
+ self._client_name = client_name
31
+ self._url = httpx.URL(url)
32
+ self._version = version
33
+
34
+ def _get_base_params(self) -> dict[str, str]:
35
+ salt = "".join(random.choices(string.ascii_letters + string.digits, k=12))
36
+ token = hashlib.md5((self._password + salt).encode("utf-8")).hexdigest()
37
+ params = {
38
+ "u": self._username,
39
+ "s": salt,
40
+ "t": token,
41
+ "c": self._client_name,
42
+ "v": self._version,
43
+ "f": "json",
44
+ }
45
+ return params
46
+
47
+ async def _get(
48
+ self,
49
+ endpoint: str,
50
+ **kwargs,
51
+ ) -> dict[str, Any]:
52
+ params = self._get_base_params()
53
+ params.update(kwargs)
54
+
55
+ async with httpx.AsyncClient() as client:
56
+ res = await client.get(
57
+ url=self._url.copy_with(path=self._url.path + endpoint),
58
+ params=params,
59
+ )
60
+
61
+ if res.status_code != 200:
62
+ raise SubsonicConnectionError(
63
+ f"Received status code {res.status_code}."
64
+ )
65
+
66
+ data = res.json()
67
+ if (
68
+ "subsonic-response" not in data
69
+ or data["subsonic-response"].get("status") != "ok"
70
+ ):
71
+ raise SubsonicCommandFailedError()
72
+
73
+ return data
74
+
75
+ async def get_collection(
76
+ self,
77
+ albums_per_query: int = 50,
78
+ ) -> list[CollectionEntry]:
79
+ albums: list[CollectionEntry] = []
80
+ offset = 0
81
+ while True:
82
+ data = await self._get(
83
+ "/rest/getAlbumList2",
84
+ type="newest",
85
+ size=albums_per_query,
86
+ offset=offset,
87
+ )
88
+ new_albums = data["subsonic-response"]["albumList2"]["album"]
89
+ if len(new_albums) == 0:
90
+ break
91
+
92
+ for e in new_albums:
93
+ album = CollectionEntry(
94
+ uid=e["id"],
95
+ artist=e["artist"],
96
+ title=e["name"],
97
+ year=e.get("year"),
98
+ genre=e.get("genre"),
99
+ )
100
+ albums.append(album)
101
+
102
+ offset += len(new_albums)
103
+
104
+ return albums
105
+
106
+ async def get_album(self, uid: str) -> AlbumData:
107
+ data = await self._get("/rest/getAlbum", id=uid)
108
+ info = data["subsonic-response"]["album"]
109
+
110
+ songs: list[TrackData] = []
111
+ for e in info["song"]:
112
+ song = TrackData(
113
+ uid=e["id"],
114
+ artist=e["artist"],
115
+ title=e["title"],
116
+ album=e["album"],
117
+ duration=e["duration"],
118
+ genre=e.get("genre"),
119
+ )
120
+ songs.append(song)
121
+
122
+ return AlbumData(songs=songs)
123
+
124
+ def get_stream_url(self, uid: str) -> httpx.URL:
125
+ params = self._get_base_params()
126
+ params.update({"id": uid, "format": "mp3"})
127
+ url = self._url.copy_with(
128
+ path=self._url.path + "/rest/stream",
129
+ params=params,
130
+ )
131
+ return url
@@ -0,0 +1,26 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class CollectionEntry:
7
+ uid: str
8
+ artist: str
9
+ title: str
10
+ year: int | None
11
+ genre: str | None
12
+
13
+
14
+ @dataclass
15
+ class TrackData:
16
+ uid: str
17
+ artist: str
18
+ title: str
19
+ album: str
20
+ duration: int
21
+ genre: str | None
22
+
23
+
24
+ @dataclass
25
+ class AlbumData:
26
+ songs: Sequence[TrackData]
@@ -0,0 +1,7 @@
1
+ from .status_bar import StatusBar
2
+ from .vim_option_list import VimOptionList
3
+
4
+ __all__ = [
5
+ "StatusBar",
6
+ "VimOptionList",
7
+ ]
@@ -0,0 +1,50 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import Horizontal
3
+ from textual.reactive import reactive
4
+ from textual.widgets import Static
5
+
6
+ from bctui.format import duration_to_hhmmss
7
+
8
+
9
+ class StatusBar(Horizontal):
10
+ DEFAULT_CSS = """
11
+ StatusBar {
12
+ height: 1;
13
+ }
14
+
15
+ .statusbar--info {
16
+ width: auto;
17
+ color: $primary;
18
+ }
19
+
20
+ .statusbar--separator {
21
+ width: auto;
22
+ }
23
+
24
+ .statusbar--time {
25
+ width: 1fr;
26
+ text-align: right;
27
+ }
28
+ """
29
+
30
+ title: reactive[str | None] = reactive(None, recompose=True)
31
+ artist: reactive[str | None] = reactive(None, recompose=True)
32
+ album: reactive[str | None] = reactive(None, recompose=True)
33
+ position: reactive[float] = reactive(0.0, recompose=True)
34
+ duration: reactive[float] = reactive(0.0, recompose=True)
35
+
36
+ def compose(self) -> ComposeResult:
37
+ if self.title is not None:
38
+ yield Static(self.title, classes="statusbar--info")
39
+
40
+ if self.artist is not None:
41
+ yield Static(" by ", classes="statusbar--separator")
42
+ yield Static(self.artist, classes="statusbar--info")
43
+
44
+ if self.album is not None:
45
+ yield Static(" from ", classes="statusbar--separator")
46
+ yield Static(self.album, classes="statusbar--info")
47
+
48
+ t1 = duration_to_hhmmss(self.position)
49
+ t2 = duration_to_hhmmss(self.duration)
50
+ yield Static(f"[{t1}/{t2}]", classes="statusbar--time")
@@ -0,0 +1,22 @@
1
+ from textual.binding import Binding
2
+ from textual.widgets import OptionList
3
+
4
+
5
+ class VimOptionList(OptionList):
6
+ DEFAULT_CSS = """
7
+ VimOptionList {
8
+ background: $background;
9
+ background-tint: $background;
10
+ scrollbar-size: 1 1;
11
+ }
12
+ """
13
+
14
+ BINDINGS = [
15
+ Binding(key="j", action="cursor_down"),
16
+ Binding(key="k", action="cursor_up"),
17
+ Binding(key="space", action="select"),
18
+ Binding(key="g", action="first"),
19
+ Binding(key="G", action="last"),
20
+ Binding(key="ctrl+f", action="page_down"),
21
+ Binding(key="ctrl+b", action="page_up"),
22
+ ]
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: bctui
3
+ Version: 0.1.0
4
+ Author-email: "Simon J. Larsen" <simonhffh@gmail.com>
5
+ Maintainer-email: "Simon J. Larsen" <simonhffh@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: textual>=8.2.7
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: pyxdg>=0.28
13
+ Requires-Dist: mpv>=1.0.8
14
+ Dynamic: license-file
15
+
16
+ bctui
17
+ =====
18
+
19
+ A simple text-based Bandcamp music player that allows you to stream your music collection.
20
+
21
+ bctui is built on the new (in beta) [OpenSubsonic API](https://blog.bandcamp.com/2026/07/16/discover-improvements-and-subsonic-implementation/) and uses libmpv for streaming and playback.
22
+ The TUI is implemented in Python using [Textual](https://textual.textualize.io).
23
+
24
+ ![screenshot](https://github.com/user-attachments/assets/9d4825fa-56ae-4a8a-bbfc-10017303f2d5)
25
+
26
+ ## Installation
27
+
28
+ First make sure `libmpv` is installed. On Debian-based systems you can install it with:
29
+ ```sh
30
+ sudo apt install libmpv2
31
+ ```
32
+
33
+ Then clone the repository and install the package, e.g.:
34
+
35
+ ```sh
36
+ git clone https://github.com/SimonLarsen/bctui.git
37
+ cd bctui
38
+ uv run bctui
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ Create a new configuration file in `$XDG_CONFIG_HOME/bctui/bctui.json` and add your Bandcamp Subsonic username and password:
44
+
45
+ ```sh
46
+ mkdir -p ~/.config/bctui
47
+ cat <<EOF> ~/.config/bctui/bctui.json
48
+ {
49
+ "username": "XXXXXXXX",
50
+ "password": "XXXXXXXX"
51
+ }
52
+ EOF
53
+ ```
54
+
55
+ You can obtain your credentials under [Settings > Fan > Subsonic](https://bandcamp.com/settings?pane=fan#subsonic).
@@ -0,0 +1,23 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ bctui/__init__.py
5
+ bctui/__main__.py
6
+ bctui/app.py
7
+ bctui/cache.py
8
+ bctui/config.py
9
+ bctui/format.py
10
+ bctui/subsonic.py
11
+ bctui/types.py
12
+ bctui.egg-info/PKG-INFO
13
+ bctui.egg-info/SOURCES.txt
14
+ bctui.egg-info/dependency_links.txt
15
+ bctui.egg-info/entry_points.txt
16
+ bctui.egg-info/requires.txt
17
+ bctui.egg-info/top_level.txt
18
+ bctui/renderables/__init__.py
19
+ bctui/renderables/album_row.py
20
+ bctui/renderables/track_row.py
21
+ bctui/widgets/__init__.py
22
+ bctui/widgets/status_bar.py
23
+ bctui/widgets/vim_option_list.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bctui = bctui.__main__:main
@@ -0,0 +1,4 @@
1
+ textual>=8.2.7
2
+ httpx>=0.28.1
3
+ pyxdg>=0.28
4
+ mpv>=1.0.8
@@ -0,0 +1 @@
1
+ bctui
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "bctui"
3
+ dynamic = ["version"]
4
+ dependencies = [
5
+ "textual>=8.2.7",
6
+ "httpx>=0.28.1",
7
+ "pyxdg>=0.28",
8
+ "mpv>=1.0.8",
9
+ ]
10
+ requires-python = ">=3.10"
11
+ authors = [
12
+ {name = "Simon J. Larsen", email = "simonhffh@gmail.com"},
13
+ ]
14
+ maintainers = [
15
+ {name = "Simon J. Larsen", email = "simonhffh@gmail.com"},
16
+ ]
17
+ readme = {file = "README.md", content-type = "text/markdown"}
18
+ license = "MIT"
19
+
20
+ [project.scripts]
21
+ bctui = "bctui.__main__:main"
22
+
23
+ [build-system]
24
+ requires = ["setuptools>=82.0.1"]
25
+
26
+ [tool.setuptools.dynamic]
27
+ version = {attr = "bctui.__version__"}
28
+
29
+ [tool.basedpyright]
30
+ typeCheckingMode = "standard"
31
+
32
+ [tool.ruff]
33
+ line-length = 88
34
+ indent-width = 4
35
+
36
+ [too.ruff.format]
37
+ quote-style = "double"
38
+ indent-style = "space"
39
+ skip-magic-trailing-comma = false
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "textual-dev>=1.8.0",
44
+ ]
bctui-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+