/
/
/
1"""All constants for Music Assistant."""
2
3import json
4import os
5import pathlib
6import re
7from copy import deepcopy
8from typing import Any, Final, cast
9
10from music_assistant_models.config_entries import (
11 MULTI_VALUE_SPLITTER,
12 ConfigEntry,
13 ConfigValueOption,
14)
15from music_assistant_models.constants import PLAYER_CONTROL_NONE
16from music_assistant_models.enums import (
17 ConfigEntryType,
18 ContentType,
19 CrossfadeMode,
20 MediaType,
21 PlayerFeature,
22)
23from music_assistant_models.media_items import (
24 Audiobook,
25 AudioFormat,
26 PodcastEpisode,
27 Radio,
28 SoundEffect,
29 Track,
30)
31
32APPLICATION_NAME: Final = "Music Assistant"
33
34# Type alias for items that can be added to playlists
35PlaylistPlayableItem = Track | Radio | PodcastEpisode | Audiobook | SoundEffect
36
37# Default number of tracks a music provider may return as a preview sample for a dynamic playlist
38DYNAMIC_PLAYLIST_SAMPLE_SIZE: Final[int] = 25
39
40# Corresponding MediaType enum values (must match PlaylistPlayableItem types above)
41PLAYLIST_MEDIA_TYPES: Final[tuple[MediaType, ...]] = (
42 MediaType.TRACK,
43 MediaType.RADIO,
44 MediaType.PODCAST_EPISODE,
45 MediaType.AUDIOBOOK,
46 MediaType.SOUND_EFFECT,
47)
48
49# API_SCHEMA_VERSION: bump this when adding new features to the API commands (and models)
50# or small non-breaking changes to existing commands
51API_SCHEMA_VERSION: Final[int] = 44
52
53# MIN_SCHEMA_VERSION is the minimum API schema version that the current server
54# version can work with. Only bump when there are breaking changes to existing
55# API commands or models, such as removing fields or changing field types.
56# Note that doing so will break compatibility with all clients in the field
57# (including Home Assistant) that have not yet been updated to the new API
58# schema version, so only bump this when absolutely necessary.
59MIN_SCHEMA_VERSION: Final[int] = 28
60
61
62MASS_LOGGER_NAME: Final[str] = "music_assistant"
63
64# Home Assistant system user
65HOMEASSISTANT_SYSTEM_USER: Final[str] = "homeassistant_system"
66# Port used by the internal ingress webserver for the HA integration
67INGRESS_SERVER_PORT: Final[int] = 8094
68
69UNKNOWN_ARTIST: Final[str] = "[unknown]"
70UNKNOWN_ARTIST_ID_MBID: Final[str] = "125ec42a-7229-4250-afc5-e057484327fe"
71VARIOUS_ARTISTS_NAME: Final[str] = "Various Artists"
72VARIOUS_ARTISTS_MBID: Final[str] = "89ad4ac3-39f7-470e-963a-56509c546377"
73
74
75RESOURCES_DIR: Final[pathlib.Path] = (
76 pathlib.Path(__file__).parent.resolve().joinpath("helpers/resources")
77)
78GENRE_ICONS_DIR_NAME: Final[str] = "genres"
79GENRE_MAPPING_FILE: Final[pathlib.Path] = RESOURCES_DIR.joinpath(
80 GENRE_ICONS_DIR_NAME, "genre_mapping.json"
81)
82PODCAST_GENRE_MAPPING_FILE: Final[pathlib.Path] = RESOURCES_DIR.joinpath(
83 GENRE_ICONS_DIR_NAME, "podcast_genre_mapping.json"
84)
85AUDIOBOOK_GENRE_MAPPING_FILE: Final[pathlib.Path] = RESOURCES_DIR.joinpath(
86 GENRE_ICONS_DIR_NAME, "audiobook_genre_mapping.json"
87)
88
89ANNOUNCE_ALERT_FILE: Final[str] = str(RESOURCES_DIR.joinpath("announce.mp3"))
90SILENCE_FILE: Final[str] = str(RESOURCES_DIR.joinpath("silence.mp3"))
91SILENCE_FILE_LONG: Final[str] = str(RESOURCES_DIR.joinpath("silence_long.ogg"))
92VARIOUS_ARTISTS_FANART: Final[str] = str(RESOURCES_DIR.joinpath("fallback_fanart.jpeg"))
93MASS_LOGO: Final[str] = str(RESOURCES_DIR.joinpath("logo.png"))
94
95
96# config keys
97CONF_ONBOARD_DONE: Final[str] = "onboard_done"
98CONF_SERVER_ID: Final[str] = "server_id"
99CONF_ENCRYPTION_KEY: Final[str] = "encryption_key"
100CONF_ENCRYPTION_KEY_MIGRATED: Final[str] = "encryption_key_migrated"
101CONF_NFS_SUBFOLDER_MIGRATED: Final[str] = "nfs_subfolder_migrated"
102CONF_IP_ADDRESS: Final[str] = "ip_address"
103CONF_PORT: Final[str] = "port"
104CONF_PROVIDERS: Final[str] = "providers"
105CONF_PLAYERS: Final[str] = "players"
106CONF_PLAYER_QUEUES: Final[str] = "player_queues"
107CONF_CORE: Final[str] = "core"
108CONF_PATH: Final[str] = "path"
109CONF_NAME: Final[str] = "name"
110CONF_USERNAME: Final[str] = "username"
111CONF_PASSWORD: Final[str] = "password"
112CONF_VOLUME_NORMALIZATION: Final[str] = "volume_normalization"
113CONF_VOLUME_NORMALIZATION_TARGET: Final[str] = "volume_normalization_target"
114CONF_PLAYER_DSP: Final[str] = "player_dsp"
115CONF_PLAYER_DSP_PRESETS: Final[str] = "player_dsp_presets"
116CONF_PLAYER_DSP_IRS: Final[str] = "player_dsp_irs"
117# subdirectory under the storage path holding convolution impulse response files
118DSP_IRS_DIRNAME: Final[str] = "dsp_irs"
119# impulse response ids are lowercased shortuuids, so plain lowercase alphanumerics;
120# validating against this keeps a caller-supplied id from escaping the storage dir
121DSP_IR_ID_RE: Final = re.compile(r"^[a-z0-9]+$")
122CONF_OUTPUT_CHANNELS: Final[str] = "output_channels"
123CONF_FLOW_MODE: Final[str] = "flow_mode"
124CONF_FLOW_MODE_SAMPLE_RATE: Final[str] = "flow_mode_sample_rate"
125CONF_LOG_LEVEL: Final[str] = "log_level"
126CONF_HIDE_GROUP_CHILDS: Final[str] = "hide_group_childs"
127CONF_CROSSFADE_DURATION: Final[str] = "crossfade_duration"
128CONF_BIND_IP: Final[str] = "bind_ip"
129CONF_BIND_PORT: Final[str] = "bind_port"
130CONF_PUBLISH_IP: Final[str] = "publish_ip"
131WILDCARD_BIND_IPS: Final[tuple[str, ...]] = ("0.0.0.0", "::")
132# Port used by the built-in Sendspin server (runs next to, not behind, the webserver)
133SENDSPIN_SERVER_PORT: Final[int] = 8927
134CONF_AUTO_PLAY: Final[str] = "auto_play"
135CONF_PLAY_MEDIA_OVERRIDES_GROUP: Final[str] = "play_media_overrides_group"
136CONF_GROUP_MEMBERS: Final[str] = "group_members"
137CONF_DYNAMIC_GROUP_MEMBERS: Final[str] = "dynamic_members"
138CONF_HIDE_IN_UI: Final[str] = "hide_in_ui"
139CONF_EXPOSE_PLAYER_TO_HA: Final[str] = "expose_player_to_ha"
140CONF_SYNC_ADJUST: Final[str] = "sync_adjust"
141CONF_TTS_PRE_ANNOUNCE: Final[str] = "tts_pre_announce"
142CONF_ANNOUNCE_VOLUME_STRATEGY: Final[str] = "announce_volume_strategy"
143CONF_ANNOUNCE_VOLUME: Final[str] = "announce_volume"
144CONF_ANNOUNCE_VOLUME_MIN: Final[str] = "announce_volume_min"
145CONF_ANNOUNCE_VOLUME_MAX: Final[str] = "announce_volume_max"
146CONF_PRE_ANNOUNCE_CHIME_URL: Final[str] = "pre_announcement_chime_url"
147CONF_ICON: Final[str] = "icon"
148CONF_LANGUAGE: Final[str] = "language"
149CONF_SAMPLE_RATES: Final[str] = "sample_rates"
150CONF_HTTP_PROFILE: Final[str] = "http_profile"
151CONF_BYPASS_NORMALIZATION_RADIO: Final[str] = "bypass_normalization_radio"
152CONF_ENABLE_ICY_METADATA: Final[str] = "enable_icy_metadata"
153CONF_VOLUME_NORMALIZATION_RADIO: Final[str] = "volume_normalization_radio"
154CONF_VOLUME_NORMALIZATION_TRACKS: Final[str] = "volume_normalization_tracks"
155CONF_VOLUME_NORMALIZATION_FIXED_GAIN_RADIO: Final[str] = "volume_normalization_fixed_gain_radio"
156CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS: Final[str] = "volume_normalization_fixed_gain_tracks"
157CONF_POWER_CONTROL: Final[str] = "power_control"
158CONF_VOLUME_CONTROL: Final[str] = "volume_control"
159CONF_VOLUME_STEP: Final[str] = "volume_step"
160CONF_MUTE_CONTROL: Final[str] = "mute_control"
161CONF_MIN_VOLUME: Final[str] = "min_volume"
162CONF_MAX_VOLUME: Final[str] = "max_volume"
163CONF_PREFERRED_OUTPUT_PROTOCOL: Final[str] = "preferred_output_protocol"
164CONF_LINKED_PROTOCOL_IDS: Final[str] = "linked_protocol_ids" # cached for fast restart
165CONF_PROTOCOL_PARENT_ID: Final[str] = (
166 "protocol_parent_id" # cached native player ID for protocol player
167)
168CONF_UNDERLYING_PLAYER_ID: Final[str] = (
169 "underlying_player_id" # player this (bridge) protocol player is derived from
170)
171CONF_CACHED_ARP_MAC: Final[str] = "cached_arp_mac" # cached ARP-resolved MAC for fast restart
172CONF_REPORTED_MAC: Final[str] = "reported_mac" # original MAC reported by provider (before ARP)
173CONF_OUTPUT_CODEC: Final[str] = "output_codec"
174CONF_ALLOW_AUDIO_CACHE: Final[str] = "allow_audio_cache"
175CONF_SMART_FADES_MODE: Final[str] = "smart_fades_mode" # legacy; consumed by one-time migration
176CONF_CROSSFADE_MODE: Final[str] = "crossfade_mode"
177CONF_SOCKS_URL: Final[str] = "socks_url"
178CONF_USE_SSL: Final[str] = "use_ssl"
179CONF_VERIFY_SSL: Final[str] = "verify_ssl"
180CONF_SSL_FINGERPRINT: Final[str] = "ssl_fingerprint"
181CONF_AUTH_ALLOW_SELF_REGISTRATION: Final[str] = "auth_allow_self_registration"
182CONF_ZEROCONF_INTERFACES: Final[str] = "zeroconf_interfaces"
183CONF_ENABLED: Final[str] = "enabled"
184CONF_PROTOCOL_KEY_SPLITTER: Final[str] = "||protocol||"
185CONF_PROTOCOL_CATEGORY_PREFIX: Final[str] = "protocol"
186CONF_DEFAULT_PROVIDERS_SETUP: Final[str] = "default_providers_setup"
187CONF_BACKGROUND_SCAN_CONCURRENCY: Final[str] = "background_scan_concurrency"
188
189# Tri-state option VALUES for per-queue settings that can follow the global (queue controller)
190# default. "global" resolves to the queue-controller value (like the log_level "GLOBAL" pattern);
191# "enabled"/"disabled" force the setting on/off for that queue. (Distinct from the CONF_ENABLED key.)
192CONF_VALUE_GLOBAL: Final[str] = "global"
193CONF_VALUE_ENABLED: Final[str] = "enabled"
194CONF_VALUE_DISABLED: Final[str] = "disabled"
195
196# Sentinel option VALUE for network settings that are resolved at runtime when not explicitly
197# set (e.g. streams publish_ip / webserver base_url resolve to the server's primary IP at
198# startup), keeping the config entry defaults static/deterministic.
199CONF_VALUE_AUTO: Final[str] = "auto"
200
201
202def _default_background_scan_concurrency() -> int:
203 # Slow and steady by default: at most 2 tracks at once even on big machines. Users who
204 # want faster overnight scans can raise it (up to 16) at the cost of more CPU at night.
205 cpu_count = os.process_cpu_count() or os.cpu_count() or 4
206 if cpu_count >= 4:
207 return 2
208 return 1
209
210
211# config default values
212DEFAULT_HOST: Final[str] = "0.0.0.0"
213DEFAULT_BACKGROUND_SCAN_CONCURRENCY: Final[int] = _default_background_scan_concurrency()
214
215
216# common db tables
217DB_TABLE_PLAYLOG: Final[str] = "playlog"
218DB_TABLE_ARTISTS: Final[str] = "artists"
219DB_TABLE_ALBUMS: Final[str] = "albums"
220DB_TABLE_TRACKS: Final[str] = "tracks"
221DB_TABLE_PLAYLISTS: Final[str] = "playlists"
222DB_TABLE_RADIOS: Final[str] = "radios"
223DB_TABLE_AUDIOBOOKS: Final[str] = "audiobooks"
224DB_TABLE_PODCASTS: Final[str] = "podcasts"
225DB_TABLE_CACHE: Final[str] = "cache"
226DB_TABLE_SETTINGS: Final[str] = "settings"
227DB_TABLE_THUMBS: Final[str] = "thumbnails"
228DB_TABLE_PROVIDER_MAPPINGS: Final[str] = "provider_mappings"
229DB_TABLE_EXTERNAL_ID_LOOKUP: Final[str] = "external_id_lookup"
230DB_TABLE_ALBUM_TRACKS: Final[str] = "album_tracks"
231DB_TABLE_TRACK_ARTISTS: Final[str] = "track_artists"
232DB_TABLE_ALBUM_ARTISTS: Final[str] = "album_artists"
233DB_TABLE_AUDIOBOOK_ARTISTS: Final[str] = "audiobook_artists"
234DB_TABLE_LOUDNESS_MEASUREMENTS: Final[str] = "loudness_measurements"
235DB_TABLE_AUDIO_ANALYSIS: Final[str] = "audio_analysis"
236DB_TABLE_AUDIO_ANALYSIS_FAILURES: Final[str] = "audio_analysis_failures"
237DB_TABLE_GENRES: Final[str] = "genres"
238DB_TABLE_GENRE_MEDIA_ITEM_MAPPING: Final[str] = "genre_media_item_mapping"
239DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION: Final[str] = "genre_media_item_exclusion"
240
241# all media item tables, each of which has a search_name column
242# backed by a {table}_fts FTS5 index table
243MEDIA_ITEM_DB_TABLES: Final[tuple[str, ...]] = (
244 DB_TABLE_ARTISTS,
245 DB_TABLE_ALBUMS,
246 DB_TABLE_TRACKS,
247 DB_TABLE_PLAYLISTS,
248 DB_TABLE_RADIOS,
249 DB_TABLE_AUDIOBOOKS,
250 DB_TABLE_PODCASTS,
251 DB_TABLE_GENRES,
252)
253
254# Min fraction of a database file reclaimable before a startup VACUUM is worth running.
255VACUUM_MIN_RECLAIM_RATIO: Final[float] = 0.2
256
257# Loudness measurements at or below this value are considered unreliable:
258# ebur128 reports ~-70 LUFS when it receives near-silence or very little
259# audio (e.g. when a stream was cancelled early).
260LOUDNESS_MEASUREMENT_MIN_LUFS: Final[float] = -50.0
261
262
263def load_genre_mapping(mapping_file: pathlib.Path) -> list[dict[str, Any]]:
264 """
265 Load a default genre mapping from a JSON file.
266
267 :param mapping_file: Path to the genre mapping JSON file (music / podcast / audiobook).
268 :return: List of genre mapping dictionaries with 'genre' and 'aliases' keys.
269 :raises FileNotFoundError: If the mapping file is missing.
270 :raises ValueError: If JSON is malformed or missing required fields.
271 """
272 try:
273 content = mapping_file.read_text(encoding="utf-8")
274 data = json.loads(content)
275 except FileNotFoundError as err:
276 msg = f"Genre mapping file not found: {mapping_file}"
277 raise FileNotFoundError(msg) from err
278 except json.JSONDecodeError as err:
279 msg = f"Invalid JSON in genre mapping file: {mapping_file}"
280 raise ValueError(msg) from err
281
282 if not isinstance(data, list):
283 msg = f"Genre mapping must be a list, got {type(data).__name__}"
284 raise TypeError(msg)
285
286 for idx, entry in enumerate(data):
287 if not isinstance(entry, dict):
288 msg = f"Genre mapping entry {idx} must be a dict, got {type(entry).__name__}"
289 raise TypeError(msg)
290 if "genre" not in entry:
291 msg = f"Genre mapping entry {idx} missing required field 'genre'"
292 raise ValueError(msg)
293 if "aliases" not in entry:
294 msg = f"Genre mapping entry {idx} missing required field 'aliases'"
295 raise ValueError(msg)
296
297 return cast("list[dict[str, Any]]", data)
298
299
300DEFAULT_GENRE_MAPPING: Final[list[dict[str, Any]]] = load_genre_mapping(GENRE_MAPPING_FILE)
301DEFAULT_PODCAST_GENRE_MAPPING: Final[list[dict[str, Any]]] = load_genre_mapping(
302 PODCAST_GENRE_MAPPING_FILE
303)
304DEFAULT_AUDIOBOOK_GENRE_MAPPING: Final[list[dict[str, Any]]] = load_genre_mapping(
305 AUDIOBOOK_GENRE_MAPPING_FILE
306)
307DEFAULT_GENRES: Final[tuple[str, ...]] = tuple(entry["genre"] for entry in DEFAULT_GENRE_MAPPING)
308
309
310# all other
311MASS_LOGO_ONLINE: Final[str] = (
312 "https://raw.githubusercontent.com/music-assistant/server/refs/heads/dev/music_assistant/logo.png"
313)
314ENCRYPT_SUFFIX = "_encrypted_"
315CONFIGURABLE_CORE_CONTROLLERS = (
316 "discovery",
317 "streams",
318 "webserver",
319 "players",
320 "metadata",
321 "cache",
322 "music",
323 "player_queues",
324 "tasks",
325)
326VERBOSE_LOG_LEVEL: Final[int] = 5
327PROVIDERS_WITH_SHAREABLE_URLS = ("spotify", "qobuz", "apple_music", "deezer")
328
329
330####### REUSABLE CONFIG ENTRIES #######
331
332CONF_ENTRY_LOG_LEVEL = ConfigEntry(
333 key=CONF_LOG_LEVEL,
334 type=ConfigEntryType.STRING,
335 options=[
336 ConfigValueOption("GLOBAL"),
337 ConfigValueOption("INFO"),
338 ConfigValueOption("WARNING"),
339 ConfigValueOption("ERROR"),
340 ConfigValueOption("DEBUG"),
341 ConfigValueOption("VERBOSE"),
342 ],
343 default_value="GLOBAL",
344 advanced=True,
345 requires_reload=False, # applied dynamically via _set_logger()
346)
347
348CONF_MAX_CONCURRENT_TASKS = "max_concurrent_tasks"
349CONF_ENTRY_MAX_CONCURRENT_TASKS = ConfigEntry(
350 key=CONF_MAX_CONCURRENT_TASKS,
351 type=ConfigEntryType.INTEGER,
352 range=(1, 10),
353 default_value=2,
354 advanced=True,
355 requires_reload=False,
356)
357
358DEFAULT_PROVIDER_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)
359DEFAULT_CORE_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)
360
361# some reusable player config entries
362
363CONF_ENTRY_FLOW_MODE = ConfigEntry(
364 key=CONF_FLOW_MODE,
365 type=ConfigEntryType.BOOLEAN,
366 default_value=False,
367 category="protocol_generic",
368 advanced=True,
369 requires_reload=True,
370)
371
372
373FLOW_MODE_SAMPLE_RATE_SMART: Final[str] = "smart"
374FLOW_MODE_SAMPLE_RATE_BIT_PERFECT: Final[str] = "bit_perfect"
375FLOW_MODE_SAMPLE_RATE_48000: Final[str] = "48000"
376FLOW_MODE_SAMPLE_RATE_96000: Final[str] = "96000"
377FLOW_MODE_SAMPLE_RATE_HIGHEST: Final[str] = "highest"
378
379CONF_ENTRY_FLOW_MODE_SAMPLE_RATE = ConfigEntry(
380 key=CONF_FLOW_MODE_SAMPLE_RATE,
381 type=ConfigEntryType.STRING,
382 options=[
383 ConfigValueOption(FLOW_MODE_SAMPLE_RATE_SMART),
384 ConfigValueOption(FLOW_MODE_SAMPLE_RATE_BIT_PERFECT),
385 ConfigValueOption(FLOW_MODE_SAMPLE_RATE_48000),
386 ConfigValueOption(FLOW_MODE_SAMPLE_RATE_96000),
387 ConfigValueOption(FLOW_MODE_SAMPLE_RATE_HIGHEST),
388 ],
389 default_value=FLOW_MODE_SAMPLE_RATE_SMART,
390 depends_on=CONF_FLOW_MODE,
391 category="protocol_generic",
392 advanced=True,
393 requires_reload=True,
394)
395
396
397CONF_ENTRY_AUTO_PLAY = ConfigEntry(
398 key=CONF_AUTO_PLAY,
399 type=ConfigEntryType.BOOLEAN,
400 default_value=False,
401 depends_on=CONF_POWER_CONTROL,
402 depends_on_value_not="none",
403 category="player_controls",
404)
405
406CONF_ENTRY_PLAY_MEDIA_OVERRIDES_GROUP = ConfigEntry(
407 key=CONF_PLAY_MEDIA_OVERRIDES_GROUP,
408 type=ConfigEntryType.BOOLEAN,
409 default_value=True,
410 category="generic",
411 advanced=False,
412)
413
414CONF_ENTRY_MIN_VOLUME = ConfigEntry(
415 key=CONF_MIN_VOLUME,
416 type=ConfigEntryType.INTEGER,
417 range=(0, 100),
418 default_value=0,
419 category="player_controls",
420 advanced=True,
421 depends_on=CONF_VOLUME_CONTROL,
422 depends_on_value_not=PLAYER_CONTROL_NONE,
423)
424
425CONF_ENTRY_MAX_VOLUME = ConfigEntry(
426 key=CONF_MAX_VOLUME,
427 type=ConfigEntryType.INTEGER,
428 range=(0, 100),
429 default_value=100,
430 category="player_controls",
431 advanced=True,
432 depends_on=CONF_VOLUME_CONTROL,
433 depends_on_value_not=PLAYER_CONTROL_NONE,
434)
435
436CONF_ENTRY_OUTPUT_CHANNELS = ConfigEntry(
437 key=CONF_OUTPUT_CHANNELS,
438 type=ConfigEntryType.STRING,
439 options=[
440 ConfigValueOption("stereo"),
441 ConfigValueOption("left"),
442 ConfigValueOption("right"),
443 ConfigValueOption("mono"),
444 ],
445 default_value="stereo",
446 category="protocol_generic",
447 advanced=True,
448 requires_reload=True,
449)
450
451CONF_ENTRY_VOLUME_NORMALIZATION_TARGET = ConfigEntry(
452 key=CONF_VOLUME_NORMALIZATION_TARGET,
453 type=ConfigEntryType.INTEGER,
454 range=(-30, -5),
455 default_value=-14,
456 category="playback",
457 advanced=True,
458 requires_reload=True,
459)
460
461# Note: the crossfade_mode select entry (standard/smart) is built dynamically in the config
462# controller because its options and default depend on smart fades availability.
463
464# Crossfade duration is a global-only (queue controller) setting; it is read fresh per stream, so a
465# change applies on the next track (no reload needed — a reload of the queues core is disruptive).
466CONF_ENTRY_CROSSFADE_DURATION = ConfigEntry(
467 key=CONF_CROSSFADE_DURATION,
468 type=ConfigEntryType.INTEGER,
469 range=(1, 15),
470 default_value=8,
471 category="crossfade",
472 depends_on=CONF_CROSSFADE_MODE,
473 depends_on_value=CrossfadeMode.STANDARD_CROSSFADE.value,
474)
475
476
477CONF_ENTRY_OUTPUT_CODEC = ConfigEntry(
478 key=CONF_OUTPUT_CODEC,
479 type=ConfigEntryType.STRING,
480 default_value="flac",
481 options=[
482 ConfigValueOption("flac"),
483 ConfigValueOption("mp3"),
484 ConfigValueOption("aac"),
485 ConfigValueOption("wav"),
486 ],
487 category="protocol_generic",
488 advanced=True,
489 requires_reload=True,
490)
491
492CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3 = ConfigEntry.from_dict(
493 {**CONF_ENTRY_OUTPUT_CODEC.to_dict(), "default_value": "mp3"}
494)
495CONF_ENTRY_OUTPUT_CODEC_ENFORCE_MP3 = ConfigEntry.from_dict(
496 {**CONF_ENTRY_OUTPUT_CODEC.to_dict(), "default_value": "mp3", "hidden": True}
497)
498CONF_ENTRY_OUTPUT_CODEC_HIDDEN = ConfigEntry.from_dict(
499 {**CONF_ENTRY_OUTPUT_CODEC.to_dict(), "hidden": True}
500)
501CONF_ENTRY_OUTPUT_CODEC_ENFORCE_FLAC = ConfigEntry.from_dict(
502 {**CONF_ENTRY_OUTPUT_CODEC.to_dict(), "default_value": "flac", "hidden": True}
503)
504
505
506def create_output_codec_config_entry(
507 hidden: bool = False, default_value: str = "flac"
508) -> ConfigEntry:
509 """Create output codec config entry based on player specific helpers."""
510 conf_entry = ConfigEntry.from_dict(CONF_ENTRY_OUTPUT_CODEC.to_dict())
511 conf_entry.hidden = hidden
512 conf_entry.default_value = default_value
513 return conf_entry
514
515
516CONF_ENTRY_SYNC_ADJUST = ConfigEntry(
517 key=CONF_SYNC_ADJUST,
518 type=ConfigEntryType.INTEGER,
519 range=(-500, 500),
520 default_value=0,
521 category="protocol_generic",
522 advanced=True,
523 requires_reload=True,
524)
525
526
527CONF_ENTRY_TTS_PRE_ANNOUNCE = ConfigEntry(
528 key=CONF_TTS_PRE_ANNOUNCE,
529 type=ConfigEntryType.BOOLEAN,
530 default_value=True,
531 category="announcements",
532)
533
534
535CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY = ConfigEntry(
536 key=CONF_ANNOUNCE_VOLUME_STRATEGY,
537 type=ConfigEntryType.STRING,
538 options=[
539 ConfigValueOption("absolute"),
540 ConfigValueOption("relative"),
541 ConfigValueOption("percentual"),
542 ConfigValueOption("none"),
543 ],
544 default_value="percentual",
545 category="announcements",
546)
547
548CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY_HIDDEN = ConfigEntry.from_dict(
549 {**CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.to_dict(), "hidden": True}
550)
551
552CONF_ENTRY_ANNOUNCE_VOLUME = ConfigEntry(
553 key=CONF_ANNOUNCE_VOLUME,
554 type=ConfigEntryType.INTEGER,
555 default_value=85,
556 category="announcements",
557)
558CONF_ENTRY_ANNOUNCE_VOLUME_HIDDEN = ConfigEntry.from_dict(
559 {**CONF_ENTRY_ANNOUNCE_VOLUME.to_dict(), "hidden": True}
560)
561
562CONF_ENTRY_ANNOUNCE_VOLUME_MIN = ConfigEntry(
563 key=CONF_ANNOUNCE_VOLUME_MIN,
564 type=ConfigEntryType.INTEGER,
565 default_value=15,
566 category="announcements",
567)
568CONF_ENTRY_ANNOUNCE_VOLUME_MIN_HIDDEN = ConfigEntry.from_dict(
569 {**CONF_ENTRY_ANNOUNCE_VOLUME_MIN.to_dict(), "hidden": True}
570)
571
572CONF_ENTRY_ANNOUNCE_VOLUME_MAX = ConfigEntry(
573 key=CONF_ANNOUNCE_VOLUME_MAX,
574 type=ConfigEntryType.INTEGER,
575 default_value=75,
576 category="announcements",
577)
578CONF_ENTRY_ANNOUNCE_VOLUME_MAX_HIDDEN = ConfigEntry.from_dict(
579 {**CONF_ENTRY_ANNOUNCE_VOLUME_MAX.to_dict(), "hidden": True}
580)
581
582
583HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES = (
584 CONF_ENTRY_ANNOUNCE_VOLUME_HIDDEN,
585 CONF_ENTRY_ANNOUNCE_VOLUME_MIN_HIDDEN,
586 CONF_ENTRY_ANNOUNCE_VOLUME_MAX_HIDDEN,
587 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY_HIDDEN,
588)
589
590
591CONF_ENTRY_SAMPLE_RATES = ConfigEntry(
592 key=CONF_SAMPLE_RATES,
593 type=ConfigEntryType.SPLITTED_STRING,
594 multi_value=True,
595 options=[
596 ConfigValueOption(f"44100{MULTI_VALUE_SPLITTER}16"),
597 ConfigValueOption(f"44100{MULTI_VALUE_SPLITTER}24"),
598 ConfigValueOption(f"48000{MULTI_VALUE_SPLITTER}16"),
599 ConfigValueOption(f"48000{MULTI_VALUE_SPLITTER}24"),
600 ConfigValueOption(f"88200{MULTI_VALUE_SPLITTER}16"),
601 ConfigValueOption(f"88200{MULTI_VALUE_SPLITTER}24"),
602 ConfigValueOption(f"96000{MULTI_VALUE_SPLITTER}16"),
603 ConfigValueOption(f"96000{MULTI_VALUE_SPLITTER}24"),
604 ConfigValueOption(f"176400{MULTI_VALUE_SPLITTER}16"),
605 ConfigValueOption(f"176400{MULTI_VALUE_SPLITTER}24"),
606 ConfigValueOption(f"192000{MULTI_VALUE_SPLITTER}16"),
607 ConfigValueOption(f"192000{MULTI_VALUE_SPLITTER}24"),
608 ConfigValueOption(f"352800{MULTI_VALUE_SPLITTER}16"),
609 ConfigValueOption(f"352800{MULTI_VALUE_SPLITTER}24"),
610 ConfigValueOption(f"384000{MULTI_VALUE_SPLITTER}16"),
611 ConfigValueOption(f"384000{MULTI_VALUE_SPLITTER}24"),
612 ],
613 default_value=[f"44100{MULTI_VALUE_SPLITTER}16", f"48000{MULTI_VALUE_SPLITTER}16"],
614 required=True,
615 category="protocol_generic",
616 advanced=True,
617 requires_reload=True,
618)
619
620
621CONF_ENTRY_HTTP_PROFILE = ConfigEntry(
622 key=CONF_HTTP_PROFILE,
623 type=ConfigEntryType.STRING,
624 options=[
625 ConfigValueOption("chunked"),
626 ConfigValueOption("no_content_length"),
627 ConfigValueOption("forced_content_length"),
628 ],
629 default_value="no_content_length",
630 category="protocol_generic",
631 advanced=True,
632 requires_reload=True,
633)
634
635CONF_ENTRY_HTTP_PROFILE_DEFAULT_1 = ConfigEntry.from_dict(
636 {**CONF_ENTRY_HTTP_PROFILE.to_dict(), "default_value": "chunked"}
637)
638
639CONF_ENTRY_HTTP_PROFILE_DEFAULT_2 = ConfigEntry.from_dict(
640 {**CONF_ENTRY_HTTP_PROFILE.to_dict(), "default_value": "no_content_length"}
641)
642CONF_ENTRY_HTTP_PROFILE_DEFAULT_3 = ConfigEntry.from_dict(
643 {**CONF_ENTRY_HTTP_PROFILE.to_dict(), "default_value": "forced_content_length"}
644)
645
646CONF_ENTRY_HTTP_PROFILE_FORCED_1 = ConfigEntry.from_dict(
647 {**CONF_ENTRY_HTTP_PROFILE_DEFAULT_1.to_dict(), "hidden": True}
648)
649CONF_ENTRY_HTTP_PROFILE_FORCED_2 = ConfigEntry.from_dict(
650 {
651 **CONF_ENTRY_HTTP_PROFILE.to_dict(),
652 "default_value": "no_content_length",
653 "hidden": True,
654 }
655)
656CONF_ENTRY_HTTP_PROFILE_FORCED_3 = ConfigEntry.from_dict(
657 {
658 **CONF_ENTRY_HTTP_PROFILE.to_dict(),
659 "default_value": "forced_content_length",
660 "hidden": True,
661 }
662)
663CONF_ENTRY_HTTP_PROFILE_HIDDEN = ConfigEntry.from_dict(
664 {**CONF_ENTRY_HTTP_PROFILE.to_dict(), "hidden": True}
665)
666
667
668CONF_ENTRY_ENABLE_ICY_METADATA = ConfigEntry(
669 key=CONF_ENABLE_ICY_METADATA,
670 type=ConfigEntryType.STRING,
671 options=[
672 ConfigValueOption("disabled"),
673 ConfigValueOption("basic"),
674 ConfigValueOption("full"),
675 ],
676 depends_on=CONF_FLOW_MODE,
677 depends_on_value_not=False,
678 default_value="disabled",
679 category="protocol_generic",
680 advanced=True,
681 requires_reload=True,
682)
683
684CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN = ConfigEntry.from_dict(
685 {**CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(), "hidden": True}
686)
687
688CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED = ConfigEntry.from_dict(
689 {
690 **CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(),
691 "default_value": "disabled",
692 "value": "disabled",
693 "hidden": True,
694 }
695)
696
697CONF_ENTRY_ICY_METADATA_DEFAULT_FULL = ConfigEntry.from_dict(
698 {
699 **CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(),
700 "default_value": "full",
701 }
702)
703
704CONF_ENTRY_CROSSFADE_DIFFERENT_SAMPLE_RATES = ConfigEntry(
705 key="crossfade_different_sample_rates",
706 type=ConfigEntryType.BOOLEAN,
707 default_value=True,
708 category="protocol_generic",
709 advanced=True,
710 requires_reload=True,
711 depends_on=CONF_FLOW_MODE,
712 depends_on_value_not=True,
713)
714
715CONF_ENTRY_WARN_PREVIEW = ConfigEntry(
716 key="preview_note",
717 type=ConfigEntryType.ALERT,
718 required=False,
719)
720
721CONF_ENTRY_UNOFFICIAL_PROVIDER = ConfigEntry(
722 key="unofficial_provider_note",
723 type=ConfigEntryType.ALERT,
724 required=False,
725)
726
727CONF_ENTRY_MANUAL_DISCOVERY_IPS = ConfigEntry(
728 key="manual_discovery_ip_addresses",
729 type=ConfigEntryType.STRING,
730 advanced=True,
731 default_value=[],
732 required=False,
733 multi_value=True,
734)
735
736CONF_ENTRY_LIBRARY_SYNC_ARTISTS = ConfigEntry(
737 key="library_sync_artists",
738 type=ConfigEntryType.BOOLEAN,
739 default_value=True,
740 category="sync_options",
741)
742
743
744CONF_ENTRY_ZEROCONF_INTERFACES = ConfigEntry(
745 key=CONF_ZEROCONF_INTERFACES,
746 type=ConfigEntryType.STRING,
747 options=[
748 ConfigValueOption("default"),
749 ConfigValueOption("all"),
750 ],
751 default_value="default",
752 advanced=True,
753 requires_reload=True,
754)
755CONF_ENTRY_LIBRARY_SYNC_ALBUMS = ConfigEntry(
756 key="library_sync_albums",
757 type=ConfigEntryType.BOOLEAN,
758 default_value=True,
759 category="sync_options",
760)
761CONF_ENTRY_LIBRARY_SYNC_TRACKS = ConfigEntry(
762 key="library_sync_tracks",
763 type=ConfigEntryType.BOOLEAN,
764 default_value=True,
765 category="sync_options",
766)
767CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS = ConfigEntry(
768 key="library_sync_playlists",
769 type=ConfigEntryType.BOOLEAN,
770 default_value=True,
771 category="sync_options",
772)
773CONF_ENTRY_LIBRARY_SYNC_PODCASTS = ConfigEntry(
774 key="library_sync_podcasts",
775 type=ConfigEntryType.BOOLEAN,
776 default_value=True,
777 category="sync_options",
778)
779CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS = ConfigEntry(
780 key="library_sync_audiobooks",
781 type=ConfigEntryType.BOOLEAN,
782 default_value=True,
783 category="sync_options",
784)
785CONF_ENTRY_LIBRARY_SYNC_RADIOS = ConfigEntry(
786 key="library_sync_radios",
787 type=ConfigEntryType.BOOLEAN,
788 default_value=True,
789 category="sync_options",
790)
791CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS = ConfigEntry(
792 key="library_sync_album_tracks",
793 type=ConfigEntryType.BOOLEAN,
794 default_value=False,
795 category="sync_options",
796)
797CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS = ConfigEntry(
798 key="library_sync_playlist_tracks",
799 type=ConfigEntryType.STRING,
800 default_value=[],
801 category="sync_options",
802 multi_value=True,
803)
804
805CONF_ENTRY_LIBRARY_SYNC_BACK = ConfigEntry(
806 key="library_sync_back",
807 type=ConfigEntryType.BOOLEAN,
808 default_value=True,
809 category="sync_options",
810)
811
812CONF_ENTRY_LIBRARY_SYNC_DELETIONS = ConfigEntry(
813 key="library_sync_deletions",
814 type=ConfigEntryType.BOOLEAN,
815 default_value=True,
816 category="sync_options",
817 advanced=True,
818)
819
820CONF_ENTRY_PLAYER_ICON = ConfigEntry(
821 key=CONF_ICON,
822 type=ConfigEntryType.ICON,
823 default_value="speaker",
824 category="generic",
825)
826
827CONF_ENTRY_PLAYER_ICON_GROUP = ConfigEntry.from_dict(
828 {**CONF_ENTRY_PLAYER_ICON.to_dict(), "default_value": "speakers"}
829)
830
831
832def create_sample_rates_config_entry(
833 supported_sample_rates: list[int] | None = None,
834 supported_bit_depths: list[int] | None = None,
835 hidden: bool = False,
836 max_sample_rate: int | None = None,
837 max_bit_depth: int | None = None,
838 safe_max_sample_rate: int = 48000,
839 safe_max_bit_depth: int = 16,
840) -> ConfigEntry:
841 """Create sample rates config entry based on player specific helpers."""
842 assert CONF_ENTRY_SAMPLE_RATES.options
843 # if no supported sample rates are defined, we apply the default 44100 as only option
844 if not supported_sample_rates and max_sample_rate is None:
845 supported_sample_rates = [44100]
846 if not supported_bit_depths and max_bit_depth is None:
847 supported_bit_depths = [16]
848 final_supported_sample_rates = supported_sample_rates or []
849 final_supported_bit_depths = supported_bit_depths or []
850 conf_entry = deepcopy(CONF_ENTRY_SAMPLE_RATES)
851 conf_entry.hidden = hidden
852 options: list[ConfigValueOption] = []
853 default_value: list[str] = []
854
855 for option in CONF_ENTRY_SAMPLE_RATES.options:
856 option_value = cast("str", option.value)
857 sample_rate_str, bit_depth_str = option_value.split(MULTI_VALUE_SPLITTER, 1)
858 sample_rate = int(sample_rate_str)
859 bit_depth = int(bit_depth_str)
860 # if no supported sample rates are defined, we accept all within max_sample_rate
861 if not supported_sample_rates and max_sample_rate and sample_rate <= max_sample_rate:
862 final_supported_sample_rates.append(sample_rate)
863 if not supported_bit_depths and max_bit_depth and bit_depth <= max_bit_depth:
864 final_supported_bit_depths.append(bit_depth)
865
866 if sample_rate not in final_supported_sample_rates:
867 continue
868 if bit_depth not in final_supported_bit_depths:
869 continue
870 options.append(option)
871 if sample_rate <= safe_max_sample_rate and bit_depth <= safe_max_bit_depth:
872 default_value.append(option_value)
873 conf_entry.options = options
874 conf_entry.default_value = default_value
875 return conf_entry
876
877
878DEFAULT_STREAM_HEADERS = {
879 "Server": APPLICATION_NAME,
880 "transferMode.dlna.org": "Streaming",
881 "Cache-Control": "no-cache",
882 "Pragma": "no-cache",
883 "Accept-Ranges": "none",
884 "Connection": "close",
885 "icy-name": APPLICATION_NAME,
886}
887
888# DLNA contentFeatures header values for different stream types.
889# ORG_OP=00: no time-seek, no byte-seek (we encode on-the-fly, unknown size).
890# ORG_FLAGS bit layout (hex, first 8 chars of 32-char field):
891# bit 31 (0x80000000): Sender Paced - server controls data rate
892# bit 24 (0x01000000): Streaming Transfer Mode
893# bit 22 (0x00400000): Background Transfer Mode
894# bit 21 (0x00200000): HTTP Connection Stalling permitted
895# bit 20 (0x00100000): DLNA V1.5
896#
897# Bufferable streams (tracks, flow mode): player may buffer aggressively.
898# Flags: 0x01700000 = Streaming + Background + Connection Stalling + V1.5
899DLNA_CONTENT_FEATURES = "DLNA.ORG_OP=00;DLNA.ORG_FLAGS=01700000000000000000000000000000"
900# Realtime streams (radio, plugin sources): server controls data rate,
901# player should not try to buffer ahead faster than the server delivers.
902# Flags: 0x81700000 = Sender Paced + Streaming + Background + Connection Stalling + V1.5
903DLNA_CONTENT_FEATURES_REALTIME = "DLNA.ORG_OP=00;DLNA.ORG_FLAGS=81700000000000000000000000000000"
904ICY_HEADERS = {
905 "icy-name": APPLICATION_NAME,
906 "icy-description": f"{APPLICATION_NAME} - Your personal music assistant",
907 "icy-version": "1",
908 "icy-logo": MASS_LOGO_ONLINE,
909}
910
911INTERNAL_PCM_FORMAT = AudioFormat(
912 # always prefer float32 as internal pcm format to create headroom
913 # for filters such as dsp and volume normalization
914 content_type=ContentType.PCM_F32LE,
915 bit_depth=32, # related to float32
916 sample_rate=48000, # static for flow stream, dynamic for anything else
917 channels=2, # static for flow stream, dynamic for anything else
918)
919
920# Seconds without a new chunk before the source is treated as stalled (above ffmpeg's ~15s reconnect window).
921STREAM_STALL_TIMEOUT: Final[int] = 20
922# Longer budget for the first chunk to allow for connect + probe.
923STREAM_START_TIMEOUT: Final[int] = 30
924
925# extra data / extra attributes keys
926ATTR_FAKE_POWER: Final[str] = "fake_power"
927ATTR_FAKE_VOLUME: Final[str] = "fake_volume_level"
928ATTR_FAKE_MUTE: Final[str] = "fake_volume_muted"
929ATTR_ANNOUNCEMENT_IN_PROGRESS: Final[str] = "announcement_in_progress"
930ATTR_PREVIOUS_VOLUME: Final[str] = "previous_volume"
931ATTR_LAST_POLL: Final[str] = "last_poll"
932ATTR_GROUP_MEMBERS: Final[str] = "group_members"
933ATTR_GROUP_VOLUME_SNAPSHOT: Final[str] = "group_volume_snapshot"
934ATTR_ENABLED: Final[str] = "enabled"
935ATTR_AVAILABLE: Final[str] = "available"
936ATTR_POWERED: Final[str] = "powered"
937ATTR_MUTE_LOCK: Final[str] = "mute_lock"
938ATTR_ACTIVE_SOURCE: Final[str] = "active_source"
939ATTR_SUPPORTED_FEATURES: Final[str] = "supported_features"
940ATTR_MUTE_CONTROL: Final[str] = "mute_control"
941ATTR_VOLUME_CONTROL: Final[str] = "volume_control"
942ATTR_POWER_CONTROL: Final[str] = "power_control"
943ATTR_PLAY_ACTION_IN_PROGRESS: Final[str] = "play_action_in_progress"
944
945# Album type detection patterns
946LIVE_INDICATORS = [
947 r"\bunplugged\b",
948 r"\bin concert\b",
949 r"\bon stage\b",
950 r"\blive\b",
951]
952
953SOUNDTRACK_INDICATORS = [
954 r"\bsoundtrack\b", # Catches all soundtrack variations
955 r"\bmusic from the .* motion picture\b",
956 r"\boriginal score\b",
957 r"\bthe score\b",
958 r"\bfilm score\b",
959 r"(^|\b)score:\s*", # e.g., "Score: The Two Towers"
960 r"\bfrom the film\b",
961 r"\boriginal.*cast.*recording\b",
962]
963
964# how often we report the playback progress in the player_queues controller
965PLAYBACK_REPORT_INTERVAL_SECONDS = 30
966
967# List of providers that do not use HTTP streaming
968# but consume raw audio data over other protocols
969# for provider domains in this list, we won't show the default
970# http-streaming specific config options in player settings
971NON_HTTP_PROVIDERS = ("airplay", "sendspin", "snapcast")
972
973# Protocol priority values (lower = more preferred)
974PROTOCOL_PRIORITY: Final[dict[str, int]] = {
975 "airplay": 10,
976 "squeezelite": 20,
977 "chromecast": 30,
978 "sendspin": 40,
979 "dlna": 50,
980}
981
982PROTOCOL_FEATURES: Final[set[PlayerFeature]] = {
983 # Player features that may be copied from (inactive) protocol implementations
984 PlayerFeature.PLAY_ANNOUNCEMENT,
985 PlayerFeature.SET_MEMBERS,
986}
987
988ACTIVE_PROTOCOL_FEATURES: Final[set[PlayerFeature]] = {
989 # Player features that may be copied from the active output protocol
990 *PROTOCOL_FEATURES,
991 PlayerFeature.ENQUEUE,
992 PlayerFeature.GAPLESS_PLAYBACK,
993 PlayerFeature.MULTI_DEVICE_DSP,
994 PlayerFeature.PAUSE,
995}
996
997PLAYER_CONTROL_PROTOCOL: Final[str] = "follow_protocol"
998DEFAULT_PROVIDERS: Final[set[tuple[str, bool]]] = {
999 # list of providers that are setup by default once
1000 # (and they can be removed/disabled by the user if they want to)
1001 # the boolean value indicates whether it needs to be discovered on mdns
1002 ("airplay", False),
1003 ("chromecast", False),
1004 ("dlna", False),
1005 ("sonos", True),
1006 ("bluesound", True),
1007 ("heos", True),
1008 ("wiim", True),
1009 ("party", False),
1010 # smart_fades gates on system requirements (RAM/CPU) in its own setup(); an
1011 # under-spec host has the auto-created config removed again at load time.
1012 ("smart_fades", False),
1013 ("lastfm_recommendations", False),
1014 ("playlist_metadata", False),
1015 # ambient_sounds provides out-of-the-box sound effects (e.g. for the queue
1016 # audio overlay feature) at zero resource cost until actually used
1017 ("ambient_sounds", False),
1018}
1019
1020EXTERNAL_SOURCES: Final[set[str]] = {
1021 # list of sources that are definitely considered "external"
1022 # values are matched case-insensitive against the player's active_source
1023 # streaming services / connect sources
1024 "spotify",
1025 "spotify_connect",
1026 "spotify connect",
1027 "apple_music",
1028 "apple music",
1029 "tidal",
1030 "tidal_connect",
1031 "tidal connect",
1032 "qobuz",
1033 "deezer",
1034 "amazon_music",
1035 "amazon music",
1036 "pandora",
1037 "iheartradio",
1038 "napster",
1039 "rhapsody",
1040 "siriusxm",
1041 "soundcloud",
1042 "tunein",
1043 "tune in",
1044 "radioparadise",
1045 "radio paradise",
1046 "radiko",
1047 "juke",
1048 "alexa",
1049 "radio",
1050 "airplay",
1051 "chromecast",
1052 # bluetooth (bluesound, musiccast)
1053 "bluetooth",
1054 # physical/analog inputs (sonos, heos, musiccast, demo)
1055 "line-in",
1056 "linein",
1057 "line in",
1058 "line_in",
1059 "aux",
1060 "tuner",
1061 # tv / hdmi (sonos, bluesound)
1062 "tv",
1063 "tv input",
1064 "hdmi arc",
1065 # local/usb playback on device (musiccast)
1066 "usb",
1067 # external (hass_players)
1068 "external",
1069}
1070
1071COLLECTION_ITEM_ID_SEPARATOR = "___"
1072