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 +21 -0
- bctui-0.1.0/PKG-INFO +55 -0
- bctui-0.1.0/README.md +40 -0
- bctui-0.1.0/bctui/__init__.py +1 -0
- bctui-0.1.0/bctui/__main__.py +5 -0
- bctui-0.1.0/bctui/app.py +405 -0
- bctui-0.1.0/bctui/cache.py +51 -0
- bctui-0.1.0/bctui/config.py +32 -0
- bctui-0.1.0/bctui/format.py +8 -0
- bctui-0.1.0/bctui/renderables/__init__.py +4 -0
- bctui-0.1.0/bctui/renderables/album_row.py +44 -0
- bctui-0.1.0/bctui/renderables/track_row.py +46 -0
- bctui-0.1.0/bctui/subsonic.py +131 -0
- bctui-0.1.0/bctui/types.py +26 -0
- bctui-0.1.0/bctui/widgets/__init__.py +7 -0
- bctui-0.1.0/bctui/widgets/status_bar.py +50 -0
- bctui-0.1.0/bctui/widgets/vim_option_list.py +22 -0
- bctui-0.1.0/bctui.egg-info/PKG-INFO +55 -0
- bctui-0.1.0/bctui.egg-info/SOURCES.txt +23 -0
- bctui-0.1.0/bctui.egg-info/dependency_links.txt +1 -0
- bctui-0.1.0/bctui.egg-info/entry_points.txt +2 -0
- bctui-0.1.0/bctui.egg-info/requires.txt +4 -0
- bctui-0.1.0/bctui.egg-info/top_level.txt +1 -0
- bctui-0.1.0/pyproject.toml +44 -0
- bctui-0.1.0/setup.cfg +4 -0
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
|
+

|
|
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
|
+

|
|
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"
|
bctui-0.1.0/bctui/app.py
ADDED
|
@@ -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,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,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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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