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 +119 -0
- plexbar-0.1.2/README.md +107 -0
- plexbar-0.1.2/pyproject.toml +23 -0
- plexbar-0.1.2/src/plexbar/__init__.py +1 -0
- plexbar-0.1.2/src/plexbar/__main__.py +6 -0
- plexbar-0.1.2/src/plexbar/app.py +411 -0
- plexbar-0.1.2/src/plexbar/models.py +61 -0
- plexbar-0.1.2/src/plexbar/playback.py +115 -0
- plexbar-0.1.2/src/plexbar/plex_client.py +193 -0
- plexbar-0.1.2/src/plexbar/settings.py +76 -0
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.
|
plexbar-0.1.2/README.md
ADDED
|
@@ -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,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('"', '\\"')
|