tinyjukebox 0.47.0__py3-none-any.whl
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.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""Targets a tag can be linked to: albums (a folder with mp3 files and an m3u playlist), internet radio stations
|
|
2
|
+
and entries of Spotify."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import mutagen
|
|
12
|
+
|
|
13
|
+
from tinyjukebox.core.logging import get_child
|
|
14
|
+
from tinyjukebox.data.common import TargetType, UpdateJson, extra_fields, is_valid_timestamp, now
|
|
15
|
+
from tinyjukebox.metadata import mp3_info
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"MAX_ALBUM_TITLE_LENGTH",
|
|
19
|
+
"MAX_STATION_NAME_LENGTH",
|
|
20
|
+
"MAX_STREAM_URL_LENGTH",
|
|
21
|
+
"SPOTIFY_KINDS",
|
|
22
|
+
"ATarget",
|
|
23
|
+
"Target",
|
|
24
|
+
"TargetAlbum",
|
|
25
|
+
"TargetRadio",
|
|
26
|
+
"TargetSpotify",
|
|
27
|
+
"check_album_title",
|
|
28
|
+
"check_spotify_link",
|
|
29
|
+
"check_station_name",
|
|
30
|
+
"check_stream_url",
|
|
31
|
+
"is_mp3_filename",
|
|
32
|
+
"read_playlist",
|
|
33
|
+
"spotify_kind",
|
|
34
|
+
"spotify_link",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
MAX_ALBUM_TITLE_LENGTH = 200
|
|
38
|
+
MAX_STATION_NAME_LENGTH = 100
|
|
39
|
+
MAX_STREAM_URL_LENGTH = 2000
|
|
40
|
+
|
|
41
|
+
# the fields each kind of target knows: a field beyond them was written by a newer version and is kept (extra_fields)
|
|
42
|
+
_COMMON_FIELDS = ("target-id", "target-type", "created", "cover")
|
|
43
|
+
_ALBUM_FIELDS = (*_COMMON_FIELDS, "album-folder", "playlist", "title")
|
|
44
|
+
_RADIO_FIELDS = (*_COMMON_FIELDS, "name", "url")
|
|
45
|
+
_SPOTIFY_FIELDS = (*_COMMON_FIELDS, "name", "uri")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_mp3_filename(name: str) -> bool:
|
|
49
|
+
return name.lower().endswith(".mp3")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_album_title(title: object) -> str:
|
|
53
|
+
"""The title without surrounding blanks. :raises ValueError: if it is empty, too long or has control
|
|
54
|
+
characters."""
|
|
55
|
+
if not isinstance(title, str) or not title.strip():
|
|
56
|
+
raise ValueError("title must not be empty.")
|
|
57
|
+
title = title.strip()
|
|
58
|
+
if len(title) > MAX_ALBUM_TITLE_LENGTH:
|
|
59
|
+
raise ValueError(f"title must not be longer than {MAX_ALBUM_TITLE_LENGTH} characters.")
|
|
60
|
+
if not title.isprintable():
|
|
61
|
+
raise ValueError("title must not contain control characters.")
|
|
62
|
+
return title
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_playlist(filename: str, playlist_name: str | None = None) -> list[str]:
|
|
66
|
+
"""Entries of an m3u playlist: comments/directives (#EXTM3U, #EXTINF) and the playlist itself are skipped.
|
|
67
|
+
|
|
68
|
+
:raises OSError: if the playlist cannot be read.
|
|
69
|
+
"""
|
|
70
|
+
with open(filename, "rb") as infile:
|
|
71
|
+
raw = infile.read()
|
|
72
|
+
try:
|
|
73
|
+
content = raw.decode("utf-8-sig")
|
|
74
|
+
except UnicodeDecodeError:
|
|
75
|
+
content = raw.decode("cp1252", errors="replace") # playlists saved on windows
|
|
76
|
+
playlist_name = playlist_name or os.path.basename(filename)
|
|
77
|
+
entries = (line.strip() for line in content.splitlines())
|
|
78
|
+
# 'ls > playlist.m3u' lists the playlist itself
|
|
79
|
+
return [entry for entry in entries if entry and not entry.startswith("#") and entry != playlist_name]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ATarget(ABC):
|
|
83
|
+
"""Base class of all targets."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, target_id: str | int, target_type: str, logger: logging.Logger, update_json: UpdateJson) -> None:
|
|
86
|
+
self._logger = get_child(logger, f"{type(self).__name__}.{target_id}")
|
|
87
|
+
self.target_id: str | None = str(target_id)
|
|
88
|
+
self.target_type: TargetType | None = TargetType[target_type]
|
|
89
|
+
self._update_json = update_json
|
|
90
|
+
self.created: str | None = None
|
|
91
|
+
self.extra: dict[str, Any] = {} # fields of a newer version (extra_fields)
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def sanity_check(self) -> tuple[bool, str]:
|
|
95
|
+
"""(passed, error messages separated by newlines)"""
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def to_dict(self) -> dict[str, Any]: ...
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
def to_text(self, compact: bool = True) -> str: ...
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TargetAlbum(ATarget):
|
|
105
|
+
"""Entry of ``targets.json``::
|
|
106
|
+
|
|
107
|
+
{"target-id": "7", "target-type": "album", "album-folder": "Album A", "playlist": "playlist.m3u",
|
|
108
|
+
"created": "...", "title": "Album A (Hörspiel)", "cover": "album-1a2b3c4d.jpg"}
|
|
109
|
+
|
|
110
|
+
``album-folder`` is relative to the albums folder (``root_folder``). ``title`` is missing in entries that were
|
|
111
|
+
made before albums had one: their title comes from the tracks or the folder
|
|
112
|
+
(:meth:`tinyjukebox.control.CatalogControl.album_title`). ``cover`` is missing for an album without a cover of
|
|
113
|
+
its own; the image is kept in the images folder (:mod:`tinyjukebox.images`).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self, target_dict: dict[str, Any], root_folder: str, logger: logging.Logger, update_json: UpdateJson
|
|
118
|
+
) -> None:
|
|
119
|
+
for key in ("target-id", "target-type"):
|
|
120
|
+
if key not in target_dict:
|
|
121
|
+
logger.error("TargetAlbum.__init__ - '%s' not in dict: %s.", key, target_dict)
|
|
122
|
+
raise ValueError(f"TargetAlbum.__init__ - '{key}' not in dict: {target_dict}.")
|
|
123
|
+
super().__init__(target_dict["target-id"], target_dict["target-type"], logger, update_json)
|
|
124
|
+
self.root_folder: str | None = os.path.normpath(root_folder)
|
|
125
|
+
self.incomplete = False # files of the album are still being fetched from the library (tinyjukebox.sync)
|
|
126
|
+
self.load(target_dict)
|
|
127
|
+
self._logger.debug("__init__ - full_album_folder: %s", self.full_album_folder)
|
|
128
|
+
|
|
129
|
+
def load(self, target_dict: dict[str, Any]) -> None:
|
|
130
|
+
"""Take over id, folder, playlist, title, cover and time stamp of ``target_dict`` (an entry of
|
|
131
|
+
``targets.json``) - nothing is written."""
|
|
132
|
+
self.target_id = str(target_dict["target-id"])
|
|
133
|
+
self.extra = extra_fields(target_dict, _ALBUM_FIELDS)
|
|
134
|
+
title = target_dict.get("title")
|
|
135
|
+
self.title: str | None = title.strip() or None if isinstance(title, str) else None
|
|
136
|
+
self.cover: str | None = target_dict.get("cover") or None
|
|
137
|
+
self.album_folder: str | None = os.path.normpath(target_dict["album-folder"])
|
|
138
|
+
self.full_album_folder: str | None = os.path.join(str(self.root_folder), self.album_folder)
|
|
139
|
+
self.playlist: str | None = target_dict["playlist"]
|
|
140
|
+
self.created = target_dict.get("created") or now()
|
|
141
|
+
self._tracks: list[str] | None = None
|
|
142
|
+
self._tracks_of: tuple[int, int] | None = None # the state of the playlist that ``_tracks`` was read from
|
|
143
|
+
|
|
144
|
+
def set_title(self, title: object) -> None:
|
|
145
|
+
"""Change the title. :raises ValueError: for an invalid title - nothing is changed then."""
|
|
146
|
+
new_title = check_album_title(title)
|
|
147
|
+
if new_title == self.title:
|
|
148
|
+
return
|
|
149
|
+
self._logger.info("set_title - '%s' -> '%s'", self.title, new_title)
|
|
150
|
+
self.title = new_title
|
|
151
|
+
self._update_json()
|
|
152
|
+
|
|
153
|
+
def set_cover(self, cover: str | None) -> None:
|
|
154
|
+
"""``cover``: name of an image in the images folder, None for no cover of its own."""
|
|
155
|
+
if cover == self.cover:
|
|
156
|
+
return
|
|
157
|
+
self._logger.info("set_cover - '%s' -> '%s'", self.cover, cover)
|
|
158
|
+
self.cover = cover
|
|
159
|
+
self._update_json()
|
|
160
|
+
|
|
161
|
+
def get_tracks(self, force_update: bool = False) -> list[str]:
|
|
162
|
+
"""Paths of all tracks in playlist order. The playlist is read again whenever it changed - an album may get
|
|
163
|
+
its files after it was listed, e.g. from the library - and with ``force_update``."""
|
|
164
|
+
state = self._playlist_state()
|
|
165
|
+
if self._tracks is None or force_update or state != self._tracks_of:
|
|
166
|
+
self._tracks = self._read_playlist()
|
|
167
|
+
self._tracks_of = state
|
|
168
|
+
return self._tracks
|
|
169
|
+
|
|
170
|
+
def _playlist_state(self) -> tuple[int, int] | None:
|
|
171
|
+
"""Changes whenever the playlist is written; None without a playlist."""
|
|
172
|
+
if self.full_album_folder is None or self.playlist is None:
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
stat = os.stat(os.path.join(self.full_album_folder, self.playlist))
|
|
176
|
+
except OSError:
|
|
177
|
+
return None
|
|
178
|
+
return stat.st_mtime_ns, stat.st_size
|
|
179
|
+
|
|
180
|
+
def _read_playlist(self) -> list[str]:
|
|
181
|
+
if self.full_album_folder is None or self.playlist is None:
|
|
182
|
+
self._logger.warning("_read_playlist - full_album_folder or playlist is None")
|
|
183
|
+
return []
|
|
184
|
+
try:
|
|
185
|
+
entries = read_playlist(os.path.join(self.full_album_folder, self.playlist), self.playlist)
|
|
186
|
+
except OSError as e:
|
|
187
|
+
self._logger.warning("_read_playlist - failed to read playlist: %s", e)
|
|
188
|
+
return []
|
|
189
|
+
tracks = [os.path.join(self.full_album_folder, entry) for entry in entries]
|
|
190
|
+
self._logger.debug("_read_playlist - %s", tracks)
|
|
191
|
+
return tracks
|
|
192
|
+
|
|
193
|
+
def sanity_check(self) -> tuple[bool, str]:
|
|
194
|
+
fields_ok, fields_err = self.sanity_check_fields()
|
|
195
|
+
files_ok, files_err = self.sanity_check_files()
|
|
196
|
+
tracks_ok, tracks_err = self.sanity_check_tracks() if files_ok else (True, "")
|
|
197
|
+
if fields_ok and files_ok and tracks_ok:
|
|
198
|
+
return True, ""
|
|
199
|
+
errors = [
|
|
200
|
+
err for ok, err in ((fields_ok, fields_err), (files_ok, files_err), (tracks_ok, tracks_err)) if not ok
|
|
201
|
+
]
|
|
202
|
+
return False, "\n".join(errors)
|
|
203
|
+
|
|
204
|
+
def sanity_check_fields(self) -> tuple[bool, str]:
|
|
205
|
+
errors = []
|
|
206
|
+
if not is_valid_timestamp(self.created):
|
|
207
|
+
errors.append(f"sanity_check - created timestamp error '{self.created}'")
|
|
208
|
+
if self.target_type != TargetType.album:
|
|
209
|
+
value = self.target_type.name if isinstance(self.target_type, TargetType) else self.target_type
|
|
210
|
+
errors.append(f"sanity_check - target type not set or not expected value '{value}'")
|
|
211
|
+
if not self.target_id:
|
|
212
|
+
errors.append("sanity_check - target-id not set.")
|
|
213
|
+
for message in errors:
|
|
214
|
+
self._logger.warning(message)
|
|
215
|
+
return not errors, "\n".join(errors)
|
|
216
|
+
|
|
217
|
+
def sanity_check_files(self) -> tuple[bool, str]:
|
|
218
|
+
# plain path checks - glob would treat '[...]' in album or file names as patterns
|
|
219
|
+
folder = self.full_album_folder
|
|
220
|
+
if folder is None or not os.path.isdir(folder):
|
|
221
|
+
return False, f"sanity_check - full_album_folder '{folder}' not found."
|
|
222
|
+
if self.playlist is not None and not os.path.isfile(os.path.join(folder, self.playlist)):
|
|
223
|
+
return False, f"sanity_check - playlist not found in full_album_folder '{folder}'."
|
|
224
|
+
if not any(is_mp3_filename(name) for name in os.listdir(folder)):
|
|
225
|
+
return False, f"sanity_check - no mp3-files found in full_album_folder '{folder}'."
|
|
226
|
+
return True, ""
|
|
227
|
+
|
|
228
|
+
def sanity_check_tracks(self) -> tuple[bool, str]:
|
|
229
|
+
tracks = self.get_tracks()
|
|
230
|
+
if not tracks:
|
|
231
|
+
return False, f"sanity_check - playlist {self.playlist} contains no tracks."
|
|
232
|
+
errors = []
|
|
233
|
+
for track in tracks:
|
|
234
|
+
if not os.path.exists(track):
|
|
235
|
+
errors.append(f"sanity_check - file {track} not found.")
|
|
236
|
+
continue
|
|
237
|
+
try:
|
|
238
|
+
mp3_info(track)
|
|
239
|
+
except mutagen.MutagenError:
|
|
240
|
+
errors.append(f"sanity_check - file {track} not valid MP3.")
|
|
241
|
+
return not errors, "\n".join(errors)
|
|
242
|
+
|
|
243
|
+
def to_dict(self) -> dict[str, Any]:
|
|
244
|
+
result = {
|
|
245
|
+
"target-id": self.target_id,
|
|
246
|
+
"target-type": self.target_type.name if self.target_type is not None else None,
|
|
247
|
+
"created": self.created,
|
|
248
|
+
"album-folder": self.album_folder,
|
|
249
|
+
"playlist": self.playlist,
|
|
250
|
+
}
|
|
251
|
+
if self.title is not None:
|
|
252
|
+
result["title"] = self.title
|
|
253
|
+
if self.cover is not None:
|
|
254
|
+
result["cover"] = self.cover
|
|
255
|
+
return {**result, **self.extra}
|
|
256
|
+
|
|
257
|
+
def to_text(self, compact: bool = True) -> str:
|
|
258
|
+
target_type = self.target_type.name if self.target_type is not None else "None"
|
|
259
|
+
text = (
|
|
260
|
+
f"id: {self.target_id}, type: {target_type}, created: {self.created}, "
|
|
261
|
+
f"album-folder: {self.album_folder}, playlist: {self.playlist}"
|
|
262
|
+
)
|
|
263
|
+
if self.title is not None:
|
|
264
|
+
text += f", title: {self.title}"
|
|
265
|
+
if not compact:
|
|
266
|
+
tracks = [os.path.relpath(t, self.root_folder) if self.root_folder else t for t in self.get_tracks()]
|
|
267
|
+
text += f", track-count: {len(tracks)}, tracks: {tracks}"
|
|
268
|
+
return text
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def create_target(
|
|
272
|
+
cls, target_dict: dict[str, Any], root_folder: str, logger: logging.Logger, update_json: UpdateJson | None
|
|
273
|
+
) -> "TargetAlbum":
|
|
274
|
+
"""A new album - :raises ValueError: if the dict is not an album or the album fails the sanity check."""
|
|
275
|
+
if target_dict.get("target-type") != TargetType.album.name:
|
|
276
|
+
logger.error("TargetAlbum.create_target - wrong target type in %s.", target_dict)
|
|
277
|
+
raise ValueError(f"TargetAlbum.create_target - wrong target type in {target_dict}.")
|
|
278
|
+
if update_json is None:
|
|
279
|
+
logger.error("TargetAlbum.create_target - update_json method is None")
|
|
280
|
+
raise ValueError("TargetAlbum.create_target - update_json method is None")
|
|
281
|
+
if "title" in target_dict:
|
|
282
|
+
target_dict["title"] = check_album_title(target_dict["title"])
|
|
283
|
+
album = TargetAlbum(target_dict, root_folder, logger, update_json)
|
|
284
|
+
sane, err = album.sanity_check()
|
|
285
|
+
if not sane:
|
|
286
|
+
message = f"TargetAlbum.create_target - sanity check failed on dict '{target_dict}': {err}"
|
|
287
|
+
album._logger.error(message)
|
|
288
|
+
raise ValueError(message)
|
|
289
|
+
return album
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def check_station_name(name: object) -> str:
|
|
293
|
+
"""The name without surrounding blanks. :raises ValueError: if it is empty or too long."""
|
|
294
|
+
if not isinstance(name, str) or not name.strip():
|
|
295
|
+
raise ValueError("name must not be empty.")
|
|
296
|
+
if len(name.strip()) > MAX_STATION_NAME_LENGTH:
|
|
297
|
+
raise ValueError(f"name must not be longer than {MAX_STATION_NAME_LENGTH} characters.")
|
|
298
|
+
return name.strip()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def check_stream_url(url: object) -> str:
|
|
302
|
+
"""The url without surrounding blanks. :raises ValueError: if it is no http(s) url with a host."""
|
|
303
|
+
if not isinstance(url, str) or not url.strip():
|
|
304
|
+
raise ValueError("url must not be empty.")
|
|
305
|
+
url = url.strip()
|
|
306
|
+
if len(url) > MAX_STREAM_URL_LENGTH:
|
|
307
|
+
raise ValueError(f"url must not be longer than {MAX_STREAM_URL_LENGTH} characters.")
|
|
308
|
+
if any(c.isspace() or not c.isprintable() for c in url):
|
|
309
|
+
raise ValueError("url must not contain blanks or control characters.")
|
|
310
|
+
try:
|
|
311
|
+
parts = urllib.parse.urlsplit(url)
|
|
312
|
+
host = parts.hostname
|
|
313
|
+
except ValueError:
|
|
314
|
+
raise ValueError(f"invalid url '{url}'.") from None
|
|
315
|
+
if parts.scheme.lower() not in ("http", "https") or not host:
|
|
316
|
+
raise ValueError("url must start with http:// or https:// and contain a host.")
|
|
317
|
+
return url
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class TargetRadio(ATarget):
|
|
321
|
+
"""Entry of ``targets.json``::
|
|
322
|
+
|
|
323
|
+
{"target-id": "12", "target-type": "radio", "name": "FM4", "url": "https://...", "created": "...",
|
|
324
|
+
"cover": "station-1a2b3c4d.jpg"}
|
|
325
|
+
|
|
326
|
+
``url`` is the stream itself or an m3u/pls playlist that lists the stream (see
|
|
327
|
+
:func:`tinyjukebox.albumplayer.radioplayer.resolve_stream_url`). ``cover`` is missing for a station without a
|
|
328
|
+
cover; the image is kept in the images folder (:mod:`tinyjukebox.images`).
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
def __init__(self, target_dict: dict[str, Any], logger: logging.Logger, update_json: UpdateJson) -> None:
|
|
332
|
+
for key in ("target-id", "target-type"):
|
|
333
|
+
if key not in target_dict:
|
|
334
|
+
logger.error("TargetRadio.__init__ - '%s' not in dict: %s.", key, target_dict)
|
|
335
|
+
raise ValueError(f"TargetRadio.__init__ - '{key}' not in dict: {target_dict}.")
|
|
336
|
+
super().__init__(target_dict["target-id"], target_dict["target-type"], logger, update_json)
|
|
337
|
+
self.load(target_dict)
|
|
338
|
+
|
|
339
|
+
def load(self, target_dict: dict[str, Any]) -> None:
|
|
340
|
+
"""Take over id, name, url, cover and time stamp of ``target_dict`` (an entry of ``targets.json``) - nothing
|
|
341
|
+
is written."""
|
|
342
|
+
self.target_id = str(target_dict["target-id"])
|
|
343
|
+
self.extra = extra_fields(target_dict, _RADIO_FIELDS)
|
|
344
|
+
self.name: str = target_dict.get("name", "")
|
|
345
|
+
self.url: str = target_dict.get("url", "")
|
|
346
|
+
self.cover: str | None = target_dict.get("cover") or None
|
|
347
|
+
self.created = target_dict.get("created") or now()
|
|
348
|
+
|
|
349
|
+
def update(self, name: str | None = None, url: str | None = None) -> None:
|
|
350
|
+
"""Change name and/or url (``None``: unchanged). :raises ValueError: for invalid values - nothing is
|
|
351
|
+
changed then."""
|
|
352
|
+
new_name = self.name if name is None else check_station_name(name)
|
|
353
|
+
new_url = self.url if url is None else check_stream_url(url)
|
|
354
|
+
if (new_name, new_url) == (self.name, self.url):
|
|
355
|
+
return
|
|
356
|
+
self._logger.info("update - name: '%s' -> '%s', url: '%s' -> '%s'", self.name, new_name, self.url, new_url)
|
|
357
|
+
self.name, self.url = new_name, new_url
|
|
358
|
+
self._update_json()
|
|
359
|
+
|
|
360
|
+
def set_cover(self, cover: str | None) -> None:
|
|
361
|
+
"""``cover``: name of an image in the images folder, None for no cover."""
|
|
362
|
+
if cover == self.cover:
|
|
363
|
+
return
|
|
364
|
+
self._logger.info("set_cover - '%s' -> '%s'", self.cover, cover)
|
|
365
|
+
self.cover = cover
|
|
366
|
+
self._update_json()
|
|
367
|
+
|
|
368
|
+
def sanity_check(self) -> tuple[bool, str]:
|
|
369
|
+
errors = []
|
|
370
|
+
if not is_valid_timestamp(self.created):
|
|
371
|
+
errors.append(f"sanity_check - created timestamp error '{self.created}'")
|
|
372
|
+
if self.target_type != TargetType.radio:
|
|
373
|
+
value = self.target_type.name if isinstance(self.target_type, TargetType) else self.target_type
|
|
374
|
+
errors.append(f"sanity_check - target type not set or not expected value '{value}'")
|
|
375
|
+
if not self.target_id:
|
|
376
|
+
errors.append("sanity_check - target-id not set.")
|
|
377
|
+
for check, value in ((check_station_name, self.name), (check_stream_url, self.url)):
|
|
378
|
+
try:
|
|
379
|
+
check(value)
|
|
380
|
+
except ValueError as e:
|
|
381
|
+
errors.append(f"sanity_check - {e}")
|
|
382
|
+
for message in errors:
|
|
383
|
+
self._logger.warning(message)
|
|
384
|
+
return not errors, "\n".join(errors)
|
|
385
|
+
|
|
386
|
+
def to_dict(self) -> dict[str, Any]:
|
|
387
|
+
result = {
|
|
388
|
+
"target-id": self.target_id,
|
|
389
|
+
"target-type": self.target_type.name if self.target_type is not None else None,
|
|
390
|
+
"created": self.created,
|
|
391
|
+
"name": self.name,
|
|
392
|
+
"url": self.url,
|
|
393
|
+
}
|
|
394
|
+
if self.cover is not None:
|
|
395
|
+
result["cover"] = self.cover
|
|
396
|
+
return {**result, **self.extra}
|
|
397
|
+
|
|
398
|
+
def to_text(self, compact: bool = True) -> str:
|
|
399
|
+
target_type = self.target_type.name if self.target_type is not None else "None"
|
|
400
|
+
return f"id: {self.target_id}, type: {target_type}, created: {self.created}, name: {self.name}, url: {self.url}"
|
|
401
|
+
|
|
402
|
+
@classmethod
|
|
403
|
+
def create_target(
|
|
404
|
+
cls, target_dict: dict[str, Any], logger: logging.Logger, update_json: UpdateJson | None
|
|
405
|
+
) -> "TargetRadio":
|
|
406
|
+
"""A new radio station - :raises ValueError: if the dict is not a radio station or name/url are invalid."""
|
|
407
|
+
if target_dict.get("target-type") != TargetType.radio.name:
|
|
408
|
+
logger.error("TargetRadio.create_target - wrong target type in %s.", target_dict)
|
|
409
|
+
raise ValueError(f"TargetRadio.create_target - wrong target type in {target_dict}.")
|
|
410
|
+
if update_json is None:
|
|
411
|
+
logger.error("TargetRadio.create_target - update_json method is None")
|
|
412
|
+
raise ValueError("TargetRadio.create_target - update_json method is None")
|
|
413
|
+
target_dict = dict(
|
|
414
|
+
target_dict, name=check_station_name(target_dict.get("name")), url=check_stream_url(target_dict.get("url"))
|
|
415
|
+
)
|
|
416
|
+
return TargetRadio(target_dict, logger, update_json)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
SPOTIFY_KINDS = ("album", "playlist", "track", "episode", "show", "audiobook", "artist")
|
|
420
|
+
"""What an entry of Spotify can be: ``show`` is a podcast."""
|
|
421
|
+
SPOTIFY_HOSTS = ("open.spotify.com", "play.spotify.com")
|
|
422
|
+
_SPOTIFY_ID = re.compile(r"^[A-Za-z0-9]{1,64}$")
|
|
423
|
+
SPOTIFY_URI = re.compile(rf"^spotify:({'|'.join(SPOTIFY_KINDS)}):[A-Za-z0-9]{{1,64}}$")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def check_spotify_link(link: object) -> str:
|
|
427
|
+
"""The address ``spotify:<kind>:<id>`` of a link of Spotify: ``https://open.spotify.com/<kind>/<id>`` as the
|
|
428
|
+
Spotify app shares it (also with ``intl-de/``, ``embed/`` or ``?si=…``) or ``spotify:<kind>:<id>``.
|
|
429
|
+
|
|
430
|
+
:raises ValueError: for anything else.
|
|
431
|
+
"""
|
|
432
|
+
if not isinstance(link, str) or not link.strip():
|
|
433
|
+
raise ValueError("link must not be empty.")
|
|
434
|
+
link = link.strip()
|
|
435
|
+
if link.lower().startswith("spotify:"):
|
|
436
|
+
parts = link.split(":")
|
|
437
|
+
else:
|
|
438
|
+
try:
|
|
439
|
+
split = urllib.parse.urlsplit(link)
|
|
440
|
+
except ValueError:
|
|
441
|
+
raise ValueError(f"invalid link '{link}'.") from None
|
|
442
|
+
if split.scheme.lower() not in ("http", "https") or (split.hostname or "").lower() not in SPOTIFY_HOSTS:
|
|
443
|
+
raise ValueError("the link must be one of open.spotify.com, as the Spotify app shares it.")
|
|
444
|
+
path = [part for part in split.path.split("/") if part and part != "embed" and not part.startswith("intl-")]
|
|
445
|
+
parts = ["spotify", *path]
|
|
446
|
+
if len(parts) != 3 or parts[1].lower() not in SPOTIFY_KINDS or not _SPOTIFY_ID.match(parts[2]):
|
|
447
|
+
raise ValueError(f"'{link}' is no album, playlist, track, episode, podcast, audiobook or artist of Spotify.")
|
|
448
|
+
return f"spotify:{parts[1].lower()}:{parts[2]}"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def spotify_kind(uri: str) -> str:
|
|
452
|
+
"""The kind of an address ``spotify:<kind>:<id>`` (``show``: a podcast)."""
|
|
453
|
+
return uri.split(":")[1]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def spotify_link(uri: str) -> str:
|
|
457
|
+
"""The link of Spotify for the address ``spotify:<kind>:<id>``."""
|
|
458
|
+
_, kind, spotify_id = uri.split(":")
|
|
459
|
+
return f"https://open.spotify.com/{kind}/{spotify_id}"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class TargetSpotify(ATarget):
|
|
463
|
+
"""Entry of ``targets.json``::
|
|
464
|
+
|
|
465
|
+
{"target-id": "14", "target-type": "spotify", "name": "Die drei ???", "uri": "spotify:playlist:...",
|
|
466
|
+
"created": "...", "cover": "spotify-1a2b3c4d.jpg"}
|
|
467
|
+
|
|
468
|
+
``uri`` is the address that Spotify plays (:func:`check_spotify_link`). ``cover`` is missing for an entry without a
|
|
469
|
+
cover; the image is kept in the images folder (:mod:`tinyjukebox.images`).
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
def __init__(self, target_dict: dict[str, Any], logger: logging.Logger, update_json: UpdateJson) -> None:
|
|
473
|
+
for key in ("target-id", "target-type"):
|
|
474
|
+
if key not in target_dict:
|
|
475
|
+
logger.error("TargetSpotify.__init__ - '%s' not in dict: %s.", key, target_dict)
|
|
476
|
+
raise ValueError(f"TargetSpotify.__init__ - '{key}' not in dict: {target_dict}.")
|
|
477
|
+
super().__init__(target_dict["target-id"], target_dict["target-type"], logger, update_json)
|
|
478
|
+
self.load(target_dict)
|
|
479
|
+
|
|
480
|
+
def load(self, target_dict: dict[str, Any]) -> None:
|
|
481
|
+
"""Take over id, name, uri, cover and time stamp of ``target_dict`` (an entry of ``targets.json``) - nothing
|
|
482
|
+
is written."""
|
|
483
|
+
self.target_id = str(target_dict["target-id"])
|
|
484
|
+
self.extra = extra_fields(target_dict, _SPOTIFY_FIELDS)
|
|
485
|
+
self.name: str = target_dict.get("name", "")
|
|
486
|
+
self.uri: str = target_dict.get("uri", "")
|
|
487
|
+
self.cover: str | None = target_dict.get("cover") or None
|
|
488
|
+
self.created = target_dict.get("created") or now()
|
|
489
|
+
|
|
490
|
+
@property
|
|
491
|
+
def kind(self) -> str:
|
|
492
|
+
return spotify_kind(self.uri) if SPOTIFY_URI.match(self.uri) else ""
|
|
493
|
+
|
|
494
|
+
def update(self, name: str | None = None, uri: str | None = None) -> None:
|
|
495
|
+
"""Change name and/or address (``None``: unchanged). :raises ValueError: for invalid values - nothing is
|
|
496
|
+
changed then."""
|
|
497
|
+
new_name = self.name if name is None else check_station_name(name)
|
|
498
|
+
new_uri = self.uri if uri is None else check_spotify_link(uri)
|
|
499
|
+
if (new_name, new_uri) == (self.name, self.uri):
|
|
500
|
+
return
|
|
501
|
+
self._logger.info("update - name: '%s' -> '%s', uri: '%s' -> '%s'", self.name, new_name, self.uri, new_uri)
|
|
502
|
+
self.name, self.uri = new_name, new_uri
|
|
503
|
+
self._update_json()
|
|
504
|
+
|
|
505
|
+
def set_cover(self, cover: str | None) -> None:
|
|
506
|
+
"""``cover``: name of an image in the images folder, None for no cover."""
|
|
507
|
+
if cover == self.cover:
|
|
508
|
+
return
|
|
509
|
+
self._logger.info("set_cover - '%s' -> '%s'", self.cover, cover)
|
|
510
|
+
self.cover = cover
|
|
511
|
+
self._update_json()
|
|
512
|
+
|
|
513
|
+
def sanity_check(self) -> tuple[bool, str]:
|
|
514
|
+
errors = []
|
|
515
|
+
if not is_valid_timestamp(self.created):
|
|
516
|
+
errors.append(f"sanity_check - created timestamp error '{self.created}'")
|
|
517
|
+
if self.target_type != TargetType.spotify:
|
|
518
|
+
value = self.target_type.name if isinstance(self.target_type, TargetType) else self.target_type
|
|
519
|
+
errors.append(f"sanity_check - target type not set or not expected value '{value}'")
|
|
520
|
+
if not self.target_id:
|
|
521
|
+
errors.append("sanity_check - target-id not set.")
|
|
522
|
+
try:
|
|
523
|
+
check_station_name(self.name)
|
|
524
|
+
except ValueError as e:
|
|
525
|
+
errors.append(f"sanity_check - {e}")
|
|
526
|
+
if not SPOTIFY_URI.match(self.uri):
|
|
527
|
+
errors.append(f"sanity_check - '{self.uri}' is no address of Spotify.")
|
|
528
|
+
for message in errors:
|
|
529
|
+
self._logger.warning(message)
|
|
530
|
+
return not errors, "\n".join(errors)
|
|
531
|
+
|
|
532
|
+
def to_dict(self) -> dict[str, Any]:
|
|
533
|
+
result = {
|
|
534
|
+
"target-id": self.target_id,
|
|
535
|
+
"target-type": self.target_type.name if self.target_type is not None else None,
|
|
536
|
+
"created": self.created,
|
|
537
|
+
"name": self.name,
|
|
538
|
+
"uri": self.uri,
|
|
539
|
+
}
|
|
540
|
+
if self.cover is not None:
|
|
541
|
+
result["cover"] = self.cover
|
|
542
|
+
return {**result, **self.extra}
|
|
543
|
+
|
|
544
|
+
def to_text(self, compact: bool = True) -> str:
|
|
545
|
+
target_type = self.target_type.name if self.target_type is not None else "None"
|
|
546
|
+
return f"id: {self.target_id}, type: {target_type}, created: {self.created}, name: {self.name}, uri: {self.uri}"
|
|
547
|
+
|
|
548
|
+
@classmethod
|
|
549
|
+
def create_target(
|
|
550
|
+
cls, target_dict: dict[str, Any], logger: logging.Logger, update_json: UpdateJson | None
|
|
551
|
+
) -> "TargetSpotify":
|
|
552
|
+
"""A new entry - :raises ValueError: if the dict is not an entry of Spotify or name/uri are invalid."""
|
|
553
|
+
if target_dict.get("target-type") != TargetType.spotify.name:
|
|
554
|
+
logger.error("TargetSpotify.create_target - wrong target type in %s.", target_dict)
|
|
555
|
+
raise ValueError(f"TargetSpotify.create_target - wrong target type in {target_dict}.")
|
|
556
|
+
if update_json is None:
|
|
557
|
+
logger.error("TargetSpotify.create_target - update_json method is None")
|
|
558
|
+
raise ValueError("TargetSpotify.create_target - update_json method is None")
|
|
559
|
+
target_dict = dict(
|
|
560
|
+
target_dict,
|
|
561
|
+
name=check_station_name(target_dict.get("name")),
|
|
562
|
+
uri=check_spotify_link(target_dict.get("uri")),
|
|
563
|
+
)
|
|
564
|
+
return TargetSpotify(target_dict, logger, update_json)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
Target = TargetAlbum | TargetRadio | TargetSpotify
|
|
568
|
+
"""Every target that can be linked to a tag."""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Album folders: find new albums in the albums folder, add them to the targets, order playlists."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from tinyjukebox.core.logging import get_child
|
|
10
|
+
from tinyjukebox.data.common import TargetType
|
|
11
|
+
from tinyjukebox.data.target import TargetAlbum
|
|
12
|
+
from tinyjukebox.data.targets import Targets
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"add_album_to_targets",
|
|
16
|
+
"add_new_albums_to_targets",
|
|
17
|
+
"natural_key",
|
|
18
|
+
"playlist_order",
|
|
19
|
+
"scan_for_album_folders",
|
|
20
|
+
"scan_for_new_albums",
|
|
21
|
+
"write_playlist",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def scan_for_album_folders(logger: logging.Logger, rootfolder: str, playlistfilename: str) -> list[str]:
|
|
26
|
+
"""All folders below ``rootfolder`` that contain a playlist - relative to ``rootfolder``, sorted."""
|
|
27
|
+
my_logger = get_child(logger, "scan_for_albums")
|
|
28
|
+
root = Path(rootfolder)
|
|
29
|
+
# hidden folders hold what is not an album yet: uploads in progress, albums that boxes are sending
|
|
30
|
+
folders = (path.parent.relative_to(root) for path in root.rglob(playlistfilename))
|
|
31
|
+
albums = sorted(str(folder) for folder in folders if not any(part.startswith((".", "@")) for part in folder.parts))
|
|
32
|
+
my_logger.info("rootfolder: %s, playlist: %s, found album folders: %s", rootfolder, playlistfilename, albums)
|
|
33
|
+
return albums
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def scan_for_new_albums(targets: Targets, logger: logging.Logger, rootfolder: str, playlistfilename: str) -> list[str]:
|
|
37
|
+
"""Album folders that are not yet known as target."""
|
|
38
|
+
my_logger = get_child(logger, "scan_for_new_albums")
|
|
39
|
+
existing_folders = {album.album_folder for album in targets.albums()}
|
|
40
|
+
new_albums = sorted(set(scan_for_album_folders(logger, rootfolder, playlistfilename)) - existing_folders)
|
|
41
|
+
my_logger.info("known albums: %s, new albums: %s", sorted(f for f in existing_folders if f), new_albums)
|
|
42
|
+
return new_albums
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def add_album_to_targets(
|
|
46
|
+
targets: Targets, logger: logging.Logger, folder: str, playlistfilename: str, title: str | None = None
|
|
47
|
+
) -> TargetAlbum:
|
|
48
|
+
""":raises ValueError: if the album is not valid (see :meth:`TargetAlbum.create_target`)."""
|
|
49
|
+
get_child(logger, "add_album_to_targets").info("adding album in folder '%s'", folder)
|
|
50
|
+
target = {"target-type": TargetType.album.name, "album-folder": folder, "playlist": playlistfilename}
|
|
51
|
+
if title is not None:
|
|
52
|
+
target["title"] = title
|
|
53
|
+
album = targets.add_target(target)
|
|
54
|
+
assert isinstance(album, TargetAlbum) # noqa: S101 - created from an album dict
|
|
55
|
+
return album
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def add_new_albums_to_targets(
|
|
59
|
+
targets: Targets, logger: logging.Logger, rootfolder: str, playlistfilename: str
|
|
60
|
+
) -> list[TargetAlbum]:
|
|
61
|
+
my_logger = get_child(logger, "add_new_albums_to_targets")
|
|
62
|
+
return [
|
|
63
|
+
add_album_to_targets(targets, my_logger, folder, playlistfilename)
|
|
64
|
+
for folder in scan_for_new_albums(targets, my_logger, rootfolder, playlistfilename)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def natural_key(text: str) -> list[int | str]:
|
|
69
|
+
"""Sort key for file names: '2' before '10'."""
|
|
70
|
+
return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", text)]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def playlist_order(files: list[dict[str, Any]]) -> list[str]:
|
|
74
|
+
"""Order of the tracks (``files``: track infos, see :func:`tinyjukebox.metadata.read_track`).
|
|
75
|
+
|
|
76
|
+
By disc/track number if every file has a track number and the numbers are unique - otherwise by file name
|
|
77
|
+
(natural order).
|
|
78
|
+
"""
|
|
79
|
+
numbers = [(f["discnumber"] or 1, f["tracknumber"]) for f in files]
|
|
80
|
+
if files and all(n[1] is not None for n in numbers) and len(set(numbers)) == len(numbers):
|
|
81
|
+
return [f["filename"] for f in sorted(files, key=lambda f: (f["discnumber"] or 1, f["tracknumber"]))]
|
|
82
|
+
return sorted((f["filename"] for f in files), key=natural_key)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def write_playlist(folder: str, playlist_filename: str, tracks: list[str]) -> None:
|
|
86
|
+
"""Write the m3u playlist (one file name per line)."""
|
|
87
|
+
with open(os.path.join(folder, playlist_filename), "w", encoding="utf-8") as f:
|
|
88
|
+
f.write("".join(name + "\n" for name in tracks))
|