plexbar 0.1.2__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.
plexbar-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.3
2
+ Name: plexbar
3
+ Version: 0.1.2
4
+ Summary: A command-line TUI music player for Plex
5
+ Author: Christopher Patti
6
+ Author-email: Christopher Patti <feoh@feoh.org>
7
+ Requires-Dist: platformdirs>=4.10.0
8
+ Requires-Dist: plexapi>=4.18.1
9
+ Requires-Dist: textual>=8.2.7
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Plexbar
14
+
15
+ Plexbar is a keyboard-driven terminal music player for Plex. It gives you a
16
+ simple Textual TUI for browsing and playing your Plex music library without
17
+ opening a web browser.
18
+
19
+ ## Features
20
+
21
+ - First-run Plex connection setup
22
+ - Browse by Artists, Albums, Tracks, and Playlists
23
+ - Search your Plex music library
24
+ - Queue tracks, albums, artists, and playlists
25
+ - Play, pause/resume, stop, and skip with keyboard shortcuts
26
+ - Local playback through `mpv`
27
+
28
+ ## Requirements
29
+
30
+ - Python 3.12+
31
+ - [`uv`](https://docs.astral.sh/uv/)
32
+ - [`mpv`](https://mpv.io/) installed and available on `PATH`
33
+ - A Plex server with at least one music library
34
+ - A Plex token
35
+
36
+ ## Installation for development
37
+
38
+ Clone the repository and sync dependencies with `uv`:
39
+
40
+ ```bash
41
+ git clone git@github.com:feoh/plexbar.git
42
+ cd plexbar
43
+ uv sync
44
+ ```
45
+
46
+ Run Plexbar from the project checkout:
47
+
48
+ ```bash
49
+ uv run plexbar
50
+ ```
51
+
52
+ ## First-run setup
53
+
54
+ If `~/.config/plexbar/config.toml` does not exist, Plexbar opens a setup screen
55
+ and prompts for:
56
+
57
+ 1. Plex base URL, for example `http://puppy:32400` or `http://127.0.0.1:32400`
58
+ 2. Plex token
59
+ 3. Default music library
60
+
61
+ The config file is written to:
62
+
63
+ ```text
64
+ ~/.config/plexbar/config.toml
65
+ ```
66
+
67
+ Plexbar writes this file with user-only permissions where supported. Do not
68
+ commit or share your Plex token.
69
+
70
+ ### Finding your Plex token
71
+
72
+ One common method:
73
+
74
+ 1. Open Plex Web and sign in.
75
+ 2. Open browser developer tools.
76
+ 3. Go to the Network tab.
77
+ 4. Refresh Plex Web or browse to a media item.
78
+ 5. Search requests for `X-Plex-Token`.
79
+ 6. Copy the token value only.
80
+
81
+ ## Usage
82
+
83
+ The top-level browser contains:
84
+
85
+ - Artists
86
+ - Albums
87
+ - Tracks
88
+ - Playlists
89
+
90
+ Press `Enter` to drill down into artists, albums, and playlists. Press `Enter`
91
+ on a track to enqueue it.
92
+
93
+ ## Keybindings
94
+
95
+ | Key | Action |
96
+ | --- | --- |
97
+ | `/` | Search Plex music |
98
+ | `Enter` | Select/drill down, or enqueue focused track |
99
+ | `p` | Play focused track/album/artist/playlist immediately and replace queue |
100
+ | `a` | Append focused track/album/artist/playlist to queue |
101
+ | `Space` | Pause/resume |
102
+ | `n` | Next track |
103
+ | `s` | Stop playback |
104
+ | `q` | Quit |
105
+
106
+ ## Local validation
107
+
108
+ ```bash
109
+ uv run ruff check .
110
+ uv run ruff format --check .
111
+ uv run pytest
112
+ uv run mypy src/plexbar tests
113
+ ```
114
+
115
+ ## Current limitations
116
+
117
+ Plexbar currently launches `mpv` as a subprocess for each track. Future versions
118
+ may switch to mpv IPC for richer playback state and automatic end-of-track
119
+ advancement.
@@ -0,0 +1,107 @@
1
+ # Plexbar
2
+
3
+ Plexbar is a keyboard-driven terminal music player for Plex. It gives you a
4
+ simple Textual TUI for browsing and playing your Plex music library without
5
+ opening a web browser.
6
+
7
+ ## Features
8
+
9
+ - First-run Plex connection setup
10
+ - Browse by Artists, Albums, Tracks, and Playlists
11
+ - Search your Plex music library
12
+ - Queue tracks, albums, artists, and playlists
13
+ - Play, pause/resume, stop, and skip with keyboard shortcuts
14
+ - Local playback through `mpv`
15
+
16
+ ## Requirements
17
+
18
+ - Python 3.12+
19
+ - [`uv`](https://docs.astral.sh/uv/)
20
+ - [`mpv`](https://mpv.io/) installed and available on `PATH`
21
+ - A Plex server with at least one music library
22
+ - A Plex token
23
+
24
+ ## Installation for development
25
+
26
+ Clone the repository and sync dependencies with `uv`:
27
+
28
+ ```bash
29
+ git clone git@github.com:feoh/plexbar.git
30
+ cd plexbar
31
+ uv sync
32
+ ```
33
+
34
+ Run Plexbar from the project checkout:
35
+
36
+ ```bash
37
+ uv run plexbar
38
+ ```
39
+
40
+ ## First-run setup
41
+
42
+ If `~/.config/plexbar/config.toml` does not exist, Plexbar opens a setup screen
43
+ and prompts for:
44
+
45
+ 1. Plex base URL, for example `http://puppy:32400` or `http://127.0.0.1:32400`
46
+ 2. Plex token
47
+ 3. Default music library
48
+
49
+ The config file is written to:
50
+
51
+ ```text
52
+ ~/.config/plexbar/config.toml
53
+ ```
54
+
55
+ Plexbar writes this file with user-only permissions where supported. Do not
56
+ commit or share your Plex token.
57
+
58
+ ### Finding your Plex token
59
+
60
+ One common method:
61
+
62
+ 1. Open Plex Web and sign in.
63
+ 2. Open browser developer tools.
64
+ 3. Go to the Network tab.
65
+ 4. Refresh Plex Web or browse to a media item.
66
+ 5. Search requests for `X-Plex-Token`.
67
+ 6. Copy the token value only.
68
+
69
+ ## Usage
70
+
71
+ The top-level browser contains:
72
+
73
+ - Artists
74
+ - Albums
75
+ - Tracks
76
+ - Playlists
77
+
78
+ Press `Enter` to drill down into artists, albums, and playlists. Press `Enter`
79
+ on a track to enqueue it.
80
+
81
+ ## Keybindings
82
+
83
+ | Key | Action |
84
+ | --- | --- |
85
+ | `/` | Search Plex music |
86
+ | `Enter` | Select/drill down, or enqueue focused track |
87
+ | `p` | Play focused track/album/artist/playlist immediately and replace queue |
88
+ | `a` | Append focused track/album/artist/playlist to queue |
89
+ | `Space` | Pause/resume |
90
+ | `n` | Next track |
91
+ | `s` | Stop playback |
92
+ | `q` | Quit |
93
+
94
+ ## Local validation
95
+
96
+ ```bash
97
+ uv run ruff check .
98
+ uv run ruff format --check .
99
+ uv run pytest
100
+ uv run mypy src/plexbar tests
101
+ ```
102
+
103
+ ## Current limitations
104
+
105
+ Plexbar currently launches `mpv` as a subprocess for each track. Future versions
106
+ may switch to mpv IPC for richer playback state and automatic end-of-track
107
+ advancement.
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "plexbar"
3
+ version = "0.1.2"
4
+ description = "A command-line TUI music player for Plex"
5
+ readme = "README.md"
6
+ authors = [{ name = "Christopher Patti", email = "feoh@feoh.org" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = ["platformdirs>=4.10.0", "plexapi>=4.18.1", "textual>=8.2.7"]
9
+
10
+ [project.scripts]
11
+ plexbar = "plexbar.app:main"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.9.18,<0.10.0"]
15
+ build-backend = "uv_build"
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "mypy>=2.1.0",
20
+ "pre-commit>=4.6.0",
21
+ "pytest>=9.0.3",
22
+ "ruff>=0.15.16",
23
+ ]
@@ -0,0 +1 @@
1
+ """Plexbar package."""
@@ -0,0 +1,6 @@
1
+ """Run Plexbar as a module."""
2
+
3
+ from .app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,411 @@
1
+ """Textual application for Plexbar."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ from textual import on, work
6
+ from textual.app import App, ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.screen import Screen
10
+ from textual.widgets import (
11
+ Button,
12
+ Footer,
13
+ Header,
14
+ Input,
15
+ Label,
16
+ ListItem,
17
+ ListView,
18
+ Static,
19
+ )
20
+
21
+ from plexbar.models import BrowserItem, ItemKind, QueueTrack
22
+ from plexbar.playback import MpvNotFoundError, MpvPlayer, PlaybackQueue
23
+ from plexbar.plex_client import PlexMusicClient
24
+ from plexbar.settings import (
25
+ CONFIG_PATH,
26
+ PlexbarConfig,
27
+ config_exists,
28
+ load_config,
29
+ save_config,
30
+ )
31
+
32
+
33
+ class BrowserRow(ListItem):
34
+ """List item that carries a BrowserItem payload."""
35
+
36
+ def __init__(self, item: BrowserItem) -> None:
37
+ super().__init__(Label(item.display_title))
38
+ self.item = item
39
+
40
+
41
+ class SetupScreen(Screen[None]):
42
+ """First-run Plex configuration screen."""
43
+
44
+ CSS = """
45
+ SetupScreen {
46
+ align: center middle;
47
+ }
48
+
49
+ #setup-box {
50
+ width: 70;
51
+ height: auto;
52
+ border: round $accent;
53
+ padding: 1 2;
54
+ }
55
+
56
+ #setup-status {
57
+ min-height: 3;
58
+ }
59
+ """
60
+
61
+ def __init__(self, on_configured: Callable[[PlexbarConfig], None]) -> None:
62
+ super().__init__()
63
+ self._on_configured = on_configured
64
+ self._base_url = ""
65
+ self._token = ""
66
+ self._libraries: list[str] = []
67
+
68
+ def compose(self) -> ComposeResult:
69
+ with Vertical(id="setup-box"):
70
+ yield Label("Plexbar first-run setup")
71
+ yield Label("Plex base URL")
72
+ yield Input(placeholder="http://127.0.0.1:32400", id="plex-url")
73
+ yield Label("Plex token")
74
+ yield Input(password=True, placeholder="Your Plex token", id="plex-token")
75
+ yield Button("Validate connection", id="validate", variant="primary")
76
+ yield Static("Enter your Plex connection details.", id="setup-status")
77
+ yield ListView(id="library-list")
78
+ yield Footer()
79
+
80
+ def on_mount(self) -> None:
81
+ self.query_one("#library-list", ListView).display = False
82
+ self.query_one("#plex-url", Input).focus()
83
+
84
+ @on(Button.Pressed, "#validate")
85
+ def validate_connection(self) -> None:
86
+ """Validate credentials and load music libraries."""
87
+
88
+ base_url = self.query_one("#plex-url", Input).value.strip()
89
+ token = self.query_one("#plex-token", Input).value.strip()
90
+ if not base_url or not token:
91
+ self._set_status("Plex URL and token are required.")
92
+ return
93
+ self._base_url = base_url
94
+ self._token = token
95
+ self._set_status("Validating Plex connection…")
96
+ self._validate_connection(base_url, token)
97
+
98
+ @work(thread=True)
99
+ def _validate_connection(self, base_url: str, token: str) -> None:
100
+ try:
101
+ libraries = PlexMusicClient.validate(base_url, token)
102
+ except Exception as exc: # noqa: BLE001 - display connection failures to user
103
+ self.app.call_from_thread(self._show_validation_error, str(exc))
104
+ return
105
+ self.app.call_from_thread(self._show_libraries, libraries)
106
+
107
+ def _show_validation_error(self, message: str) -> None:
108
+ self._set_status(f"Connection failed: {message}")
109
+
110
+ def _show_libraries(self, libraries: list[str]) -> None:
111
+ self._libraries = libraries
112
+ if not libraries:
113
+ self._set_status("Connected, but no music libraries were found.")
114
+ return
115
+ library_list = self.query_one("#library-list", ListView)
116
+ library_list.clear()
117
+ for library in libraries:
118
+ library_list.append(BrowserRow(BrowserItem(library, ItemKind.ROOT)))
119
+ library_list.display = True
120
+ library_list.focus()
121
+ self._set_status("Select a default music library and press Enter.")
122
+
123
+ @on(ListView.Selected, "#library-list")
124
+ def save_selected_library(self, event: ListView.Selected) -> None:
125
+ """Persist selected library and continue into the app."""
126
+
127
+ row = event.item
128
+ if not isinstance(row, BrowserRow):
129
+ return
130
+ config = PlexbarConfig(
131
+ base_url=self._base_url,
132
+ token=self._token,
133
+ default_library=row.item.title,
134
+ )
135
+ save_config(config)
136
+ self._on_configured(config)
137
+ self.dismiss()
138
+
139
+ def _set_status(self, message: str) -> None:
140
+ self.query_one("#setup-status", Static).update(message)
141
+
142
+
143
+ class PlexbarApp(App[None]):
144
+ """Plex music player TUI."""
145
+
146
+ CSS = """
147
+ #browser {
148
+ width: 2fr;
149
+ border: round $primary;
150
+ }
151
+
152
+ #side {
153
+ width: 1fr;
154
+ border: round $secondary;
155
+ }
156
+
157
+ #search {
158
+ dock: top;
159
+ }
160
+
161
+ #status {
162
+ dock: bottom;
163
+ height: 3;
164
+ border-top: solid $primary;
165
+ }
166
+
167
+ .panel-title {
168
+ text-style: bold;
169
+ padding: 0 1;
170
+ }
171
+ """
172
+
173
+ BINDINGS = [
174
+ Binding("q", "quit", "Quit"),
175
+ Binding("/", "search", "Search"),
176
+ Binding("enter", "select", "Select/enqueue"),
177
+ Binding("p", "play_now", "Play now"),
178
+ Binding("a", "append", "Append"),
179
+ Binding("space", "pause_resume", "Pause/resume"),
180
+ Binding("n", "next_track", "Next"),
181
+ Binding("s", "stop", "Stop"),
182
+ ]
183
+
184
+ def __init__(self) -> None:
185
+ super().__init__()
186
+ self.config: PlexbarConfig | None = None
187
+ self.client: PlexMusicClient | None = None
188
+ self.player: MpvPlayer | None = None
189
+ self.queue = PlaybackQueue()
190
+ self.history: list[list[BrowserItem]] = []
191
+ self.items: list[BrowserItem] = []
192
+
193
+ def compose(self) -> ComposeResult:
194
+ yield Header()
195
+ yield Input(placeholder="Search Plex music…", id="search")
196
+ with Horizontal():
197
+ with Vertical(id="browser"):
198
+ yield Label("Browse", classes="panel-title")
199
+ yield ListView(id="browser-list")
200
+ with Vertical(id="side"):
201
+ yield Label("Now Playing", classes="panel-title")
202
+ yield Static("Nothing playing", id="now-playing")
203
+ yield Label("Queue", classes="panel-title")
204
+ yield Static("Queue is empty", id="queue")
205
+ yield Static("Starting Plexbar…", id="status")
206
+ yield Footer()
207
+
208
+ def on_mount(self) -> None:
209
+ self.query_one("#search", Input).display = False
210
+ if config_exists():
211
+ try:
212
+ self.start_with_config(load_config())
213
+ except Exception as exc: # noqa: BLE001 - show config failures in TUI
214
+ self.set_status(f"Failed to load {CONFIG_PATH}: {exc}")
215
+ self.push_screen(SetupScreen(self.start_with_config))
216
+ else:
217
+ self.push_screen(SetupScreen(self.start_with_config))
218
+
219
+ def start_with_config(self, config: PlexbarConfig) -> None:
220
+ """Connect to Plex and initialize playback/browsing."""
221
+
222
+ self.config = config
223
+ self.set_status("Connecting to Plex…")
224
+ self._connect(config)
225
+
226
+ @work(thread=True)
227
+ def _connect(self, config: PlexbarConfig) -> None:
228
+ try:
229
+ client = PlexMusicClient(config)
230
+ player = MpvPlayer()
231
+ except MpvNotFoundError as exc:
232
+ self.call_from_thread(self.set_status, str(exc))
233
+ return
234
+ except Exception as exc: # noqa: BLE001 - show connection failures in TUI
235
+ self.call_from_thread(self.set_status, f"Plex connection failed: {exc}")
236
+ return
237
+ self.call_from_thread(self._connected, client, player)
238
+
239
+ def _connected(self, client: PlexMusicClient, player: MpvPlayer) -> None:
240
+ self.client = client
241
+ self.player = player
242
+ self.show_items(
243
+ client.root_items(), "Connected. Choose a section.", remember=False
244
+ )
245
+
246
+ def show_items(
247
+ self, items: list[BrowserItem], status: str, *, remember: bool = True
248
+ ) -> None:
249
+ """Display browser items."""
250
+
251
+ if remember and self.items:
252
+ self.history.append(self.items)
253
+ self.items = items
254
+ browser = self.query_one("#browser-list", ListView)
255
+ browser.clear()
256
+ if self.history:
257
+ browser.append(BrowserRow(BrowserItem("..", ItemKind.BACK)))
258
+ for item in items:
259
+ browser.append(BrowserRow(item))
260
+ browser.focus()
261
+ self.set_status(status)
262
+
263
+ async def action_quit(self) -> None:
264
+ """Stop playback before exiting Plexbar."""
265
+
266
+ if self.player is not None:
267
+ self.player.stop()
268
+ self.exit()
269
+
270
+ def action_search(self) -> None:
271
+ search = self.query_one("#search", Input)
272
+ search.display = True
273
+ search.focus()
274
+
275
+ @on(Input.Submitted, "#search")
276
+ def perform_search(self, event: Input.Submitted) -> None:
277
+ query = event.value.strip()
278
+ search = self.query_one("#search", Input)
279
+ search.display = False
280
+ search.value = ""
281
+ if not query or self.client is None:
282
+ return
283
+ self.show_items(self.client.search(query), f"Search results for '{query}'.")
284
+
285
+ @on(ListView.Selected, "#browser-list")
286
+ def select_browser_row(self, event: ListView.Selected) -> None:
287
+ """Handle Enter on the browser list.
288
+
289
+ Textual's ListView consumes Enter and emits ``Selected`` instead of
290
+ letting the app-level Enter binding run, so route the selected row
291
+ through the same selection logic used by the explicit action.
292
+ """
293
+
294
+ row = event.item
295
+ if isinstance(row, BrowserRow):
296
+ self.select_item(row.item)
297
+
298
+ def action_select(self) -> None:
299
+ item = self.focused_item()
300
+ if item is not None:
301
+ self.select_item(item)
302
+
303
+ def select_item(self, item: BrowserItem) -> None:
304
+ """Select, drill into, or enqueue a browser item."""
305
+
306
+ if self.client is None:
307
+ return
308
+ if item.kind is ItemKind.BACK:
309
+ self.go_back()
310
+ elif item.kind is ItemKind.ARTISTS:
311
+ self.show_items(self.client.artists(), "Artists")
312
+ elif item.kind is ItemKind.ALBUMS:
313
+ self.show_items(self.client.albums(), "Albums")
314
+ elif item.kind is ItemKind.TRACKS:
315
+ self.show_items(self.client.tracks(), "Tracks")
316
+ elif item.kind is ItemKind.PLAYLISTS:
317
+ self.show_items(self.client.playlists(), "Playlists")
318
+ elif item.kind is ItemKind.ARTIST:
319
+ self.show_items(self.client.albums(item.source), f"Albums by {item.title}")
320
+ elif item.kind in {ItemKind.ALBUM, ItemKind.PLAYLIST}:
321
+ self.show_items(self.client.tracks(item.source), item.title)
322
+ elif item.kind is ItemKind.TRACK:
323
+ self.append_item(item)
324
+
325
+ def action_play_now(self) -> None:
326
+ item = self.focused_item()
327
+ if item is None or self.client is None:
328
+ return
329
+ tracks = self.client.playable_tracks(item)
330
+ first = self.queue.replace(tracks)
331
+ if first is None:
332
+ self.set_status(f"Nothing playable for {item.title}.")
333
+ return
334
+ self.play(first)
335
+ self.refresh_queue()
336
+
337
+ def action_append(self) -> None:
338
+ item = self.focused_item()
339
+ if item is not None:
340
+ self.append_item(item)
341
+
342
+ def action_pause_resume(self) -> None:
343
+ if self.player is not None:
344
+ self.player.pause_resume()
345
+
346
+ def action_next_track(self) -> None:
347
+ track = self.queue.next()
348
+ if track is None:
349
+ self.set_status("End of queue.")
350
+ self.refresh_queue()
351
+ return
352
+ self.play(track)
353
+ self.refresh_queue()
354
+
355
+ def action_stop(self) -> None:
356
+ if self.player is not None:
357
+ self.player.stop()
358
+ self.set_status("Stopped.")
359
+
360
+ def append_item(self, item: BrowserItem) -> None:
361
+ if self.client is None:
362
+ return
363
+ tracks = self.client.playable_tracks(item)
364
+ if not tracks:
365
+ self.set_status(f"Nothing playable for {item.title}.")
366
+ return
367
+ self.queue.append(tracks)
368
+ self.refresh_queue()
369
+ self.set_status(f"Added {len(tracks)} track(s) to queue.")
370
+
371
+ def play(self, track: QueueTrack) -> None:
372
+ if self.player is None:
373
+ self.set_status("mpv is not available.")
374
+ return
375
+ self.player.play(track)
376
+ self.query_one("#now-playing", Static).update(track.label)
377
+ self.set_status(f"Playing {track.label}")
378
+
379
+ def focused_item(self) -> BrowserItem | None:
380
+ browser = self.query_one("#browser-list", ListView)
381
+ row = browser.highlighted_child
382
+ if isinstance(row, BrowserRow):
383
+ return row.item
384
+ return None
385
+
386
+ def go_back(self) -> None:
387
+ if not self.history:
388
+ return
389
+ self.items = self.history.pop()
390
+ browser = self.query_one("#browser-list", ListView)
391
+ browser.clear()
392
+ if self.history:
393
+ browser.append(BrowserRow(BrowserItem("..", ItemKind.BACK)))
394
+ for item in self.items:
395
+ browser.append(BrowserRow(item))
396
+ self.set_status("Back")
397
+
398
+ def refresh_queue(self) -> None:
399
+ labels = self.queue.labels()
400
+ self.query_one("#queue", Static).update(
401
+ "\n".join(labels) if labels else "Queue is empty"
402
+ )
403
+
404
+ def set_status(self, message: str) -> None:
405
+ self.query_one("#status", Static).update(message)
406
+
407
+
408
+ def main() -> None:
409
+ """Run Plexbar."""
410
+
411
+ PlexbarApp().run()
@@ -0,0 +1,61 @@
1
+ """Small domain models used by the Plexbar UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+
10
+ class ItemKind(StrEnum):
11
+ """Kinds of Plex music items Plexbar can browse."""
12
+
13
+ ROOT = "root"
14
+ ARTISTS = "artists"
15
+ ALBUMS = "albums"
16
+ TRACKS = "tracks"
17
+ PLAYLISTS = "playlists"
18
+ ARTIST = "artist"
19
+ ALBUM = "album"
20
+ TRACK = "track"
21
+ PLAYLIST = "playlist"
22
+ BACK = "back"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class BrowserItem:
27
+ """A row displayed in the browser list."""
28
+
29
+ title: str
30
+ kind: ItemKind
31
+ source: Any | None = None
32
+ subtitle: str = ""
33
+
34
+ @property
35
+ def display_title(self) -> str:
36
+ """Human-readable title with optional subtitle."""
37
+
38
+ if self.subtitle:
39
+ return f"{self.title} — {self.subtitle}"
40
+ return self.title
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class QueueTrack:
45
+ """A track queued for playback."""
46
+
47
+ title: str
48
+ artist: str
49
+ album: str
50
+ stream_url: str
51
+
52
+ @property
53
+ def label(self) -> str:
54
+ """Human-readable track label."""
55
+
56
+ bits = [self.title]
57
+ if self.artist:
58
+ bits.append(self.artist)
59
+ if self.album:
60
+ bits.append(self.album)
61
+ return " — ".join(bits)
@@ -0,0 +1,115 @@
1
+ """Playback queue and mpv subprocess integration."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ from dataclasses import dataclass, field
6
+
7
+ from plexbar.models import QueueTrack
8
+
9
+
10
+ class MpvNotFoundError(RuntimeError):
11
+ """Raised when mpv is required but not installed."""
12
+
13
+
14
+ @dataclass
15
+ class PlaybackQueue:
16
+ """In-memory playback queue."""
17
+
18
+ tracks: list[QueueTrack] = field(default_factory=list)
19
+ current_index: int = -1
20
+
21
+ @property
22
+ def current(self) -> QueueTrack | None:
23
+ """Return the current track, if any."""
24
+
25
+ if 0 <= self.current_index < len(self.tracks):
26
+ return self.tracks[self.current_index]
27
+ return None
28
+
29
+ def append(self, tracks: list[QueueTrack]) -> None:
30
+ """Append tracks to the queue."""
31
+
32
+ self.tracks.extend(tracks)
33
+ if self.current_index == -1 and self.tracks:
34
+ self.current_index = 0
35
+
36
+ def replace(self, tracks: list[QueueTrack]) -> QueueTrack | None:
37
+ """Replace the queue and return the first track."""
38
+
39
+ self.tracks = list(tracks)
40
+ self.current_index = 0 if self.tracks else -1
41
+ return self.current
42
+
43
+ def next(self) -> QueueTrack | None:
44
+ """Advance to the next track and return it."""
45
+
46
+ if self.current_index + 1 < len(self.tracks):
47
+ self.current_index += 1
48
+ return self.current
49
+ self.current_index = -1
50
+ return None
51
+
52
+ def labels(self) -> list[str]:
53
+ """Return display labels for the queue."""
54
+
55
+ labels: list[str] = []
56
+ for index, track in enumerate(self.tracks):
57
+ prefix = "▶ " if index == self.current_index else " "
58
+ labels.append(f"{prefix}{track.label}")
59
+ return labels
60
+
61
+
62
+ class MpvPlayer:
63
+ """Simple one-track-at-a-time mpv controller."""
64
+
65
+ def __init__(self) -> None:
66
+ if shutil.which("mpv") is None:
67
+ raise MpvNotFoundError(
68
+ "mpv was not found on PATH; install mpv to use Plexbar playback."
69
+ )
70
+ self._process: subprocess.Popen[str] | None = None
71
+ self._paused = False
72
+
73
+ @property
74
+ def is_running(self) -> bool:
75
+ """Return whether mpv is currently running."""
76
+
77
+ return self._process is not None and self._process.poll() is None
78
+
79
+ def play(self, track: QueueTrack) -> None:
80
+ """Start playing a track, replacing any existing mpv process."""
81
+
82
+ self.stop()
83
+ self._paused = False
84
+ self._process = subprocess.Popen( # noqa: S603
85
+ ["mpv", "--no-video", "--really-quiet", track.stream_url],
86
+ stdin=subprocess.PIPE,
87
+ text=True,
88
+ )
89
+
90
+ def pause_resume(self) -> None:
91
+ """Toggle pause/resume in mpv."""
92
+
93
+ if (
94
+ self._process is None
95
+ or self._process.stdin is None
96
+ or self._process.poll() is not None
97
+ ):
98
+ return
99
+ self._process.stdin.write("p\n")
100
+ self._process.stdin.flush()
101
+ self._paused = not self._paused
102
+
103
+ def stop(self) -> None:
104
+ """Stop mpv if it is running."""
105
+
106
+ if self._process is None:
107
+ return
108
+ if self._process.poll() is None:
109
+ self._process.terminate()
110
+ try:
111
+ self._process.wait(timeout=2)
112
+ except subprocess.TimeoutExpired:
113
+ self._process.kill()
114
+ self._process = None
115
+ self._paused = False
@@ -0,0 +1,193 @@
1
+ """Plex API wrapper for music browsing."""
2
+
3
+ from collections.abc import Iterable
4
+ from typing import Any, cast
5
+
6
+ from plexapi.audio import Album, Artist, Track # type: ignore[import-untyped]
7
+ from plexapi.library import MusicSection # type: ignore[import-untyped]
8
+ from plexapi.server import PlexServer # type: ignore[import-untyped]
9
+
10
+ from plexbar.models import BrowserItem, ItemKind, QueueTrack
11
+
12
+
13
+ class PlexMusicClient:
14
+ """Small adapter around python-plexapi for the UI."""
15
+
16
+ def __init__(self, config: Any) -> None:
17
+ self.config = config
18
+ self.server = PlexServer(config.base_url, config.token)
19
+ self.library = self._default_music_library(config.default_library)
20
+
21
+ @staticmethod
22
+ def validate(base_url: str, token: str) -> list[str]:
23
+ """Validate Plex credentials and return available music library names."""
24
+
25
+ server = PlexServer(base_url, token)
26
+ return [section.title for section in music_sections(server)]
27
+
28
+ def root_items(self) -> list[BrowserItem]:
29
+ """Top-level browser sections."""
30
+
31
+ return [
32
+ BrowserItem("Artists", ItemKind.ARTISTS),
33
+ BrowserItem("Albums", ItemKind.ALBUMS),
34
+ BrowserItem("Tracks", ItemKind.TRACKS),
35
+ BrowserItem("Playlists", ItemKind.PLAYLISTS),
36
+ ]
37
+
38
+ def artists(self) -> list[BrowserItem]:
39
+ """Return artist rows."""
40
+
41
+ artists = self.library.search(libtype="artist")
42
+ return [BrowserItem(item.title, ItemKind.ARTIST, item) for item in artists]
43
+
44
+ def albums(self, artist: Any | None = None) -> list[BrowserItem]:
45
+ """Return album rows, optionally scoped to an artist."""
46
+
47
+ albums = artist.albums() if artist is not None else self.library.albums()
48
+ return [
49
+ BrowserItem(
50
+ str(album.title),
51
+ ItemKind.ALBUM,
52
+ album,
53
+ _safe_title(album, "parentTitle"),
54
+ )
55
+ for album in albums
56
+ if album is not None
57
+ ]
58
+
59
+ def tracks(self, parent: Any | None = None) -> list[BrowserItem]:
60
+ """Return track rows, optionally scoped to an album or playlist."""
61
+
62
+ if parent is None:
63
+ tracks = self.library.search(libtype="track")
64
+ elif hasattr(parent, "tracks"):
65
+ tracks = parent.tracks()
66
+ elif hasattr(parent, "items"):
67
+ tracks = [item for item in parent.items() if _is_track(item)]
68
+ else:
69
+ tracks = []
70
+ return [self.track_item(track) for track in tracks]
71
+
72
+ def playlists(self) -> list[BrowserItem]:
73
+ """Return music playlists."""
74
+
75
+ playlists = [
76
+ playlist
77
+ for playlist in self.server.playlists()
78
+ if playlist is not None and _is_audio_playlist(playlist)
79
+ ]
80
+ return [
81
+ BrowserItem(str(playlist.title), ItemKind.PLAYLIST, playlist)
82
+ for playlist in playlists
83
+ ]
84
+
85
+ def search(self, query: str) -> list[BrowserItem]:
86
+ """Search the configured music library by title.
87
+
88
+ PlexAPI's ``MusicSection.search`` does not accept a ``query`` keyword;
89
+ unknown keywords are treated as filter fields, which can raise for music
90
+ libraries. Search each supported music type by title instead.
91
+ """
92
+
93
+ items: list[BrowserItem] = []
94
+ for libtype in ("artist", "album", "track"):
95
+ results = self.library.search(title=query, libtype=libtype)
96
+ for result in results:
97
+ item = self._item_from_result(result)
98
+ if item is not None:
99
+ items.append(item)
100
+ items.extend(
101
+ playlist
102
+ for playlist in self.playlists()
103
+ if query.casefold() in playlist.title.casefold()
104
+ )
105
+ return items
106
+
107
+ def playable_tracks(self, item: BrowserItem) -> list[QueueTrack]:
108
+ """Expand a browser item to queueable tracks."""
109
+
110
+ if item.kind is ItemKind.TRACK and item.source is not None:
111
+ return [self.queue_track(item.source)]
112
+ if item.kind is ItemKind.ALBUM and item.source is not None:
113
+ return [self.queue_track(track) for track in item.source.tracks()]
114
+ if item.kind is ItemKind.PLAYLIST and item.source is not None:
115
+ return [
116
+ self.queue_track(track) for track in self._playlist_tracks(item.source)
117
+ ]
118
+ if item.kind is ItemKind.ARTIST and item.source is not None:
119
+ return [
120
+ self.queue_track(track)
121
+ for album in item.source.albums()
122
+ for track in album.tracks()
123
+ ]
124
+ return []
125
+
126
+ def track_item(self, track: Track) -> BrowserItem:
127
+ """Convert a Plex track to a browser row."""
128
+
129
+ return BrowserItem(track.title, ItemKind.TRACK, track, _track_subtitle(track))
130
+
131
+ def queue_track(self, track: Track) -> QueueTrack:
132
+ """Convert a Plex track to a playback queue item."""
133
+
134
+ return QueueTrack(
135
+ title=track.title,
136
+ artist=_safe_title(track, "grandparentTitle"),
137
+ album=_safe_title(track, "parentTitle"),
138
+ stream_url=track.getStreamURL(),
139
+ )
140
+
141
+ def _default_music_library(self, preferred_name: str | None) -> MusicSection:
142
+ sections = music_sections(self.server)
143
+ if not sections:
144
+ msg = "No Plex music libraries were found."
145
+ raise RuntimeError(msg)
146
+ if preferred_name:
147
+ for section in sections:
148
+ if section.title == preferred_name:
149
+ return section
150
+ return sections[0]
151
+
152
+ def _playlist_tracks(self, playlist: Any) -> list[Track]:
153
+ return [cast(Track, item) for item in playlist.items() if _is_track(item)]
154
+
155
+ def _item_from_result(self, result: Any) -> BrowserItem | None:
156
+ if isinstance(result, Artist):
157
+ return BrowserItem(result.title, ItemKind.ARTIST, result)
158
+ if isinstance(result, Album):
159
+ return BrowserItem(
160
+ result.title, ItemKind.ALBUM, result, _safe_title(result, "parentTitle")
161
+ )
162
+ if _is_track(result):
163
+ return self.track_item(result)
164
+ return None
165
+
166
+
167
+ def music_sections(server: PlexServer) -> list[MusicSection]:
168
+ """Return only music sections from a Plex server."""
169
+
170
+ return [
171
+ section
172
+ for section in server.library.sections()
173
+ if isinstance(section, MusicSection)
174
+ ]
175
+
176
+
177
+ def _track_subtitle(track: Track) -> str:
178
+ artist = _safe_title(track, "grandparentTitle")
179
+ album = _safe_title(track, "parentTitle")
180
+ return " — ".join(part for part in [artist, album] if part)
181
+
182
+
183
+ def _safe_title(item: Any, attr: str) -> str:
184
+ return str(getattr(item, attr, "") or "")
185
+
186
+
187
+ def _is_track(item: Any) -> bool:
188
+ return isinstance(item, Track) or getattr(item, "TYPE", None) == "track"
189
+
190
+
191
+ def _is_audio_playlist(playlist: Any) -> bool:
192
+ items: Iterable[Any] = playlist.items()
193
+ return any(_is_track(item) for item in items)
@@ -0,0 +1,76 @@
1
+ """Configuration loading and first-run setup persistence."""
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from platformdirs import user_config_dir
9
+
10
+ APP_NAME = "plexbar"
11
+ CONFIG_DIR = Path(user_config_dir(APP_NAME))
12
+ CONFIG_PATH = CONFIG_DIR / "config.toml"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PlexbarConfig:
17
+ """Persisted Plexbar configuration."""
18
+
19
+ base_url: str
20
+ token: str
21
+ default_library: str | None = None
22
+
23
+
24
+ def config_exists(path: Path = CONFIG_PATH) -> bool:
25
+ """Return whether a Plexbar config file exists."""
26
+
27
+ return path.exists()
28
+
29
+
30
+ def load_config(path: Path = CONFIG_PATH) -> PlexbarConfig:
31
+ """Load config from TOML."""
32
+
33
+ with path.open("rb") as config_file:
34
+ data = tomllib.load(config_file)
35
+
36
+ plex = data.get("plex", {})
37
+ base_url = str(plex.get("base_url", "")).strip()
38
+ token = str(plex.get("token", "")).strip()
39
+ default_library = plex.get("default_library")
40
+
41
+ if not base_url or not token:
42
+ msg = f"Invalid Plexbar config at {path}: base_url and token are required"
43
+ raise ValueError(msg)
44
+
45
+ return PlexbarConfig(
46
+ base_url=base_url,
47
+ token=token,
48
+ default_library=str(default_library) if default_library else None,
49
+ )
50
+
51
+
52
+ def save_config(config: PlexbarConfig, path: Path = CONFIG_PATH) -> None:
53
+ """Save config as TOML with user-only permissions where possible."""
54
+
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ lines = [
57
+ "[plex]",
58
+ f'base_url = "{_toml_escape(config.base_url)}"',
59
+ f'token = "{_toml_escape(config.token)}"',
60
+ ]
61
+ if config.default_library:
62
+ lines.append(f'default_library = "{_toml_escape(config.default_library)}"')
63
+ contents = "\n".join(lines) + "\n"
64
+
65
+ old_umask = os.umask(0o177)
66
+ try:
67
+ path.write_text(contents, encoding="utf-8")
68
+ finally:
69
+ os.umask(old_umask)
70
+ path.chmod(0o600)
71
+
72
+
73
+ def _toml_escape(value: str) -> str:
74
+ """Escape a value for a basic TOML string."""
75
+
76
+ return value.replace("\\", "\\\\").replace('"', '\\"')