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,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)."""
|