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