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