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.
@@ -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.
@@ -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
+ ![Python](https://img.shields.io/badge/python-3.10+-blue)
31
+ ![License](https://img.shields.io/badge/license-MIT-green)
32
+ ![Platform](https://img.shields.io/badge/platform-Linux-lightgrey)
33
+ ![Status](https://img.shields.io/badge/status-alpha-orange)
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
+ ![Python](https://img.shields.io/badge/python-3.10+-blue)
6
+ ![License](https://img.shields.io/badge/license-MIT-green)
7
+ ![Platform](https://img.shields.io/badge/platform-Linux-lightgrey)
8
+ ![Status](https://img.shields.io/badge/status-alpha-orange)
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)