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.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. 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()