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