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