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,173 @@
|
|
|
1
|
+
"""Fetch a backup of a box or of the library to this computer::
|
|
2
|
+
|
|
3
|
+
tinyjukebox-backup https://nas:8866 --user admin # into the current folder
|
|
4
|
+
tinyjukebox-backup https://tinyjukebox.local --user admin --albums ~/backups
|
|
5
|
+
|
|
6
|
+
or set the folders in which the library - or a box without a library - keeps the backups that it makes by itself,
|
|
7
|
+
the one for the backups without the albums and the one for those with them::
|
|
8
|
+
|
|
9
|
+
tinyjukebox-backup https://nas:8866 --user admin --library-folder /volume1/tinyjukebox/backups
|
|
10
|
+
tinyjukebox-backup https://nas:8866 --user admin --library-folder-albums /volume2/backups-with-mp3s
|
|
11
|
+
|
|
12
|
+
or put a backup back - it replaces all data of the box or of the library::
|
|
13
|
+
|
|
14
|
+
tinyjukebox-backup https://nas:8866 --user admin --restore tinyjukebox-library-20261010-030000.tar.gz
|
|
15
|
+
|
|
16
|
+
Without ``--albums`` the backup holds everything but the audio files of the albums and is small; with it, it holds
|
|
17
|
+
them as well and is as large as all albums together. Nothing secret is part of a backup. The file is read to its end
|
|
18
|
+
before it gets its name: a backup that was cut off on the way is not kept. What a backup contains and how it is put
|
|
19
|
+
back: docs/developer/operations.md, section 10.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import contextlib
|
|
24
|
+
import getpass
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from collections.abc import Callable, Sequence
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from tinyjukebox.backup import NAME, PART, Incomplete, verify
|
|
31
|
+
from tinyjukebox.sync.client import Client, HttpError, Unreachable, WrongCertificate
|
|
32
|
+
from tinyjukebox.tools.fill_library import _client
|
|
33
|
+
|
|
34
|
+
__all__ = ["fetch", "main", "restore", "set_library_folder"]
|
|
35
|
+
|
|
36
|
+
KEPT = ("/api/library/backups", "/api/backups") # the schedules of the library, and of a box without a library
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch(client: Client, folder: str, albums: bool) -> tuple[str, dict[str, Any]]:
|
|
40
|
+
"""Fetch a backup into ``folder``; returns its file and what it says about itself
|
|
41
|
+
(:func:`tinyjukebox.backup.verify`). ``client`` is logged in as an admin.
|
|
42
|
+
|
|
43
|
+
:raises Incomplete: if what arrived is no complete backup - nothing is kept then.
|
|
44
|
+
"""
|
|
45
|
+
part = os.path.join(folder, f".tinyjukebox-backup{PART}")
|
|
46
|
+
with contextlib.suppress(FileNotFoundError):
|
|
47
|
+
os.remove(part) # the client would continue a file that is there
|
|
48
|
+
try:
|
|
49
|
+
answer = client.request("GET", "/api/backup" + ("?albums=1" if albums else ""), sink=part)
|
|
50
|
+
name = (answer.headers.get_filename() or "").strip()
|
|
51
|
+
if NAME.fullmatch(name) is None:
|
|
52
|
+
raise Incomplete(f"the answer names no backup: {name!r}")
|
|
53
|
+
about = verify(part)
|
|
54
|
+
target = os.path.join(folder, name)
|
|
55
|
+
os.replace(part, target)
|
|
56
|
+
finally:
|
|
57
|
+
with contextlib.suppress(FileNotFoundError):
|
|
58
|
+
os.remove(part)
|
|
59
|
+
return target, about
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def restore(client: Client, filename: str, confirm: Callable[[dict[str, Any]], bool] | None = None) -> dict[str, Any]:
|
|
63
|
+
"""Put the backup in ``filename`` back on a box or on the library: it is sent and unpacked there beside the data,
|
|
64
|
+
read to its end. ``confirm(status)`` decides then (None: yes); a backup that is not confirmed is discarded there.
|
|
65
|
+
Returns the status of the restore: ``confirmed`` - the other side starts again with the data of the backup.
|
|
66
|
+
|
|
67
|
+
:raises HttpError: if the other side refuses the backup, e.g. one of the other kind or one that was cut off.
|
|
68
|
+
"""
|
|
69
|
+
status: dict[str, Any] = client.request("PUT", "/api/restore", upload=filename).json()
|
|
70
|
+
if confirm is not None and not confirm(status):
|
|
71
|
+
return dict(client.delete("/api/restore"))
|
|
72
|
+
return dict(client.post("/api/restore/confirm"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ask(status: dict[str, Any]) -> bool:
|
|
76
|
+
about = status["about"]
|
|
77
|
+
_say(f"This replaces all data of the {status['kind']} by those of the backup of {about['created']}.")
|
|
78
|
+
if about.get("albums"):
|
|
79
|
+
_say("With MP3s: all albums are deleted and replaced.")
|
|
80
|
+
if status.get("library"):
|
|
81
|
+
_say("This box works with a library: at its next synchronization the box gets the state of the library again.")
|
|
82
|
+
_say("No backup is made before. Make one first if the data of now may be needed again.")
|
|
83
|
+
return input("Put it back? [y/N] ").strip().lower() == "y"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _say(text: str) -> None:
|
|
87
|
+
print(text, flush=True)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def set_library_folder(client: Client, folder: str, albums: bool = False) -> str:
|
|
91
|
+
"""Set the folder in which the library keeps the backups that it makes by itself - those without the albums or,
|
|
92
|
+
with ``albums``, those with them; returns the folder that counts now. ``client`` is logged in as an admin of the
|
|
93
|
+
library, or of a box that keeps backups.
|
|
94
|
+
|
|
95
|
+
:raises HttpError: if the folder cannot be written to, or if nothing keeps backups there.
|
|
96
|
+
"""
|
|
97
|
+
kind = "albums" if albums else "plain"
|
|
98
|
+
for path in KEPT:
|
|
99
|
+
try:
|
|
100
|
+
return str(client.put(path, {kind: {"folder": folder}})[kind]["folder"])
|
|
101
|
+
except HttpError as error:
|
|
102
|
+
if error.status != 404:
|
|
103
|
+
raise
|
|
104
|
+
raise HttpError(404, "no backups are kept there: it is neither a library nor a box of this version")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
108
|
+
"""Console script ``tinyjukebox-backup``."""
|
|
109
|
+
parser = argparse.ArgumentParser(description="Fetch a backup of a box or of the library to this computer.")
|
|
110
|
+
parser.add_argument("address", help="address of the box or of the library, e.g. https://nas:8866")
|
|
111
|
+
parser.add_argument("folder", nargs="?", default=".", help="where the backup is put (default: the current folder)")
|
|
112
|
+
parser.add_argument("--albums", action="store_true", help="with the audio files of the albums")
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--library-folder",
|
|
115
|
+
metavar="FOLDER",
|
|
116
|
+
help="fetch nothing: set the folder on the computer of the library in which it keeps the backups without "
|
|
117
|
+
"the albums that it makes by itself ('' for the one of its config)",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--library-folder-albums",
|
|
121
|
+
metavar="FOLDER",
|
|
122
|
+
help="fetch nothing: the same for the backups with the albums",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--restore",
|
|
126
|
+
metavar="FILE",
|
|
127
|
+
help="fetch nothing: put this backup back - it replaces all data of the box or of the library, which starts "
|
|
128
|
+
"again",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument("--yes", action="store_true", help="with --restore: do not ask before the data is replaced")
|
|
131
|
+
parser.add_argument("--user", required=True, help="an admin of the web page")
|
|
132
|
+
parser.add_argument("--password-file", help="file with the password (default: asked for)")
|
|
133
|
+
parser.add_argument("--fingerprint", help="sha256 fingerprint of the certificate (else: shown and asked)")
|
|
134
|
+
arguments = parser.parse_args(argv)
|
|
135
|
+
folder = os.path.expanduser(arguments.folder)
|
|
136
|
+
folders = [(arguments.library_folder, False), (arguments.library_folder_albums, True)]
|
|
137
|
+
folders = [(kept, albums) for kept, albums in folders if kept is not None]
|
|
138
|
+
if arguments.restore is not None and not os.path.isfile(os.path.expanduser(arguments.restore)):
|
|
139
|
+
sys.exit(f"'{arguments.restore}' is no file")
|
|
140
|
+
if not folders and arguments.restore is None and not os.path.isdir(folder):
|
|
141
|
+
sys.exit(f"'{folder}' is no folder")
|
|
142
|
+
if arguments.password_file:
|
|
143
|
+
with open(arguments.password_file, encoding="utf-8") as infile:
|
|
144
|
+
password = infile.read().strip()
|
|
145
|
+
else:
|
|
146
|
+
password = getpass.getpass(f"Password of {arguments.user}: ")
|
|
147
|
+
try:
|
|
148
|
+
client = _client(arguments.address.rstrip("/"), arguments.fingerprint, True, "the box or library")
|
|
149
|
+
token = client.post("/api/login", {"username": arguments.user, "password": password})["token"]
|
|
150
|
+
client.headers["Authorization"] = f"Bearer {token}"
|
|
151
|
+
if arguments.restore is not None:
|
|
152
|
+
_say("sending the backup - it is read to its end there and unpacked beside the data")
|
|
153
|
+
status = restore(client, os.path.expanduser(arguments.restore), None if arguments.yes else _ask)
|
|
154
|
+
_say(
|
|
155
|
+
"put back: it starts again with the data of the backup"
|
|
156
|
+
if status["state"] == "confirmed"
|
|
157
|
+
else "not put back"
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
for kept, albums in folders:
|
|
161
|
+
what = "its backups with the albums" if albums else "its backups"
|
|
162
|
+
print(f"the library keeps {what} in {set_library_folder(client, kept, albums)}")
|
|
163
|
+
if folders:
|
|
164
|
+
return
|
|
165
|
+
target, about = fetch(client, folder, arguments.albums)
|
|
166
|
+
except (Unreachable, WrongCertificate, HttpError, Incomplete, OSError) as error:
|
|
167
|
+
sys.exit(f"no backup: {error}")
|
|
168
|
+
print(
|
|
169
|
+
f"{target}: backup of a {about['kind']} (version {about['version']}), {about['files']} files, "
|
|
170
|
+
f"{about['bytes']} bytes"
|
|
171
|
+
)
|
|
172
|
+
for name in about["skipped"]:
|
|
173
|
+
print(f" left out, it disappeared while it was read: {name}")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Fill the library from what is at hand on this computer (docs/developer/library.md, section 3.5)::
|
|
2
|
+
|
|
3
|
+
tinyjukebox-fill-library https://nas:8866 --user admin --json ~/tinyjukebox/json --albums ~/tinyjukebox/albums \\
|
|
4
|
+
--images ~/tinyjukebox/json/images
|
|
5
|
+
|
|
6
|
+
Tags, albums with their files, stations and their covers are taken over under their ids, so that a box that has the
|
|
7
|
+
same data and the library agree from the start. The command can be repeated: what is there already is skipped. The
|
|
8
|
+
positions come from the boxes themselves.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import getpass
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from tinyjukebox.images import is_cover
|
|
19
|
+
from tinyjukebox.sync import handshake
|
|
20
|
+
from tinyjukebox.sync import manifest as manifests
|
|
21
|
+
from tinyjukebox.sync.client import Client, HttpError, Unreachable, WrongCertificate
|
|
22
|
+
from tinyjukebox.sync.files import _quoted
|
|
23
|
+
from tinyjukebox.sync.util import read_json
|
|
24
|
+
|
|
25
|
+
__all__ = ["fill", "main"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _say(text: str) -> None:
|
|
29
|
+
print(text)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _client(url: str, fingerprint: str | None, confirm: bool, server: str = "the library") -> Client:
|
|
33
|
+
"""A client that accepts the certificate of the library only. The library has a self-signed certificate, so
|
|
34
|
+
its fingerprint is compared with the one that is expected - or, like ssh, shown and asked for. ``server`` names
|
|
35
|
+
the other side in the texts, for a command that also talks to a box."""
|
|
36
|
+
probe = Client(url)
|
|
37
|
+
probe.request("GET", "/favicon.svg")
|
|
38
|
+
certificate = probe.peer_certificate or ""
|
|
39
|
+
found = handshake.fingerprint(certificate)
|
|
40
|
+
if fingerprint is not None:
|
|
41
|
+
if found.lower() != fingerprint.replace(":", "").lower():
|
|
42
|
+
raise WrongCertificate(f"{server} presents a certificate with the fingerprint {found}")
|
|
43
|
+
elif confirm:
|
|
44
|
+
_say(f"{server[:1].upper()}{server[1:]} presents a certificate with the sha256 fingerprint\n {found}")
|
|
45
|
+
hint = " (its log shows the fingerprint at the start)" if server == "the library" else ""
|
|
46
|
+
if input(f"Is this {server}{hint}? [y/N] ").strip().lower() != "y":
|
|
47
|
+
raise WrongCertificate("not confirmed")
|
|
48
|
+
return Client(url, certificate=certificate, timeout=60.0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fill(
|
|
52
|
+
client: Client, json_folder: str, albums_folder: str | None, images_folder: str | None, say: Any = _say
|
|
53
|
+
) -> dict[str, int]:
|
|
54
|
+
"""Send the catalog in ``json_folder``, the albums and the covers of the stations. ``client`` is logged in as an
|
|
55
|
+
admin."""
|
|
56
|
+
targets = read_json(os.path.join(json_folder, "targets.json"), {})
|
|
57
|
+
tags = read_json(os.path.join(json_folder, "tags.json"), {})
|
|
58
|
+
# a tag that is linked to nothing that exists is not linked
|
|
59
|
+
for tag in tags.values():
|
|
60
|
+
if str(tag.get("target", {}).get("id")) not in targets:
|
|
61
|
+
tag["target"] = {"type": tag.get("target", {}).get("type") or "album", "id": -1}
|
|
62
|
+
taken = client.post("/api/library/fill", {"targets": targets, "tags": tags})
|
|
63
|
+
counts = {name: len(keys) for name, keys in taken.items()}
|
|
64
|
+
say(f"taken over: {counts['targets']} targets, {counts['tags']} tags")
|
|
65
|
+
counts["files"] = counts["images"] = 0
|
|
66
|
+
for key, target in targets.items() if albums_folder else []:
|
|
67
|
+
if target.get("target-type") != "album":
|
|
68
|
+
continue
|
|
69
|
+
folder = os.path.join(albums_folder or "", target["album-folder"])
|
|
70
|
+
try:
|
|
71
|
+
manifest = manifests.create(folder, target["playlist"])
|
|
72
|
+
except OSError as error:
|
|
73
|
+
say(f"album {key} ({target['album-folder']}): cannot be read here, skipped ({error})")
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
lacks = client.post(f"/api/library/fill/albums/{key}", {"manifest": manifest})["lacks"]
|
|
77
|
+
except HttpError as error:
|
|
78
|
+
say(f"album {key} ({target['album-folder']}): {error}")
|
|
79
|
+
continue
|
|
80
|
+
for name in lacks:
|
|
81
|
+
client.request(
|
|
82
|
+
"PUT", f"/api/library/fill/albums/{key}/files/{_quoted(name)}", upload=manifests.path(folder, name)
|
|
83
|
+
)
|
|
84
|
+
counts["files"] += 1
|
|
85
|
+
say(f"album {key} ({target['album-folder']}): {len(lacks)} of {len(manifest['files'])} files sent")
|
|
86
|
+
if images_folder:
|
|
87
|
+
for name in sorted(os.listdir(images_folder)):
|
|
88
|
+
if is_cover(name):
|
|
89
|
+
filename = os.path.join(images_folder, name)
|
|
90
|
+
client.request("PUT", f"/api/library/fill/images/{_quoted(name)}", upload=filename)
|
|
91
|
+
counts["images"] += 1
|
|
92
|
+
say(f"{counts['images']} covers of stations sent; the library keeps the ones it lacked")
|
|
93
|
+
return counts
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
97
|
+
"""Console script ``tinyjukebox-fill-library``."""
|
|
98
|
+
parser = argparse.ArgumentParser(
|
|
99
|
+
description="Fill the library from the json files, albums and station covers here."
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument("library", help="address of the library, e.g. https://nas:8866")
|
|
102
|
+
parser.add_argument("--user", required=True, help="an admin of the page of the library")
|
|
103
|
+
parser.add_argument("--password-file", help="file with the password (default: asked for)")
|
|
104
|
+
parser.add_argument("--json", required=True, help="folder with tags.json and targets.json")
|
|
105
|
+
parser.add_argument("--albums", help="albums folder - album-folder entries of targets.json are relative to it")
|
|
106
|
+
parser.add_argument("--images", help="folder with the covers of the radio stations")
|
|
107
|
+
parser.add_argument("--fingerprint", help="sha256 fingerprint of the certificate of the library (else: asked)")
|
|
108
|
+
arguments = parser.parse_args(argv)
|
|
109
|
+
if arguments.password_file:
|
|
110
|
+
with open(arguments.password_file, encoding="utf-8") as infile:
|
|
111
|
+
password = infile.read().strip()
|
|
112
|
+
else:
|
|
113
|
+
password = getpass.getpass(f"Password of {arguments.user}: ")
|
|
114
|
+
try:
|
|
115
|
+
client = _client(arguments.library.rstrip("/"), arguments.fingerprint, confirm=True)
|
|
116
|
+
token = client.post("/api/login", {"username": arguments.user, "password": password})["token"]
|
|
117
|
+
client.headers["Authorization"] = f"Bearer {token}"
|
|
118
|
+
fill(client, arguments.json, arguments.albums, arguments.images)
|
|
119
|
+
except (Unreachable, WrongCertificate, HttpError) as error:
|
|
120
|
+
sys.exit(f"the library was not filled: {error}")
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""tinyjukebox-import-cards - cut the pictures of cards out of a PowerPoint file (several cards per slide) into a
|
|
2
|
+
folder.
|
|
3
|
+
|
|
4
|
+
tinyjukebox-import-cards cards.pptx OUTPUT-FOLDER --grid 2x2 --slides 1,3,5
|
|
5
|
+
|
|
6
|
+
Needs LibreOffice (pptx -> pdf) and pdftoppm (poppler-utils). Every grid cell that contains at least one picture
|
|
7
|
+
becomes a card image '<prefix><slide>-<cell>-<title>.jpg'; the texts on the card are stored as caption in
|
|
8
|
+
captions.json. Use --prefix to keep the cards of several files apart in one folder.
|
|
9
|
+
To make the images covers of albums, name them ``album-<id>.jpg`` and list them in captions.json
|
|
10
|
+
(``tinyjukebox-import-covers``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import zipfile
|
|
22
|
+
from collections.abc import Callable, Sequence
|
|
23
|
+
from typing import Any
|
|
24
|
+
from xml.etree import ElementTree
|
|
25
|
+
|
|
26
|
+
__all__ = ["cards_of_slide", "main", "read_presentation", "slug"]
|
|
27
|
+
|
|
28
|
+
NS = {
|
|
29
|
+
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
|
30
|
+
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
31
|
+
}
|
|
32
|
+
CAPTIONS_FILE = "captions.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _slide_names(archive: zipfile.ZipFile) -> list[str]:
|
|
36
|
+
"""Slide xml files in presentation order."""
|
|
37
|
+
names = [n for n in archive.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", n)]
|
|
38
|
+
return sorted(names, key=lambda n: int(re.sub(r"\D", "", n.rsplit("/", 1)[1])))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _xfrm(element: ElementTree.Element, properties: str) -> tuple[int, int, int, int, ElementTree.Element] | None:
|
|
42
|
+
"""(x, y, cx, cy, xfrm element) of a shape or None."""
|
|
43
|
+
xfrm = element.find(f"p:{properties}/a:xfrm", NS)
|
|
44
|
+
if xfrm is None:
|
|
45
|
+
return None
|
|
46
|
+
off, ext = xfrm.find("a:off", NS), xfrm.find("a:ext", NS)
|
|
47
|
+
if off is None or ext is None:
|
|
48
|
+
return None
|
|
49
|
+
return int(off.get("x", 0)), int(off.get("y", 0)), int(ext.get("cx", 0)), int(ext.get("cy", 0)), xfrm
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
Transform = Callable[[int, int, int, int], tuple[int, int, int, int]]
|
|
53
|
+
Shape = tuple[str, int, int, int, int, str]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _identity(x: int, y: int, cx: int, cy: int) -> tuple[int, int, int, int]:
|
|
57
|
+
return x, y, cx, cy
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _shapes(element: ElementTree.Element, transform: Transform = _identity) -> list[Shape]:
|
|
61
|
+
"""(kind, x, y, cx, cy, text) of all pictures and text shapes in slide coordinates - groups are resolved."""
|
|
62
|
+
result: list[Shape] = []
|
|
63
|
+
for child in element:
|
|
64
|
+
tag = child.tag.split("}")[1]
|
|
65
|
+
if tag == "grpSp":
|
|
66
|
+
position = _xfrm(child, "grpSpPr")
|
|
67
|
+
if position is None:
|
|
68
|
+
continue
|
|
69
|
+
x, y, cx, cy, xfrm = position
|
|
70
|
+
ch_off, ch_ext = xfrm.find("a:chOff", NS), xfrm.find("a:chExt", NS)
|
|
71
|
+
if ch_off is None or ch_ext is None:
|
|
72
|
+
continue
|
|
73
|
+
cox, coy = int(ch_off.get("x", 0)), int(ch_off.get("y", 0))
|
|
74
|
+
ccx, ccy = max(1, int(ch_ext.get("cx", 1))), max(1, int(ch_ext.get("cy", 1)))
|
|
75
|
+
|
|
76
|
+
def child_transform(
|
|
77
|
+
x2: int,
|
|
78
|
+
y2: int,
|
|
79
|
+
cx2: int,
|
|
80
|
+
cy2: int,
|
|
81
|
+
x: int = x,
|
|
82
|
+
y: int = y,
|
|
83
|
+
cx: int = cx,
|
|
84
|
+
cy: int = cy,
|
|
85
|
+
cox: int = cox,
|
|
86
|
+
coy: int = coy,
|
|
87
|
+
ccx: int = ccx,
|
|
88
|
+
ccy: int = ccy,
|
|
89
|
+
outer: Transform = transform,
|
|
90
|
+
) -> tuple[int, int, int, int]:
|
|
91
|
+
return outer(x + (x2 - cox) * cx // ccx, y + (y2 - coy) * cy // ccy, cx2 * cx // ccx, cy2 * cy // ccy)
|
|
92
|
+
|
|
93
|
+
result += _shapes(child, child_transform)
|
|
94
|
+
continue
|
|
95
|
+
if tag not in ("pic", "sp"):
|
|
96
|
+
continue
|
|
97
|
+
position = _xfrm(child, "spPr")
|
|
98
|
+
if position is None:
|
|
99
|
+
continue
|
|
100
|
+
x, y, cx, cy = transform(*position[:4])
|
|
101
|
+
if tag == "pic":
|
|
102
|
+
result.append(("pic", x, y, cx, cy, ""))
|
|
103
|
+
else:
|
|
104
|
+
paragraphs = ["".join(t.text or "" for t in p.findall(".//a:t", NS)) for p in child.findall(".//a:p", NS)]
|
|
105
|
+
text = " ".join(p.strip() for p in paragraphs if p.strip())
|
|
106
|
+
if text:
|
|
107
|
+
result.append(("text", x, y, cx, cy, text))
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_presentation(filename: str) -> tuple[int, int, list[list[Shape]]]:
|
|
112
|
+
"""Returns (slide width, slide height, [shapes of slide 1, shapes of slide 2, ...]) in EMU."""
|
|
113
|
+
with zipfile.ZipFile(filename) as archive:
|
|
114
|
+
presentation = ElementTree.fromstring(archive.read("ppt/presentation.xml")) # noqa: S314
|
|
115
|
+
size = presentation.find("p:sldSz", NS)
|
|
116
|
+
slides = []
|
|
117
|
+
for name in _slide_names(archive):
|
|
118
|
+
tree = ElementTree.fromstring(archive.read(name)) # noqa: S314
|
|
119
|
+
tree_root = tree.find(".//p:spTree", NS)
|
|
120
|
+
slides.append(_shapes(tree_root) if tree_root is not None else [])
|
|
121
|
+
if size is None:
|
|
122
|
+
raise SystemExit(f"'{filename}' has no slide size.")
|
|
123
|
+
return int(size.get("cx", 0)), int(size.get("cy", 0)), slides
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cards_of_slide(
|
|
127
|
+
shapes: list[Shape], width: int, height: int, columns: int, rows: int
|
|
128
|
+
) -> list[tuple[int, int, list[str]]]:
|
|
129
|
+
"""Grid cells with at least one picture: [(column, row, caption texts)] - a shape belongs to the cell of its
|
|
130
|
+
center. Texts of a single character (the cell letters of the template) are no captions."""
|
|
131
|
+
cells: dict[tuple[int, int], dict[str, Any]] = {}
|
|
132
|
+
for kind, x, y, cx, cy, text in shapes:
|
|
133
|
+
column = min(columns - 1, max(0, (x + cx // 2) * columns // width))
|
|
134
|
+
row = min(rows - 1, max(0, (y + cy // 2) * rows // height))
|
|
135
|
+
cell = cells.setdefault((column, row), {"pictures": 0, "texts": []})
|
|
136
|
+
if kind == "pic":
|
|
137
|
+
cell["pictures"] += 1
|
|
138
|
+
elif len(text) > 1:
|
|
139
|
+
cell["texts"].append(text)
|
|
140
|
+
return [
|
|
141
|
+
(c, r, cells[(c, r)]["texts"])
|
|
142
|
+
for r in range(rows)
|
|
143
|
+
for c in range(columns)
|
|
144
|
+
if (c, r) in cells and cells[(c, r)]["pictures"] > 0
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def slug(text: str, length: int = 40) -> str:
|
|
149
|
+
text = re.sub(r"[^\w]+", "-", text, flags=re.UNICODE).strip("-").lower()
|
|
150
|
+
return text[:length].strip("-") or "karte"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def convert_to_pdf(pptx: str, folder: str) -> str:
|
|
154
|
+
office = shutil.which("soffice") or shutil.which("libreoffice")
|
|
155
|
+
if office is None:
|
|
156
|
+
raise SystemExit("LibreOffice (soffice) not found.")
|
|
157
|
+
# snap packages cannot read hidden folders or /tmp - work on a copy in the output folder
|
|
158
|
+
copy = os.path.join(folder, "cards.pptx")
|
|
159
|
+
shutil.copy(pptx, copy)
|
|
160
|
+
subprocess.run(
|
|
161
|
+
[office, "--headless", "--convert-to", "pdf", "--outdir", folder, copy],
|
|
162
|
+
check=True,
|
|
163
|
+
stdout=subprocess.DEVNULL,
|
|
164
|
+
stderr=subprocess.DEVNULL,
|
|
165
|
+
)
|
|
166
|
+
os.remove(copy)
|
|
167
|
+
pdf = os.path.join(folder, "cards.pdf")
|
|
168
|
+
if not os.path.exists(pdf):
|
|
169
|
+
raise SystemExit(f"converting '{pptx}' to pdf failed.")
|
|
170
|
+
return pdf
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render_cell(
|
|
174
|
+
pdf: str,
|
|
175
|
+
slide: int,
|
|
176
|
+
column: int,
|
|
177
|
+
row: int,
|
|
178
|
+
columns: int,
|
|
179
|
+
rows: int,
|
|
180
|
+
width_px: int,
|
|
181
|
+
height_px: int,
|
|
182
|
+
dpi: int,
|
|
183
|
+
target: str,
|
|
184
|
+
) -> None:
|
|
185
|
+
cell_w, cell_h = width_px // columns, height_px // rows
|
|
186
|
+
base = target[:-4]
|
|
187
|
+
subprocess.run(
|
|
188
|
+
[
|
|
189
|
+
"pdftoppm",
|
|
190
|
+
"-jpeg",
|
|
191
|
+
"-jpegopt",
|
|
192
|
+
"quality=85",
|
|
193
|
+
"-r",
|
|
194
|
+
str(dpi),
|
|
195
|
+
"-f",
|
|
196
|
+
str(slide),
|
|
197
|
+
"-l",
|
|
198
|
+
str(slide),
|
|
199
|
+
"-x",
|
|
200
|
+
str(column * cell_w),
|
|
201
|
+
"-y",
|
|
202
|
+
str(row * cell_h),
|
|
203
|
+
"-W",
|
|
204
|
+
str(cell_w),
|
|
205
|
+
"-H",
|
|
206
|
+
str(cell_h),
|
|
207
|
+
"-singlefile",
|
|
208
|
+
pdf,
|
|
209
|
+
base,
|
|
210
|
+
],
|
|
211
|
+
check=True,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
216
|
+
parser = argparse.ArgumentParser(prog="tinyjukebox-import-cards", description=(__doc__ or "").split("\n\n")[0])
|
|
217
|
+
parser.add_argument("pptx", help="PowerPoint file with the card fronts")
|
|
218
|
+
parser.add_argument("folder", help="folder for the images")
|
|
219
|
+
parser.add_argument("--grid", default="2x2", help="cards per slide as COLUMNSxROWS (default: 2x2)")
|
|
220
|
+
parser.add_argument("--slides", help="comma separated slide numbers (default: every slide with pictures)")
|
|
221
|
+
parser.add_argument("--dpi", type=int, default=150, help="resolution of the card images (default: 150)")
|
|
222
|
+
parser.add_argument("--prefix", default="", help="start of the image names, e.g. 'b' for a second file")
|
|
223
|
+
args = parser.parse_args(argv)
|
|
224
|
+
|
|
225
|
+
if shutil.which("pdftoppm") is None:
|
|
226
|
+
raise SystemExit("pdftoppm not found - install poppler-utils.")
|
|
227
|
+
columns, rows = (int(v) for v in args.grid.lower().split("x"))
|
|
228
|
+
width, height, slides = read_presentation(args.pptx)
|
|
229
|
+
selected = [int(s) for s in args.slides.split(",")] if args.slides else list(range(1, len(slides) + 1))
|
|
230
|
+
if any(not 1 <= number <= len(slides) for number in selected):
|
|
231
|
+
raise SystemExit(f"slide numbers must be between 1 and {len(slides)}.")
|
|
232
|
+
if columns * rows > 26 or min(columns, rows) < 1:
|
|
233
|
+
raise SystemExit("the grid must have 1 to 26 cells.")
|
|
234
|
+
if not re.fullmatch(r"[\w-]*", args.prefix):
|
|
235
|
+
raise SystemExit("the prefix may only contain letters, digits, '-' and '_'.")
|
|
236
|
+
os.makedirs(args.folder, exist_ok=True)
|
|
237
|
+
captions_file = os.path.join(args.folder, CAPTIONS_FILE)
|
|
238
|
+
try:
|
|
239
|
+
with open(captions_file, encoding="UTF-8") as f:
|
|
240
|
+
captions = json.load(f)
|
|
241
|
+
except (FileNotFoundError, ValueError):
|
|
242
|
+
captions = {}
|
|
243
|
+
|
|
244
|
+
width_px = round(width / 914400 * args.dpi)
|
|
245
|
+
height_px = round(height / 914400 * args.dpi)
|
|
246
|
+
with tempfile.TemporaryDirectory(dir=args.folder) as work:
|
|
247
|
+
pdf = convert_to_pdf(args.pptx, work)
|
|
248
|
+
count = 0
|
|
249
|
+
for number in selected:
|
|
250
|
+
for column, row, texts in cards_of_slide(slides[number - 1], width, height, columns, rows):
|
|
251
|
+
cell = "abcdefghijklmnopqrstuvwxyz"[row * columns + column]
|
|
252
|
+
name = f"{args.prefix}{number:02d}-{cell}-{slug(texts[0] if texts else 'karte')}.jpg"
|
|
253
|
+
render_cell(
|
|
254
|
+
pdf,
|
|
255
|
+
number,
|
|
256
|
+
column,
|
|
257
|
+
row,
|
|
258
|
+
columns,
|
|
259
|
+
rows,
|
|
260
|
+
width_px,
|
|
261
|
+
height_px,
|
|
262
|
+
args.dpi,
|
|
263
|
+
os.path.join(args.folder, name),
|
|
264
|
+
)
|
|
265
|
+
captions[name] = " · ".join(texts)
|
|
266
|
+
count += 1
|
|
267
|
+
print(f"{name} {captions[name]}")
|
|
268
|
+
with open(captions_file, "w", encoding="UTF-8") as f:
|
|
269
|
+
json.dump(captions, f, indent=2, sort_keys=True, ensure_ascii=False)
|
|
270
|
+
print(f"{count} card images in '{args.folder}'.", file=sys.stderr)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
main()
|