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,138 @@
1
+ """Set the titles and the covers of the albums and the covers of the radio stations of a box or of the library from
2
+ a folder on this computer::
3
+
4
+ tinyjukebox-import-covers https://tinyjukebox.local --user admin /path/to/covers
5
+
6
+ The folder holds the images and ``captions.json``, which names the image and the title of an album under its id::
7
+
8
+ {"14": {"image": "album-14.jpg", "title": "Artist - Album (2010)"}}
9
+
10
+ An entry may leave out ``image`` or ``title``. The cover of a radio station is the image that is named after its id:
11
+ ``radio-115.jpg`` (or ``.jpeg``, ``.png``, ``.webp``).
12
+
13
+ The command shows what it is going to change and asks before it does. It can be repeated: a title that is the same
14
+ as in ``captions.json`` is skipped, every other one is replaced.
15
+ """
16
+
17
+ import argparse
18
+ import getpass
19
+ import os
20
+ import re
21
+ import sys
22
+ from collections.abc import Sequence
23
+ from typing import Any
24
+
25
+ from tinyjukebox.sync.client import Client, HttpError, Unreachable, WrongCertificate
26
+ from tinyjukebox.sync.util import read_json
27
+ from tinyjukebox.tools.fill_library import _client
28
+
29
+ __all__ = ["CAPTIONS_FILE", "import_covers", "main", "plan"]
30
+
31
+ CAPTIONS_FILE = "captions.json"
32
+ STATION_COVER = re.compile(r"radio-(.+)\.(jpe?g|png|webp)", re.IGNORECASE)
33
+
34
+ Step = tuple[str, str, str | None, str | None]
35
+ """("album" or "radio", id, new title or None, image file or None)"""
36
+ NAMES = {"album": "album", "radio": "radio station"}
37
+
38
+
39
+ def _say(text: str) -> None:
40
+ print(text)
41
+
42
+
43
+ def plan(
44
+ folder: str, albums: dict[str, dict[str, Any]], say: Any = _say, radios: dict[str, dict[str, Any]] | None = None
45
+ ) -> list[Step]:
46
+ """What is to be done: the steps for the albums of ``captions.json`` in ``folder`` that are there (``albums``:
47
+ the albums of the box or library by id), then for the images ``radio-<id>`` of the stations that are there
48
+ (``radios``: the stations by id). What cannot be done is said and left out.
49
+
50
+ :raises ValueError: if ``captions.json`` is missing or not of the form above.
51
+ """
52
+ captions = read_json(os.path.join(folder, CAPTIONS_FILE), None)
53
+ if not isinstance(captions, dict) or not all(isinstance(entry, dict) for entry in captions.values()):
54
+ raise ValueError(f"{os.path.join(folder, CAPTIONS_FILE)} must map album ids to their image and title")
55
+ steps: list[Step] = []
56
+ for album_id, entry in captions.items():
57
+ album = albums.get(album_id)
58
+ if album is None:
59
+ say(f"album {album_id}: not there - skipped")
60
+ continue
61
+ title, image = entry.get("title"), entry.get("image")
62
+ filename = os.path.join(folder, str(image)) if image else None
63
+ if filename is not None and (os.path.basename(str(image)) != image or not os.path.isfile(filename)):
64
+ say(f"album {album_id}: image '{image}' not found in the folder - the image is skipped")
65
+ filename = None
66
+ new_title = title.strip() if isinstance(title, str) and title.strip() != album["title"] else None
67
+ if new_title or filename:
68
+ steps.append(("album", album_id, new_title or None, filename))
69
+ for name in sorted(os.listdir(folder)):
70
+ match = STATION_COVER.fullmatch(name)
71
+ if match is None:
72
+ continue
73
+ if match.group(1) not in (radios or {}):
74
+ say(f"radio station {match.group(1)}: not there - skipped")
75
+ continue
76
+ steps.append(("radio", match.group(1), None, os.path.join(folder, name)))
77
+ return steps
78
+
79
+
80
+ def import_covers(client: Client, folder: str, confirm: Any = None, say: Any = _say) -> dict[str, int]:
81
+ """Set titles and covers as :func:`plan` says. ``confirm(steps)`` may refuse (returns False): nothing is changed
82
+ then. Returns how many titles and covers were set and how many albums and stations failed."""
83
+ albums = {album["id"]: album for album in client.get("/api/albums")}
84
+ radios = {radio["id"]: radio for radio in client.get("/api/radios")}
85
+ steps = plan(folder, albums, say, radios)
86
+ counts = {"titles": 0, "covers": 0, "failed": 0}
87
+ if not steps or (confirm is not None and not confirm(steps)):
88
+ return counts
89
+ for kind, target_id, title, filename in steps:
90
+ try:
91
+ if title is not None:
92
+ client.request("PATCH", f"/api/albums/{target_id}", {"title": title})
93
+ counts["titles"] += 1
94
+ if filename is not None:
95
+ client.request("PUT", f"/api/{kind}s/{target_id}/cover", upload=filename)
96
+ counts["covers"] += 1
97
+ except HttpError as error:
98
+ say(f"{NAMES[kind]} {target_id}: failed - {error}")
99
+ counts["failed"] += 1
100
+ return counts
101
+
102
+
103
+ def _ask(steps: list[Step], shown: int = 200) -> bool:
104
+ for kind, target_id, title, filename in steps[:shown]:
105
+ changes = [f"title '{title}'" if title else "", f"image {os.path.basename(filename)}" if filename else ""]
106
+ _say(f"{NAMES[kind]} {target_id}: " + ", ".join(change for change in changes if change))
107
+ if len(steps) > shown:
108
+ _say(f"... and {len(steps) - shown} more")
109
+ return input(f"Change these {len(steps)} albums and radio stations? [y/N] ").strip().lower() == "y"
110
+
111
+
112
+ def main(argv: Sequence[str] | None = None) -> None:
113
+ """Console script ``tinyjukebox-import-covers``."""
114
+ parser = argparse.ArgumentParser(
115
+ description="Set titles and covers of albums and covers of radio stations from a folder with captions.json."
116
+ )
117
+ parser.add_argument("address", help="address of the box or of the library, e.g. https://tinyjukebox.local")
118
+ parser.add_argument("folder", help=f"folder with the images and {CAPTIONS_FILE}")
119
+ parser.add_argument("--user", required=True, help="an admin of the web page")
120
+ parser.add_argument("--password-file", help="file with the password (default: asked for)")
121
+ parser.add_argument("--fingerprint", help="sha256 fingerprint of the certificate (else: shown and asked)")
122
+ parser.add_argument("--yes", action="store_true", help="do not ask before anything is changed")
123
+ arguments = parser.parse_args(argv)
124
+ if arguments.password_file:
125
+ with open(arguments.password_file, encoding="utf-8") as infile:
126
+ password = infile.read().strip()
127
+ else:
128
+ password = getpass.getpass(f"Password of {arguments.user}: ")
129
+ try:
130
+ client = _client(arguments.address.rstrip("/"), arguments.fingerprint, True, "the box or library")
131
+ token = client.post("/api/login", {"username": arguments.user, "password": password})["token"]
132
+ client.headers["Authorization"] = f"Bearer {token}"
133
+ counts = import_covers(client, os.path.expanduser(arguments.folder), None if arguments.yes else _ask)
134
+ except (Unreachable, WrongCertificate, HttpError, ValueError) as error:
135
+ sys.exit(f"nothing more was changed: {error}")
136
+ _say(f"{counts['titles']} titles and {counts['covers']} covers set, {counts['failed']} failed")
137
+ if counts["failed"]:
138
+ sys.exit(1)
@@ -0,0 +1,151 @@
1
+ """tinyjukebox-migrate-01 - convert the json files of the old box (version 0.1) into the current format.
2
+
3
+ tinyjukebox-migrate-01 /path/to/old-card/json /srv/tinyjukebox/json
4
+
5
+ Reads albums.json and tags.json from the old folder and writes targets.json, tags.json and an empty activity.json into
6
+ the new folder. The albums themselves are not touched - copy the albums folder separately.
7
+
8
+ old albums.json: "45": {"folder": "/srv/tinyjukebox/albums/Album A/", "id": "45", "playlist": "playlist.m3u"}
9
+ new targets.json: "45": {"target-id": "45", "target-type": "album", "album-folder": "Album A",
10
+ "playlist": "playlist.m3u", "created": "..."}
11
+
12
+ The old format does not know when an album was added: 'created' is the date of the oldest tag linked to the album (or
13
+ now, for albums without tag). The positions of the old activity.json are not taken over - every album starts at its
14
+ beginning.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ from collections.abc import Sequence
22
+ from typing import Any
23
+
24
+ import jsonschema
25
+
26
+ from tinyjukebox.data.common import TargetType, is_valid_timestamp, now
27
+ from tinyjukebox.schema.activities import schema as activities_schema
28
+ from tinyjukebox.schema.tags import schema as tags_schema
29
+ from tinyjukebox.schema.targets import schema as targets_schema
30
+
31
+ __all__ = ["convert_albums", "main"]
32
+
33
+ OLD_ALBUMS_FOLDER = "/srv/tinyjukebox/albums"
34
+ ALBUMS_FILE, TAGS_FILE, TARGETS_FILE, ACTIVITIES_FILE = "albums.json", "tags.json", "targets.json", "activity.json"
35
+
36
+
37
+ def _load(filename: str) -> dict[str, Any]:
38
+ try:
39
+ with open(filename, encoding="utf-8") as f:
40
+ data = json.load(f)
41
+ except (OSError, ValueError) as e:
42
+ raise SystemExit(f"cannot read '{filename}': {e}") from e
43
+ if not isinstance(data, dict):
44
+ raise SystemExit(f"'{filename}' must contain a json object.")
45
+ return data
46
+
47
+
48
+ def _created_by_album(tags: dict[str, Any]) -> dict[str, str]:
49
+ """Album id -> 'created' of the oldest tag linked to it (the timestamps sort like their dates)."""
50
+ created: dict[str, str] = {}
51
+ for tag in tags.values():
52
+ target = tag.get("target", {})
53
+ stamp = tag.get("created")
54
+ if target.get("type") != TargetType.album.name or not is_valid_timestamp(stamp):
55
+ continue
56
+ album_id = str(target.get("id"))
57
+ if album_id not in created or stamp < created[album_id]:
58
+ created[album_id] = stamp
59
+ return created
60
+
61
+
62
+ def convert_albums(albums: dict[str, Any], tags: dict[str, Any], old_albums_folder: str) -> dict[str, Any]:
63
+ """The targets of the old ``albums`` - their folders become relative to ``old_albums_folder``.
64
+
65
+ :raises ValueError: if an entry cannot be converted (all problems in one message).
66
+ """
67
+ root = os.path.normpath(old_albums_folder)
68
+ created = _created_by_album(tags)
69
+ default_created = now()
70
+ targets: dict[str, Any] = {}
71
+ problems = []
72
+ for key, album in albums.items():
73
+ if not isinstance(album, dict) or not all(isinstance(album.get(k), str) for k in ("folder", "playlist")):
74
+ problems.append(f"album '{key}': 'folder' and 'playlist' are required.")
75
+ continue
76
+ if str(album.get("id", key)) != key:
77
+ problems.append(f"album '{key}': its 'id' is '{album['id']}'.")
78
+ continue
79
+ folder = os.path.normpath(album["folder"])
80
+ if os.path.commonpath([root, folder]) != root or folder == root:
81
+ problems.append(f"album '{key}': folder '{album['folder']}' is not inside '{old_albums_folder}'.")
82
+ continue
83
+ targets[key] = {
84
+ "target-id": key,
85
+ "target-type": TargetType.album.name,
86
+ "album-folder": os.path.relpath(folder, root),
87
+ "playlist": album["playlist"],
88
+ "created": created.get(key, default_created),
89
+ }
90
+ if problems:
91
+ raise ValueError("\n".join(problems))
92
+ return targets
93
+
94
+
95
+ def _write(filename: str, data: dict[str, Any]) -> None:
96
+ with open(filename, "w", encoding="utf-8") as f:
97
+ json.dump(data, f, indent=4)
98
+ f.flush()
99
+ os.fsync(f.fileno())
100
+
101
+
102
+ def _validate(name: str, data: dict[str, Any], schema: dict[str, Any]) -> None:
103
+ try:
104
+ jsonschema.validate(data, schema)
105
+ except jsonschema.ValidationError as e:
106
+ raise SystemExit(f"{name}: {e.message} (at {'/'.join(str(p) for p in e.absolute_path)})") from e
107
+
108
+
109
+ def main(argv: Sequence[str] | None = None) -> None:
110
+ parser = argparse.ArgumentParser(
111
+ prog="tinyjukebox-migrate-01", description="Convert the json files of the old box (version 0.1)."
112
+ )
113
+ parser.add_argument("old", help=f"folder with {ALBUMS_FILE} and {TAGS_FILE} of the old box")
114
+ parser.add_argument("new", help=f"folder for {TARGETS_FILE}, {TAGS_FILE} and {ACTIVITIES_FILE} (json folder)")
115
+ parser.add_argument(
116
+ "--old-albums-folder",
117
+ default=OLD_ALBUMS_FOLDER,
118
+ help=f"albums folder of the old box - the album folders become relative to it (default: {OLD_ALBUMS_FOLDER})",
119
+ )
120
+ parser.add_argument("--force", action="store_true", help="overwrite existing files in the new folder")
121
+ args = parser.parse_args(argv)
122
+
123
+ albums = _load(os.path.join(args.old, ALBUMS_FILE))
124
+ tags = _load(os.path.join(args.old, TAGS_FILE))
125
+ _validate(TAGS_FILE, tags, tags_schema) # the format of the tags did not change
126
+ try:
127
+ targets = convert_albums(albums, tags, args.old_albums_folder)
128
+ except ValueError as e:
129
+ raise SystemExit(str(e)) from e
130
+ activities: dict[str, Any] = {}
131
+ _validate(TARGETS_FILE, targets, targets_schema)
132
+ _validate(ACTIVITIES_FILE, activities, activities_schema)
133
+ output = {TARGETS_FILE: targets, TAGS_FILE: tags, ACTIVITIES_FILE: activities}
134
+
135
+ os.makedirs(args.new, exist_ok=True)
136
+ existing = [name for name in output if os.path.exists(os.path.join(args.new, name))]
137
+ if existing and not args.force:
138
+ raise SystemExit(f"'{args.new}' already contains {', '.join(existing)} - use --force to overwrite.")
139
+ for name, data in output.items():
140
+ _write(os.path.join(args.new, name), data)
141
+
142
+ linked = {str(tag["target"].get("id")) for tag in tags.values() if tag["target"].get("type")}
143
+ for album_id in sorted(linked - set(targets), key=str):
144
+ print(f"warning: a tag is linked to album '{album_id}', which does not exist.", file=sys.stderr)
145
+ for album_id in sorted(set(targets) - linked, key=lambda i: targets[i]["album-folder"]):
146
+ print(f"album without tag: {album_id} {targets[album_id]['album-folder']}")
147
+ print(f"{len(targets)} albums, {len(tags)} tags written to '{args.new}'.", file=sys.stderr)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,355 @@
1
+ """Volume: limits and steps (VolumeManager) of the speaker and the headset (Volumes) and the mixer that applies it
2
+ (ALSA, virtual or the bluetooth driver)."""
3
+
4
+ import collections
5
+ import importlib
6
+ import logging
7
+ import select
8
+ import threading
9
+ import time
10
+ from collections.abc import Callable, Mapping
11
+ from enum import StrEnum
12
+ from typing import Any, Protocol
13
+
14
+ from tinyjukebox.core.logging import get_child
15
+ from tinyjukebox.core.threads import LoggerThread
16
+ from tinyjukebox.data.volumelimits import VolumeLimits
17
+
18
+ __all__ = [
19
+ "HOLD_STEP",
20
+ "AlsaVolumeControl",
21
+ "VirtualVolumeControl",
22
+ "VolumeControl",
23
+ "VolumeManager",
24
+ "VolumeProfile",
25
+ "Volumes",
26
+ "check_limits",
27
+ ]
28
+
29
+ WATCH_POLL_MS = 500 # the watch thread checks for stop at least this often
30
+ WATCH_RETRY = 5.0 # seconds until the watch thread opens the mixer again after an error
31
+ OWN_CHANGE_WINDOW = 1.0 # seconds in which a reported volume that VolumeManager has set itself is ignored
32
+
33
+
34
+ class VolumeControl(Protocol):
35
+ def set_volume(self, volume: float) -> object:
36
+ """Set the volume in % (0-100)."""
37
+
38
+
39
+ class AlsaVolumeControl:
40
+ """Sets the volume of an ALSA mixer control (e.g. 'Master', 'PCM', 'Headphone') of the default card - or of the
41
+ mixer ``device`` (e.g. the one of BlueALSA for a headset) - with pyalsaaudio, in the same scale as
42
+ ``amixer sset <control> N%`` (linear over the raw range of the control).
43
+
44
+ pyalsaaudio is imported on first use: it is only installed on the Pi. Its thread safety is not documented - the
45
+ lock serializes the calls of state machines, rest api and command line. If the mixer fails (e.g. sound card
46
+ re-plugged), it is created once more before the error is logged.
47
+
48
+ :meth:`watch` reports the volume of the control whenever it changes - also changes made outside tinyjukebox, e.g.
49
+ with alsamixer or (later) by a bluetooth headset.
50
+ """
51
+
52
+ def __init__(
53
+ self, control: str, logger: logging.Logger, device: str | None = None, watch_retry: float | None = None
54
+ ) -> None:
55
+ """``watch_retry``: seconds until :meth:`watch` opens a failing mixer again (default: :data:`WATCH_RETRY`)."""
56
+ self._logger = get_child(logger, type(self).__name__)
57
+ self._control = control
58
+ self._device = device
59
+ self._watch_retry = watch_retry
60
+ self._lock = threading.Lock()
61
+ self._mixer: Any = None
62
+ self._watch_stop = threading.Event()
63
+ self._watch_thread: LoggerThread | None = None
64
+
65
+ def set_volume(self, volume: float) -> bool:
66
+ """Set the volume in % - False if that failed (logged)."""
67
+ with self._lock:
68
+ try:
69
+ alsaaudio = importlib.import_module("alsaaudio")
70
+ except ImportError:
71
+ self._logger.error("set_volume - pyalsaaudio not installed (only on the pi, needs libasound2-dev).")
72
+ return False
73
+ for attempt in (1, 2):
74
+ try:
75
+ if self._mixer is None:
76
+ self._mixer = self._open(alsaaudio)
77
+ self._mixer.setvolume(round(volume))
78
+ return True
79
+ except alsaaudio.ALSAAudioError as e:
80
+ self._mixer = None
81
+ if attempt == 2:
82
+ self._logger.error("set_volume - '%s' %s%% failed: %s", self._control, round(volume), e)
83
+ return False
84
+
85
+ def _open(self, alsaaudio: Any) -> Any:
86
+ """A new mixer of the control. :raises alsaaudio.ALSAAudioError: if it does not exist."""
87
+ if self._device is None:
88
+ return alsaaudio.Mixer(self._control)
89
+ return alsaaudio.Mixer(self._control, device=self._device)
90
+
91
+ def watch(self, callback: Callable[[float], None]) -> None:
92
+ """Call ``callback`` with the volume (mean of the channels, %) whenever the control changes - in the watch
93
+ thread, also for the changes made with :meth:`set_volume`."""
94
+ if self._watch_thread is not None:
95
+ return
96
+ self._watch_stop.clear()
97
+ self._watch_thread = LoggerThread(
98
+ target=self._watch, args=(callback,), name="AlsaVolumeControl.watch", logger=self._logger, daemon=True
99
+ )
100
+ self._watch_thread.start()
101
+
102
+ def stop_watching(self) -> None:
103
+ self._watch_stop.set()
104
+ if self._watch_thread is not None:
105
+ self._watch_thread.join()
106
+ self._watch_thread = None
107
+
108
+ def _watch(self, callback: Callable[[float], None]) -> None:
109
+ try:
110
+ alsaaudio = importlib.import_module("alsaaudio")
111
+ except ImportError:
112
+ self._logger.error("watch - pyalsaaudio not installed (only on the pi, needs libasound2-dev).")
113
+ return
114
+ retry = WATCH_RETRY if self._watch_retry is None else self._watch_retry
115
+ failure: str | None = None
116
+ while not self._watch_stop.is_set():
117
+ try:
118
+ # a mixer of its own: alsa delivers the events to the handle that is polled
119
+ mixer = self._open(alsaaudio)
120
+ try:
121
+ poller = select.poll()
122
+ for fd, eventmask in mixer.polldescriptors():
123
+ # the mixer of BlueALSA reports -1 for a descriptor that is unused at the moment - poll(2)
124
+ # ignores those, register() raises
125
+ if fd >= 0:
126
+ poller.register(fd, eventmask)
127
+ failure = None
128
+ while not self._watch_stop.is_set():
129
+ if poller.poll(WATCH_POLL_MS) and mixer.handleevents():
130
+ callback(self._read_volume(alsaaudio))
131
+ finally:
132
+ mixer.close()
133
+ except alsaaudio.ALSAAudioError as e:
134
+ # the same reason is reported once - a mixer may be missing for a while (headset not ready yet)
135
+ level = logging.DEBUG if str(e) == failure else logging.WARNING
136
+ failure = str(e)
137
+ self._logger.log(level, "watch - mixer '%s' failed: %s - retrying in %s s", self._control, e, retry)
138
+ self._watch_stop.wait(retry)
139
+
140
+ def _read_volume(self, alsaaudio: Any) -> float:
141
+ """The volume (mean of the channels, %), read with a mixer of its own - never with the watched one.
142
+
143
+ pyalsaaudio looks the control up for every call without checking the result. Once the watched mixer has
144
+ handled the removal of the control (headset disconnected - and perhaps connected again at once, e.g. when
145
+ bluetoothd restarts), its getvolume() aborts the whole process in alsa-lib (assertion 'elem' in
146
+ snd_mixer_selem_has_playback_channel). A new mixer finds the control when it is created - or raises - and
147
+ handles no events until it is closed.
148
+
149
+ :raises alsaaudio.ALSAAudioError: if the control is gone.
150
+ """
151
+ mixer = self._open(alsaaudio)
152
+ try:
153
+ volumes: list[int] = mixer.getvolume()
154
+ finally:
155
+ mixer.close()
156
+ return sum(volumes) / len(volumes)
157
+
158
+
159
+ class VirtualVolumeControl:
160
+ """Only remembers the volume - for running without hardware (--no-hardware): the mixer of the pc stays as it is."""
161
+
162
+ def __init__(self, logger: logging.Logger) -> None:
163
+ self._logger = get_child(logger, type(self).__name__)
164
+ self.volume: float | None = None
165
+
166
+ def set_volume(self, volume: float) -> None:
167
+ self.volume = volume
168
+ self._logger.info("set_volume - virtual volume %s%%", round(volume))
169
+
170
+
171
+ HOLD_STEP = 1.0
172
+ """Change of the volume in % per hold event of a volume button unless the config (``volume-step-hold``) says
173
+ otherwise - as ``volumecontrol.py`` of the old box."""
174
+
175
+
176
+ class VolumeManager:
177
+ """Current volume between 0 and ``max-volume``; changed by buttons, web page and command line concurrently.
178
+ A button press changes it by ``volume-step``, every hold event of a button by ``volume-step-hold``.
179
+
180
+ The lock is held while the mixer is set - never from a pubsub handler directly.
181
+
182
+ :meth:`mixer_changed` takes over volumes that were set outside tinyjukebox; ``max-volume`` stays a limit for them.
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ volume_control: VolumeControl | None,
188
+ config: Mapping[str, Any],
189
+ logger: logging.Logger,
190
+ logger_name: str | None = None,
191
+ ) -> None:
192
+ self._logger = get_child(logger, logger_name or type(self).__name__)
193
+ self._volume_control = volume_control
194
+ self._max_volume: float = config["max-volume"]
195
+ self._default_volume: float = config["default-volume"]
196
+ self._volume_step: float = config["volume-step"]
197
+ self._hold_step: float = config.get("volume-step-hold", HOLD_STEP)
198
+ self._lock = threading.RLock() # the mixer may report a change while it is set
199
+ self._current_volume: float = min(self._default_volume, self._max_volume)
200
+ self._own_changes: collections.deque[tuple[float, int]] = collections.deque(maxlen=50) # (time, volume)
201
+ self._logger.info(
202
+ "__init__ - default %s, max %s, step %s", self._default_volume, self._max_volume, self._volume_step
203
+ )
204
+ self.update_volume()
205
+
206
+ @property
207
+ def default_volume(self) -> float:
208
+ return self._default_volume
209
+
210
+ @property
211
+ def max_volume(self) -> float:
212
+ return self._max_volume
213
+
214
+ @property
215
+ def volume_step(self) -> float:
216
+ return self._volume_step
217
+
218
+ def set_limits(self, default_volume: float, max_volume: float) -> None:
219
+ """New start value and maximum (see :func:`check_limits`) - a current volume above the new maximum is lowered
220
+ at once, the start value applies from the next start (:meth:`reset`)."""
221
+ check_limits(default_volume, max_volume)
222
+ with self._lock:
223
+ self._logger.info("set_limits - default %s, max %s", default_volume, max_volume)
224
+ self._default_volume, self._max_volume = default_volume, max_volume
225
+ if self._current_volume > max_volume:
226
+ self._apply(max_volume)
227
+
228
+ def reset(self) -> None:
229
+ """Back to the start value (e.g. whenever the headset connects)."""
230
+ with self._lock:
231
+ self._apply(self._default_volume)
232
+
233
+ def update_volume(self) -> None:
234
+ """Apply the current volume (limited to 0..max-volume) to the mixer."""
235
+ with self._lock:
236
+ self._apply(self._current_volume)
237
+
238
+ def _apply(self, volume: float) -> None:
239
+ self._current_volume = min(max(volume, 0), self._max_volume)
240
+ self._own_changes.append((time.monotonic(), round(self._current_volume)))
241
+ self._logger.info("volume %s", self._current_volume)
242
+ if self._volume_control is not None:
243
+ self._volume_control.set_volume(self._current_volume)
244
+
245
+ def get_volume(self) -> float:
246
+ return self._current_volume
247
+
248
+ def mixer_changed(self, volume: float) -> None:
249
+ """The mixer reports ``volume`` (%): volumes set outside tinyjukebox become the current volume; above
250
+ ``max-volume`` the mixer is set back to it. The reports of the own changes are ignored - also late ones, which
251
+ would otherwise undo a quick second button press for a moment."""
252
+ with self._lock:
253
+ now = time.monotonic()
254
+ if any(now - at < OWN_CHANGE_WINDOW and own == round(volume) for at, own in self._own_changes):
255
+ return
256
+ if volume > self._max_volume:
257
+ self._logger.warning("volume %s%% set outside tinyjukebox - limited to max-volume", round(volume))
258
+ self._apply(self._max_volume)
259
+ elif round(volume) != round(self._current_volume):
260
+ self._logger.info("volume %s%% set outside tinyjukebox", round(volume))
261
+ self._current_volume = volume
262
+
263
+ def set_volume(self, volume: float) -> None:
264
+ with self._lock:
265
+ self._apply(volume)
266
+
267
+ def volume_up(self, held: bool = False) -> None:
268
+ """One step up - ``held``: the small step of a button that is kept pressed."""
269
+ with self._lock:
270
+ self._apply(self._current_volume + (self._hold_step if held else self._volume_step))
271
+
272
+ def volume_down(self, held: bool = False) -> None:
273
+ with self._lock:
274
+ self._apply(self._current_volume - (self._hold_step if held else self._volume_step))
275
+
276
+ def volume_off(self) -> None:
277
+ with self._lock:
278
+ self._apply(0)
279
+
280
+
281
+ def _number(value: object) -> float:
282
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
283
+ raise ValueError("start value and maximum must be numbers.")
284
+ return value
285
+
286
+
287
+ def check_limits(default_volume: object, max_volume: object) -> tuple[float, float]:
288
+ """(start value, maximum). :raises ValueError: unless they are numbers with 0 <= start value <= maximum <= 100."""
289
+ start, maximum = _number(default_volume), _number(max_volume)
290
+ if not 0 <= start <= maximum <= 100:
291
+ raise ValueError("0 <= start value <= maximum <= 100 is required.")
292
+ return start, maximum
293
+
294
+
295
+ class VolumeProfile(StrEnum):
296
+ """Which volume the buttons change - value: name in the rest api, the command line and ``volume.json``."""
297
+
298
+ SPEAKER = "speaker"
299
+ HEADPHONES = "headphones"
300
+
301
+
302
+ class Volumes:
303
+ """The volumes of speaker and headset - buttons, web page and command line change the active one (the headset's
304
+ while it is connected). Start value and maximum of both can be changed at runtime; they are stored in
305
+ :class:`~tinyjukebox.data.volumelimits.VolumeLimits` and override the config from then on."""
306
+
307
+ def __init__(
308
+ self, speaker: VolumeManager, headphones: VolumeManager, limits: VolumeLimits | None, logger: logging.Logger
309
+ ) -> None:
310
+ self._logger = get_child(logger, type(self).__name__)
311
+ self._managers = {VolumeProfile.SPEAKER: speaker, VolumeProfile.HEADPHONES: headphones}
312
+ self._limits = limits
313
+ self._active = VolumeProfile.SPEAKER
314
+ self._lock = threading.Lock() # changes of the limits are stored one after the other
315
+
316
+ @property
317
+ def active(self) -> VolumeProfile:
318
+ return self._active
319
+
320
+ def manager(self, profile: VolumeProfile | None = None) -> VolumeManager:
321
+ """The volume of ``profile`` (default: the active one)."""
322
+ return self._managers[profile or self._active]
323
+
324
+ def use(self, profile: VolumeProfile) -> None:
325
+ if profile != self._active:
326
+ self._logger.info("buttons change the volume of the %s now", profile.value)
327
+ self._active = profile
328
+
329
+ def set_limits(self, profile: VolumeProfile, default_volume: float, max_volume: float) -> None:
330
+ """:raises ValueError: for invalid values (see :func:`check_limits`)."""
331
+ with self._lock:
332
+ self._managers[profile].set_limits(default_volume, max_volume)
333
+ if self._limits is not None:
334
+ self._limits.store(profile.value, default_volume, max_volume)
335
+
336
+ # --- the active volume (buttons, web page, command line) ------------------------------------------------------
337
+
338
+ def get_volume(self) -> float:
339
+ return self.manager().get_volume()
340
+
341
+ def set_volume(self, volume: float) -> None:
342
+ self.manager().set_volume(volume)
343
+
344
+ def volume_up(self, held: bool = False) -> None:
345
+ self.manager().volume_up(held)
346
+
347
+ def volume_down(self, held: bool = False) -> None:
348
+ self.manager().volume_down(held)
349
+
350
+ def update_volume(self) -> None:
351
+ self.manager().update_volume()
352
+
353
+ def volume_off(self) -> None:
354
+ """Mute the speaker (shutdown) - the headset is disconnected anyway."""
355
+ self._managers[VolumeProfile.SPEAKER].volume_off()
@@ -0,0 +1 @@
1
+ """REST api and web page of the Tiny Jukebox box (https with a self signed certificate, login with users and roles)."""