jellyburn 0.3.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.
- jellyburn-0.3.0/LICENSE +25 -0
- jellyburn-0.3.0/PKG-INFO +131 -0
- jellyburn-0.3.0/README.md +106 -0
- jellyburn-0.3.0/jellyburn/__init__.py +1 -0
- jellyburn-0.3.0/jellyburn/__main__.py +46 -0
- jellyburn-0.3.0/jellyburn/api.py +140 -0
- jellyburn-0.3.0/jellyburn/burner.py +216 -0
- jellyburn-0.3.0/jellyburn/config.py +98 -0
- jellyburn-0.3.0/jellyburn/icons/jellyburn.svg +41 -0
- jellyburn-0.3.0/jellyburn/player.py +68 -0
- jellyburn-0.3.0/jellyburn/ui/__init__.py +0 -0
- jellyburn-0.3.0/jellyburn/ui/main_window.py +967 -0
- jellyburn-0.3.0/jellyburn/ui/mini_player.py +125 -0
- jellyburn-0.3.0/jellyburn/ui/settings_dialog.py +75 -0
- jellyburn-0.3.0/jellyburn.egg-info/PKG-INFO +131 -0
- jellyburn-0.3.0/jellyburn.egg-info/SOURCES.txt +20 -0
- jellyburn-0.3.0/jellyburn.egg-info/dependency_links.txt +1 -0
- jellyburn-0.3.0/jellyburn.egg-info/entry_points.txt +2 -0
- jellyburn-0.3.0/jellyburn.egg-info/requires.txt +1 -0
- jellyburn-0.3.0/jellyburn.egg-info/top_level.txt +1 -0
- jellyburn-0.3.0/pyproject.toml +42 -0
- jellyburn-0.3.0/setup.cfg +4 -0
jellyburn-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
<<<<<<< HEAD
|
|
4
|
+
Copyright (c) 2026 Ömer Akyüz
|
|
5
|
+
=======
|
|
6
|
+
Copyright (c) 2026 Ömer Hamzaoglu
|
|
7
|
+
>>>>>>> bb5c7e10716ec4e038836f66b34c688a6a3adee9
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
jellyburn-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jellyburn
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A GTK music player for Jellyfin with CD burning support
|
|
5
|
+
Author: Ömer Hamzaoğlu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/oemerhamzaoglu/jellyburn
|
|
8
|
+
Project-URL: Issues, https://github.com/oemerhamzaoglu/jellyburn/issues
|
|
9
|
+
Keywords: jellyfin,music,cd,burning,gtk,linux
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: X11 Applications :: GTK
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: requests>=2.28
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# jellyburn
|
|
27
|
+
|
|
28
|
+
A GTK3 desktop app for Linux to browse your Jellyfin music library, build playlists, and burn them directly to audio CD.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+

|
|
32
|
+

|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- Connects to any Jellyfin server via API key or username/password
|
|
38
|
+
- iTunes-style column browser — filter by artist, then album, then tracks
|
|
39
|
+
- Track number column, search by title, artist or album
|
|
40
|
+
- Album art display fetched directly from Jellyfin
|
|
41
|
+
- Playback via `mpv` with now-playing info, progress bar and scrubbing
|
|
42
|
+
- Collapse to a compact mini player that stays on top while you use other apps
|
|
43
|
+
- Playlist builder with drag-and-drop reordering, save/load as JSON
|
|
44
|
+
- Delete tracks from playlist with the Delete key or right-click
|
|
45
|
+
- Real-time CD capacity bar (green → yellow → red, max. 74 min)
|
|
46
|
+
- Burn directly to audio CD in Disc At Once mode — no extra steps
|
|
47
|
+
- Auto-detects optical drives, shown as a dropdown in settings
|
|
48
|
+
- Library cached locally for instant startup, refreshed in the background
|
|
49
|
+
- Checks for missing system dependencies on startup with clear instructions
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
**System packages** (Debian/Ubuntu/Mint):
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-gdkpixbuf-2.0 mpv ffmpeg cdrskin
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> `cdrskin` is the recommended burn backend. `wodim` is supported as a fallback but may require extra permissions on modern kernels:
|
|
60
|
+
> ```bash
|
|
61
|
+
> sudo setcap cap_ipc_lock+ep $(which wodim)
|
|
62
|
+
> ```
|
|
63
|
+
|
|
64
|
+
**Python:** 3.10 or newer. The only Python dependency (`requests`) is installed automatically via pip.
|
|
65
|
+
|
|
66
|
+
**Your user must be in the `cdrom` group** to access the optical drive without root:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
sudo usermod -aG cdrom $USER
|
|
70
|
+
# log out and back in for the change to take effect
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Installation
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install jellyburn
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or run from source:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/oemerhamzaoglu/jellyburn
|
|
83
|
+
cd jellyburn
|
|
84
|
+
pip install -e .
|
|
85
|
+
jellyburn
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Setup
|
|
89
|
+
|
|
90
|
+
1. Click the settings icon (⚙) in the top right
|
|
91
|
+
2. Enter your Jellyfin server URL, e.g. `https://jellyfin.example.com`
|
|
92
|
+
3. Enter an API key — Jellyfin Dashboard → Administration → API Keys → New Key
|
|
93
|
+
4. Select your CD drive from the dropdown (auto-detected)
|
|
94
|
+
5. Set burn speed (default: 4×)
|
|
95
|
+
6. Save — the app connects and loads your library
|
|
96
|
+
|
|
97
|
+
Config is stored in `~/.config/jellyburn.json`. Passwords are never saved — only the API token obtained after login.
|
|
98
|
+
Library cache is stored in `~/.cache/jellyburn/`.
|
|
99
|
+
|
|
100
|
+
## Usage
|
|
101
|
+
|
|
102
|
+
| Action | How |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Browse | Click an artist → albums update; click an album → tracks filter |
|
|
105
|
+
| Search | Type in the search bar — filters title, artist and album live |
|
|
106
|
+
| Play | Double-click a track, or select it and press Play |
|
|
107
|
+
| Scrub | Click or drag the progress bar to seek within a track |
|
|
108
|
+
| Mini player | Click the collapse button (⧉) in the header; click ⤢ to restore |
|
|
109
|
+
| Add to playlist | Select tracks (Ctrl+click for multiple), then „+ Auswahl hinzufügen" |
|
|
110
|
+
| Reorder playlist | Drag and drop rows |
|
|
111
|
+
| Remove from playlist | Select rows and press Delete, or right-click → remove |
|
|
112
|
+
| Save/load playlist | Use the save/open icons in the playlist header |
|
|
113
|
+
| Burn | Click „● CD BRENNEN" — tracks are downloaded, converted and burned |
|
|
114
|
+
|
|
115
|
+
The CD capacity bar turns yellow above 85 % and red when the playlist exceeds 74 minutes.
|
|
116
|
+
|
|
117
|
+
## Burn process
|
|
118
|
+
|
|
119
|
+
1. Tracks are downloaded from Jellyfin one by one
|
|
120
|
+
2. Each track is converted to WAV (44100 Hz, 16-bit stereo) via `ffmpeg`
|
|
121
|
+
3. All WAV files are written to CD as an audio disc in DAO mode via `cdrskin` (or `wodim`)
|
|
122
|
+
|
|
123
|
+
Temporary files in `/tmp/jellyfin_burn_*/` are cleaned up automatically after burning.
|
|
124
|
+
|
|
125
|
+
## Contributing
|
|
126
|
+
|
|
127
|
+
Open an issue before starting larger changes. PRs welcome.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# jellyburn
|
|
2
|
+
|
|
3
|
+
A GTK3 desktop app for Linux to browse your Jellyfin music library, build playlists, and burn them directly to audio CD.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Connects to any Jellyfin server via API key or username/password
|
|
13
|
+
- iTunes-style column browser — filter by artist, then album, then tracks
|
|
14
|
+
- Track number column, search by title, artist or album
|
|
15
|
+
- Album art display fetched directly from Jellyfin
|
|
16
|
+
- Playback via `mpv` with now-playing info, progress bar and scrubbing
|
|
17
|
+
- Collapse to a compact mini player that stays on top while you use other apps
|
|
18
|
+
- Playlist builder with drag-and-drop reordering, save/load as JSON
|
|
19
|
+
- Delete tracks from playlist with the Delete key or right-click
|
|
20
|
+
- Real-time CD capacity bar (green → yellow → red, max. 74 min)
|
|
21
|
+
- Burn directly to audio CD in Disc At Once mode — no extra steps
|
|
22
|
+
- Auto-detects optical drives, shown as a dropdown in settings
|
|
23
|
+
- Library cached locally for instant startup, refreshed in the background
|
|
24
|
+
- Checks for missing system dependencies on startup with clear instructions
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
**System packages** (Debian/Ubuntu/Mint):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-gdkpixbuf-2.0 mpv ffmpeg cdrskin
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
> `cdrskin` is the recommended burn backend. `wodim` is supported as a fallback but may require extra permissions on modern kernels:
|
|
35
|
+
> ```bash
|
|
36
|
+
> sudo setcap cap_ipc_lock+ep $(which wodim)
|
|
37
|
+
> ```
|
|
38
|
+
|
|
39
|
+
**Python:** 3.10 or newer. The only Python dependency (`requests`) is installed automatically via pip.
|
|
40
|
+
|
|
41
|
+
**Your user must be in the `cdrom` group** to access the optical drive without root:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
sudo usermod -aG cdrom $USER
|
|
45
|
+
# log out and back in for the change to take effect
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install jellyburn
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or run from source:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/oemerhamzaoglu/jellyburn
|
|
58
|
+
cd jellyburn
|
|
59
|
+
pip install -e .
|
|
60
|
+
jellyburn
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Setup
|
|
64
|
+
|
|
65
|
+
1. Click the settings icon (⚙) in the top right
|
|
66
|
+
2. Enter your Jellyfin server URL, e.g. `https://jellyfin.example.com`
|
|
67
|
+
3. Enter an API key — Jellyfin Dashboard → Administration → API Keys → New Key
|
|
68
|
+
4. Select your CD drive from the dropdown (auto-detected)
|
|
69
|
+
5. Set burn speed (default: 4×)
|
|
70
|
+
6. Save — the app connects and loads your library
|
|
71
|
+
|
|
72
|
+
Config is stored in `~/.config/jellyburn.json`. Passwords are never saved — only the API token obtained after login.
|
|
73
|
+
Library cache is stored in `~/.cache/jellyburn/`.
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
| Action | How |
|
|
78
|
+
|---|---|
|
|
79
|
+
| Browse | Click an artist → albums update; click an album → tracks filter |
|
|
80
|
+
| Search | Type in the search bar — filters title, artist and album live |
|
|
81
|
+
| Play | Double-click a track, or select it and press Play |
|
|
82
|
+
| Scrub | Click or drag the progress bar to seek within a track |
|
|
83
|
+
| Mini player | Click the collapse button (⧉) in the header; click ⤢ to restore |
|
|
84
|
+
| Add to playlist | Select tracks (Ctrl+click for multiple), then „+ Auswahl hinzufügen" |
|
|
85
|
+
| Reorder playlist | Drag and drop rows |
|
|
86
|
+
| Remove from playlist | Select rows and press Delete, or right-click → remove |
|
|
87
|
+
| Save/load playlist | Use the save/open icons in the playlist header |
|
|
88
|
+
| Burn | Click „● CD BRENNEN" — tracks are downloaded, converted and burned |
|
|
89
|
+
|
|
90
|
+
The CD capacity bar turns yellow above 85 % and red when the playlist exceeds 74 minutes.
|
|
91
|
+
|
|
92
|
+
## Burn process
|
|
93
|
+
|
|
94
|
+
1. Tracks are downloaded from Jellyfin one by one
|
|
95
|
+
2. Each track is converted to WAV (44100 Hz, 16-bit stereo) via `ffmpeg`
|
|
96
|
+
3. All WAV files are written to CD as an audio disc in DAO mode via `cdrskin` (or `wodim`)
|
|
97
|
+
|
|
98
|
+
Temporary files in `/tmp/jellyfin_burn_*/` are cleaned up automatically after burning.
|
|
99
|
+
|
|
100
|
+
## Contributing
|
|
101
|
+
|
|
102
|
+
Open an issue before starting larger changes. PRs welcome.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Jellyburn – Jellyfin music player and CD burner.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import gi
|
|
9
|
+
gi.require_version("Gtk", "3.0")
|
|
10
|
+
from gi.repository import Gtk
|
|
11
|
+
|
|
12
|
+
from .config import check_dependencies
|
|
13
|
+
from .ui.main_window import MainWindow
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JellyburnApp(Gtk.Application):
|
|
17
|
+
def __init__(self):
|
|
18
|
+
super().__init__(application_id="de.linumed.jellyfinburner")
|
|
19
|
+
|
|
20
|
+
def do_activate(self):
|
|
21
|
+
win = MainWindow(application=self)
|
|
22
|
+
win.show_all()
|
|
23
|
+
missing = check_dependencies()
|
|
24
|
+
if missing:
|
|
25
|
+
dlg = Gtk.MessageDialog(
|
|
26
|
+
transient_for=win, modal=True,
|
|
27
|
+
message_type=Gtk.MessageType.WARNING,
|
|
28
|
+
buttons=Gtk.ButtonsType.OK,
|
|
29
|
+
text="Fehlende Systemabhängigkeiten",
|
|
30
|
+
)
|
|
31
|
+
dlg.format_secondary_text(
|
|
32
|
+
"Folgende Programme wurden nicht gefunden:\n\n" +
|
|
33
|
+
"\n".join(f" • {label}" for label in missing) +
|
|
34
|
+
"\n\nBitte installieren, damit alle Funktionen verfügbar sind."
|
|
35
|
+
)
|
|
36
|
+
dlg.run()
|
|
37
|
+
dlg.destroy()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
app = JellyburnApp()
|
|
42
|
+
app.run(sys.argv)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def track_artist(track):
|
|
5
|
+
return (track.get("AlbumArtist")
|
|
6
|
+
or (track.get("Artists") or [""])[0]
|
|
7
|
+
or "")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JellyfinClient:
|
|
11
|
+
TIMEOUT = 15
|
|
12
|
+
|
|
13
|
+
def __init__(self, server_url, api_key=None, username=None, password=None):
|
|
14
|
+
self.server_url = server_url.rstrip("/")
|
|
15
|
+
self.api_key = api_key
|
|
16
|
+
self.user_id = None
|
|
17
|
+
self.session = requests.Session()
|
|
18
|
+
self.session.headers.update({
|
|
19
|
+
"X-Emby-Authorization": (
|
|
20
|
+
'MediaBrowser Client="Jellyburn", Device="Linux",'
|
|
21
|
+
' DeviceId="jellyburn-01", Version="1.0"'
|
|
22
|
+
),
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
})
|
|
25
|
+
self.session.request = lambda method, url, **kw: \
|
|
26
|
+
requests.Session.request(self.session, method, url,
|
|
27
|
+
timeout=kw.pop("timeout", self.TIMEOUT), **kw)
|
|
28
|
+
if api_key:
|
|
29
|
+
self.session.headers["X-MediaBrowser-Token"] = api_key
|
|
30
|
+
elif username and password:
|
|
31
|
+
self._login(username, password)
|
|
32
|
+
|
|
33
|
+
def _login(self, username, password):
|
|
34
|
+
url = f"{self.server_url}/Users/AuthenticateByName"
|
|
35
|
+
resp = self.session.post(url, json={"Username": username, "Pw": password})
|
|
36
|
+
resp.raise_for_status()
|
|
37
|
+
data = resp.json()
|
|
38
|
+
self.api_key = data["AccessToken"]
|
|
39
|
+
self.user_id = data["User"]["Id"]
|
|
40
|
+
self.session.headers["X-MediaBrowser-Token"] = self.api_key
|
|
41
|
+
|
|
42
|
+
def get_user_id(self):
|
|
43
|
+
if self.user_id:
|
|
44
|
+
return self.user_id
|
|
45
|
+
resp = self.session.get(f"{self.server_url}/Users/Me")
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
self.user_id = resp.json()["Id"]
|
|
48
|
+
return self.user_id
|
|
49
|
+
|
|
50
|
+
def search_music(self, query="", on_page=None):
|
|
51
|
+
uid = self.get_user_id()
|
|
52
|
+
params = {
|
|
53
|
+
"IncludeItemTypes": "Audio",
|
|
54
|
+
"Recursive": "true",
|
|
55
|
+
"Fields": "RunTimeTicks,AlbumArtist,Album,Path,ParentId,ArtistIds",
|
|
56
|
+
"UserId": uid,
|
|
57
|
+
"Limit": 500,
|
|
58
|
+
"StartIndex": 0,
|
|
59
|
+
}
|
|
60
|
+
if query:
|
|
61
|
+
params["SearchTerm"] = query
|
|
62
|
+
resp = self.session.get(f"{self.server_url}/Items", params=params)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
return resp.json().get("Items", [])
|
|
65
|
+
|
|
66
|
+
all_items = []
|
|
67
|
+
total = None
|
|
68
|
+
while True:
|
|
69
|
+
params["StartIndex"] = len(all_items)
|
|
70
|
+
resp = self.session.get(f"{self.server_url}/Items", params=params)
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
data = resp.json()
|
|
73
|
+
if total is None:
|
|
74
|
+
total = data.get("TotalRecordCount", 0)
|
|
75
|
+
page = data.get("Items", [])
|
|
76
|
+
all_items.extend(page)
|
|
77
|
+
if on_page:
|
|
78
|
+
on_page(page, len(all_items), total)
|
|
79
|
+
if len(all_items) >= total or not page:
|
|
80
|
+
break
|
|
81
|
+
return all_items
|
|
82
|
+
|
|
83
|
+
def get_artists(self):
|
|
84
|
+
uid = self.get_user_id()
|
|
85
|
+
resp = self.session.get(
|
|
86
|
+
f"{self.server_url}/Artists",
|
|
87
|
+
params={"UserId": uid, "Recursive": "true", "Limit": 500},
|
|
88
|
+
)
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
return resp.json().get("Items", [])
|
|
91
|
+
|
|
92
|
+
def get_albums(self, artist_id=None):
|
|
93
|
+
uid = self.get_user_id()
|
|
94
|
+
params = {
|
|
95
|
+
"IncludeItemTypes": "MusicAlbum",
|
|
96
|
+
"Recursive": "true",
|
|
97
|
+
"UserId": uid,
|
|
98
|
+
"Limit": 500,
|
|
99
|
+
"Fields": "AlbumArtist,ChildCount,RunTimeTicks",
|
|
100
|
+
}
|
|
101
|
+
if artist_id:
|
|
102
|
+
params["AlbumArtistIds"] = artist_id
|
|
103
|
+
resp = self.session.get(f"{self.server_url}/Items", params=params)
|
|
104
|
+
resp.raise_for_status()
|
|
105
|
+
return resp.json().get("Items", [])
|
|
106
|
+
|
|
107
|
+
def get_tracks(self, album_id=None, artist_id=None):
|
|
108
|
+
uid = self.get_user_id()
|
|
109
|
+
params = {
|
|
110
|
+
"IncludeItemTypes": "Audio",
|
|
111
|
+
"Recursive": "true",
|
|
112
|
+
"UserId": uid,
|
|
113
|
+
"Limit": 500,
|
|
114
|
+
"Fields": "RunTimeTicks,AlbumArtist,Album,IndexNumber,ParentIndexNumber,Path",
|
|
115
|
+
"SortBy": "ParentIndexNumber,IndexNumber,SortName",
|
|
116
|
+
}
|
|
117
|
+
if album_id:
|
|
118
|
+
params["ParentId"] = album_id
|
|
119
|
+
if artist_id:
|
|
120
|
+
params["ArtistIds"] = artist_id
|
|
121
|
+
resp = self.session.get(f"{self.server_url}/Items", params=params)
|
|
122
|
+
resp.raise_for_status()
|
|
123
|
+
return resp.json().get("Items", [])
|
|
124
|
+
|
|
125
|
+
def get_stream_url(self, item_id):
|
|
126
|
+
uid = self.get_user_id()
|
|
127
|
+
return (
|
|
128
|
+
f"{self.server_url}/Audio/{item_id}/stream"
|
|
129
|
+
f"?UserId={uid}&api_key={self.api_key}&AudioCodec=flac&Container=flac"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def get_download_url(self, item_id):
|
|
133
|
+
return f"{self.server_url}/Items/{item_id}/Download?api_key={self.api_key}"
|
|
134
|
+
|
|
135
|
+
def ticks_to_seconds(self, ticks):
|
|
136
|
+
return ticks // 10_000_000 if ticks else 0
|
|
137
|
+
|
|
138
|
+
def format_duration(self, ticks):
|
|
139
|
+
s = self.ticks_to_seconds(ticks)
|
|
140
|
+
return f"{s // 60}:{s % 60:02d}"
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import resource
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_sg_device(sr_device):
|
|
9
|
+
"""Löst /dev/srX in das passende /dev/sgX auf (für wodim nötig)."""
|
|
10
|
+
try:
|
|
11
|
+
name = os.path.basename(sr_device) # z.B. "sr0"
|
|
12
|
+
sg_dir = f"/sys/block/{name}/device/scsi_generic"
|
|
13
|
+
sg_name = os.listdir(sg_dir)[0] # z.B. "sg1"
|
|
14
|
+
return f"/dev/{sg_name}"
|
|
15
|
+
except Exception:
|
|
16
|
+
return sr_device # Fallback: original behalten
|
|
17
|
+
|
|
18
|
+
import gi
|
|
19
|
+
gi.require_version("Gtk", "3.0")
|
|
20
|
+
from gi.repository import Gtk, GLib
|
|
21
|
+
|
|
22
|
+
from .api import track_artist
|
|
23
|
+
from .config import check_dependencies, get_burn_tool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BurnDialog(Gtk.Dialog):
|
|
27
|
+
def __init__(self, parent, playlist, client, config):
|
|
28
|
+
super().__init__(title="CD brennen", transient_for=parent, modal=True)
|
|
29
|
+
self.set_default_size(500, 400)
|
|
30
|
+
self.playlist = playlist
|
|
31
|
+
self.client = client
|
|
32
|
+
self.config = config
|
|
33
|
+
self.cancelled = False
|
|
34
|
+
self._burning = False
|
|
35
|
+
|
|
36
|
+
box = self.get_content_area()
|
|
37
|
+
box.set_spacing(8)
|
|
38
|
+
box.set_margin_start(16)
|
|
39
|
+
box.set_margin_end(16)
|
|
40
|
+
box.set_margin_top(16)
|
|
41
|
+
box.set_margin_bottom(16)
|
|
42
|
+
|
|
43
|
+
box.pack_start(Gtk.Label(label="<b>Tracks auf CD:</b>", use_markup=True, xalign=0), False, False, 0)
|
|
44
|
+
|
|
45
|
+
sw = Gtk.ScrolledWindow()
|
|
46
|
+
sw.set_min_content_height(150)
|
|
47
|
+
tv = Gtk.TextView(editable=False, monospace=True)
|
|
48
|
+
buf = tv.get_buffer()
|
|
49
|
+
lines = "\n".join(
|
|
50
|
+
f"{i+1:2}. {track_artist(t) or '?'} - {t.get('Name','?')}"
|
|
51
|
+
f" ({client.format_duration(t.get('RunTimeTicks', 0))})"
|
|
52
|
+
for i, t in enumerate(playlist)
|
|
53
|
+
)
|
|
54
|
+
buf.set_text(lines)
|
|
55
|
+
sw.add(tv)
|
|
56
|
+
box.pack_start(sw, True, True, 0)
|
|
57
|
+
|
|
58
|
+
self.status_label = Gtk.Label(label="Bereit zum Brennen.", xalign=0)
|
|
59
|
+
self.status_label.set_line_wrap(True)
|
|
60
|
+
box.pack_start(self.status_label, False, False, 0)
|
|
61
|
+
|
|
62
|
+
self.progress = Gtk.ProgressBar()
|
|
63
|
+
self.progress.set_show_text(True)
|
|
64
|
+
box.pack_start(self.progress, False, False, 0)
|
|
65
|
+
|
|
66
|
+
btn_box = Gtk.Box(spacing=8, halign=Gtk.Align.END)
|
|
67
|
+
btn_box.set_margin_top(4)
|
|
68
|
+
|
|
69
|
+
self.cancel_btn = Gtk.Button(label="Abbrechen")
|
|
70
|
+
self.cancel_btn.connect("clicked", self._on_cancel)
|
|
71
|
+
btn_box.pack_start(self.cancel_btn, False, False, 0)
|
|
72
|
+
|
|
73
|
+
self.burn_btn = Gtk.Button(label="Jetzt brennen")
|
|
74
|
+
self.burn_btn.get_style_context().add_class("suggested-action")
|
|
75
|
+
self.burn_btn.connect("clicked", self._on_burn_clicked)
|
|
76
|
+
btn_box.pack_start(self.burn_btn, False, False, 0)
|
|
77
|
+
|
|
78
|
+
box.pack_start(btn_box, False, False, 0)
|
|
79
|
+
self.show_all()
|
|
80
|
+
|
|
81
|
+
def _on_burn_clicked(self, _):
|
|
82
|
+
missing = check_dependencies()
|
|
83
|
+
if missing:
|
|
84
|
+
self._set_status("Fehlende Programme: " + ", ".join(missing))
|
|
85
|
+
return
|
|
86
|
+
self.burn_btn.set_sensitive(False)
|
|
87
|
+
self.cancel_btn.set_sensitive(False)
|
|
88
|
+
self._burning = True
|
|
89
|
+
threading.Thread(target=self._burn_thread, daemon=True).start()
|
|
90
|
+
|
|
91
|
+
def _on_cancel(self, _):
|
|
92
|
+
if self._burning:
|
|
93
|
+
self.cancelled = True
|
|
94
|
+
else:
|
|
95
|
+
self.response(Gtk.ResponseType.CANCEL)
|
|
96
|
+
|
|
97
|
+
def _on_burn_done(self):
|
|
98
|
+
self._burning = False
|
|
99
|
+
self.cancel_btn.set_label("Schließen")
|
|
100
|
+
self.cancel_btn.set_sensitive(True)
|
|
101
|
+
|
|
102
|
+
def _set_status(self, text):
|
|
103
|
+
GLib.idle_add(self.status_label.set_text, text)
|
|
104
|
+
|
|
105
|
+
def _set_progress(self, fraction, text=""):
|
|
106
|
+
def _update():
|
|
107
|
+
self.progress.set_fraction(fraction)
|
|
108
|
+
if text:
|
|
109
|
+
self.progress.set_text(text)
|
|
110
|
+
GLib.idle_add(_update)
|
|
111
|
+
|
|
112
|
+
def _burn_thread(self):
|
|
113
|
+
tmpdir = tempfile.mkdtemp(prefix="jellyfin_burn_")
|
|
114
|
+
wav_files = []
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
total = len(self.playlist)
|
|
118
|
+
for i, track in enumerate(self.playlist):
|
|
119
|
+
if self.cancelled:
|
|
120
|
+
return
|
|
121
|
+
name = track.get("Name", f"track_{i+1}")
|
|
122
|
+
artist = track_artist(track)
|
|
123
|
+
self._set_status(f"Lade: {artist} - {name} ({i+1}/{total})")
|
|
124
|
+
self._set_progress(i / total / 2, f"Download {i+1}/{total}")
|
|
125
|
+
|
|
126
|
+
url = self.client.get_download_url(track["Id"])
|
|
127
|
+
resp = self.client.session.get(url, stream=True)
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
|
|
130
|
+
src_path = os.path.join(tmpdir, f"track_{i+1:02d}_src")
|
|
131
|
+
with open(src_path, "wb") as f:
|
|
132
|
+
for chunk in resp.iter_content(chunk_size=65536):
|
|
133
|
+
f.write(chunk)
|
|
134
|
+
|
|
135
|
+
wav_path = os.path.join(tmpdir, f"track_{i+1:02d}.wav")
|
|
136
|
+
self._set_status(f"Konvertiere: {name}")
|
|
137
|
+
result = subprocess.run(
|
|
138
|
+
["ffmpeg", "-y", "-i", src_path,
|
|
139
|
+
"-ar", "44100", "-ac", "2", "-f", "wav", wav_path],
|
|
140
|
+
capture_output=True, text=True,
|
|
141
|
+
)
|
|
142
|
+
if result.returncode != 0:
|
|
143
|
+
self._set_status(
|
|
144
|
+
f"Konvertierung fehlgeschlagen: {name}\n{result.stderr.strip()[-400:]}"
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
wav_files.append(wav_path)
|
|
148
|
+
os.unlink(src_path)
|
|
149
|
+
self._set_progress((i + 1) / total / 2, f"Konvertiert {i+1}/{total}")
|
|
150
|
+
|
|
151
|
+
if self.cancelled:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
self._set_status("Starte Brennvorgang – bitte nicht abbrechen...")
|
|
155
|
+
self._set_progress(0.5, "Brennen...")
|
|
156
|
+
|
|
157
|
+
device = resolve_sg_device(self.config.get("cd_device", "/dev/sr0"))
|
|
158
|
+
speed = self.config.get("burn_speed", 4)
|
|
159
|
+
burn_tool = get_burn_tool()
|
|
160
|
+
if not burn_tool:
|
|
161
|
+
self._set_status("Kein Brennprogramm gefunden.\nBitte installieren: sudo apt install cdrskin")
|
|
162
|
+
return
|
|
163
|
+
cmd = [burn_tool, f"dev={device}", f"speed={speed}", "-v", "-dao", "-audio", "-pad"] + wav_files
|
|
164
|
+
|
|
165
|
+
def _raise_memlock():
|
|
166
|
+
try:
|
|
167
|
+
soft, hard = resource.getrlimit(resource.RLIMIT_MEMLOCK)
|
|
168
|
+
if hard == resource.RLIM_INFINITY:
|
|
169
|
+
resource.setrlimit(resource.RLIMIT_MEMLOCK,
|
|
170
|
+
(resource.RLIM_INFINITY, resource.RLIM_INFINITY))
|
|
171
|
+
elif hard > soft:
|
|
172
|
+
resource.setrlimit(resource.RLIMIT_MEMLOCK, (hard, hard))
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
177
|
+
text=True, preexec_fn=_raise_memlock)
|
|
178
|
+
output_lines = []
|
|
179
|
+
for line in proc.stdout:
|
|
180
|
+
line = line.strip()
|
|
181
|
+
output_lines.append(line)
|
|
182
|
+
if "%" in line or "Track" in line or "Writing" in line:
|
|
183
|
+
self._set_status(line)
|
|
184
|
+
|
|
185
|
+
proc.wait()
|
|
186
|
+
if proc.returncode == 0:
|
|
187
|
+
self._set_status("CD erfolgreich gebrannt!")
|
|
188
|
+
self._set_progress(1.0, "Fertig!")
|
|
189
|
+
else:
|
|
190
|
+
full = "\n".join(output_lines[-20:])
|
|
191
|
+
if "RLIMIT_MEMLOCK" in full or "mmap" in full:
|
|
192
|
+
hint = (
|
|
193
|
+
f"Brenner-Fehler (Code {proc.returncode}) – Speicher-Lock-Problem.\n\n"
|
|
194
|
+
"Lösung: cdrskin installieren (empfohlen):\n"
|
|
195
|
+
" sudo apt install cdrskin\n\n"
|
|
196
|
+
"Oder Rechte für wodim setzen:\n"
|
|
197
|
+
" sudo setcap cap_ipc_lock+ep $(which wodim)\n\n"
|
|
198
|
+
f"Ausgabe:\n{full}"
|
|
199
|
+
)
|
|
200
|
+
self._set_status(hint)
|
|
201
|
+
else:
|
|
202
|
+
self._set_status(f"Brenner-Fehler (Code {proc.returncode}):\n{full}")
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
self._set_status(f"Fehler: {e}")
|
|
206
|
+
finally:
|
|
207
|
+
for f in wav_files:
|
|
208
|
+
try:
|
|
209
|
+
os.unlink(f)
|
|
210
|
+
except OSError:
|
|
211
|
+
pass
|
|
212
|
+
try:
|
|
213
|
+
os.rmdir(tmpdir)
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
GLib.idle_add(self._on_burn_done)
|