Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0418a33
feat(audio-analysis): add audio_analysis_failures table and constant
chrisuthe Jun 7, 2026
7f47ead
feat(audio-analysis): add AudioAnalysisError for classified failures
chrisuthe Jun 7, 2026
639329d
style(audio-analysis): tighten AudioAnalysisError docstring to caller…
chrisuthe Jun 7, 2026
c652a1a
feat(audio-analysis): add record/clear analysis failure store methods
chrisuthe Jun 7, 2026
1f39220
feat(audio-analysis): clear failure row on successful analysis write
chrisuthe Jun 7, 2026
65e8108
feat(audio-analysis): record failures from base provider lifecycle
chrisuthe Jun 7, 2026
50f9d51
style(audio-analysis): move _record_failure rationale to inline comment
chrisuthe Jun 7, 2026
adf9eb3
feat(audio-analysis): record eviction and track-level scan failures
chrisuthe Jun 7, 2026
12c77d7
fix(audio-analysis): clear _session_meta on full eviction to prevent …
chrisuthe Jun 7, 2026
90b417d
feat(audio-analysis): gate blocked failures out of scan candidates
chrisuthe Jun 7, 2026
a21fa69
docs(audio-analysis): restore strategy comment on candidate gate query
chrisuthe Jun 7, 2026
f90a189
feat(audio-analysis): add failures list/clear API commands
chrisuthe Jun 7, 2026
ea48293
feat(audio-analysis): classify deterministic skips as recorded failures
chrisuthe Jun 7, 2026
9b60214
docs(audio-analysis): note retry_at must be timezone-aware
chrisuthe Jun 7, 2026
2a7c645
fix(audio-analysis): record loudness too-quiet and smart_fades no-bea…
chrisuthe Jun 7, 2026
2bb063e
@
chrisuthe Jun 7, 2026
8c4edd7
feat(audio-analysis): re-scan stale analysis_version tracks in backgr…
chrisuthe Jun 7, 2026
f0ffa4e
feat(audio-analysis): count stale-version tracks as pending in coverage
chrisuthe Jun 7, 2026
100367f
style(audio-analysis): keep _count docstring on one line within 100 cols
chrisuthe Jun 7, 2026
c8c21eb
test(audio-analysis): exercise stale-version gate on real DB, pin fai…
chrisuthe Jun 11, 2026
8052367
fix(tests): satisfy mypy and ruff in audio-analysis failure tests
chrisuthe Jun 11, 2026
cd4c19e
Merge branch 'dev' into feat/aa-failure-tracking
chrisuthe Jun 19, 2026
94a0dc5
Merge branch 'dev' into feat/aa-failure-tracking
chrisuthe Jun 19, 2026
2abebd6
Merge branch 'dev' into feat/aa-failure-tracking
chrisuthe Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions music_assistant/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""All constants for Music Assistant."""

import json
Expand Down Expand Up @@ -182,6 +182,7 @@
DB_TABLE_ALBUM_ARTISTS: Final[str] = "album_artists"
DB_TABLE_LOUDNESS_MEASUREMENTS: Final[str] = "loudness_measurements"
DB_TABLE_AUDIO_ANALYSIS: Final[str] = "audio_analysis"
DB_TABLE_AUDIO_ANALYSIS_FAILURES: Final[str] = "audio_analysis_failures"
DB_TABLE_GENRES: Final[str] = "genres"
DB_TABLE_GENRE_MEDIA_ITEM_MAPPING: Final[str] = "genre_media_item_mapping"
DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION: Final[str] = "genre_media_item_exclusion"
Expand Down
2 changes: 1 addition & 1 deletion music_assistant/controllers/music/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Constants for the music controller."""

from __future__ import annotations
Expand All @@ -9,7 +9,7 @@
CONF_SYNC_INTERVAL = "sync_interval"
CONF_DELETED_PROVIDERS = "deleted_providers"

DB_SCHEMA_VERSION: Final[int] = 43
DB_SCHEMA_VERSION: Final[int] = 44

# tracks longer that this will not be included in radio mode
RADIO_TRACK_MAX_DURATION_SECS: Final[int] = 20 * 60
Expand Down
15 changes: 15 additions & 0 deletions music_assistant/controllers/music/database.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""
Database setup logic for the MusicController.

Expand Down Expand Up @@ -25,6 +25,7 @@
DB_TABLE_ALBUMS,
DB_TABLE_ARTISTS,
DB_TABLE_AUDIO_ANALYSIS,
DB_TABLE_AUDIO_ANALYSIS_FAILURES,
DB_TABLE_AUDIOBOOKS,
DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION,
DB_TABLE_GENRE_MEDIA_ITEM_MAPPING,
Expand Down Expand Up @@ -493,6 +494,20 @@
UNIQUE(item_id,provider,aa_provider_domain,media_type));"""
)

await self.database.execute(
f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_AUDIO_ANALYSIS_FAILURES}(
[id] INTEGER PRIMARY KEY AUTOINCREMENT,
[media_type] TEXT NOT NULL,
[item_id] TEXT NOT NULL,
[provider] TEXT NOT NULL,
[aa_provider_domain] TEXT NOT NULL,
[reason] TEXT NOT NULL,
[analysis_version] INTEGER NOT NULL DEFAULT 1,
[next_retry] INTEGER,
[timestamp_created] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
UNIQUE(item_id,provider,aa_provider_domain,media_type));"""
)

await self.database.commit()

async def __create_database_indexes(self) -> None:
Expand Down
201 changes: 182 additions & 19 deletions music_assistant/controllers/streams/audio_analysis.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Controller for distributing audio analysis to providers."""

from __future__ import annotations
Expand All @@ -20,13 +20,14 @@
from music_assistant.constants import (
CONF_BACKGROUND_SCAN_CONCURRENCY,
DB_TABLE_AUDIO_ANALYSIS,
DB_TABLE_AUDIO_ANALYSIS_FAILURES,
DB_TABLE_PROVIDER_MAPPINGS,
DEFAULT_BACKGROUND_SCAN_CONCURRENCY,
LOUDNESS_MEASUREMENT_MIN_LUFS,
MASS_LOGGER_NAME,
)
from music_assistant.helpers.api import api_command
from music_assistant.helpers.datetime import local_clock_time_to_utc
from music_assistant.helpers.datetime import local_clock_time_to_utc, utc_timestamp
from music_assistant.helpers.json import json_dumps, json_loads
from music_assistant.helpers.util import is_arm
from music_assistant.models.audio_analysis import AudioAnalysisData
Expand Down Expand Up @@ -65,6 +66,8 @@
LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.audio_analysis")

if TYPE_CHECKING:
from datetime import datetime

from music_assistant_models.media_items import AudioFormat
from music_assistant_models.streamdetails import StreamDetails

Expand Down Expand Up @@ -351,6 +354,92 @@
"analysis_version": analysis_version,
},
)
await self.clear_analysis_failure(
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
aa_provider_domain=aa_provider_domain,
media_type=media_type,
)

async def record_analysis_failure(
self,
item_id: str,
provider_instance_id_or_domain: str,
aa_provider_domain: str,
reason: str,
retry_at: datetime | None = None,
analysis_version: int = 1,
media_type: MediaType = MediaType.TRACK,
) -> None:
"""
Record an analysis failure for a track.

No-op when the provider does not resolve to a loaded music provider.

:param item_id: Provider-native item ID from streamdetails.item_id.
:param provider_instance_id_or_domain: Music provider instance ID or domain.
:param aa_provider_domain: Domain of the AA provider that failed.
:param reason: Human-readable failure reason.
:param retry_at: Timezone-aware datetime when to allow a retry; None (default)
means never auto-retry.
:param analysis_version: The AA provider's algorithm version at failure time.
:param media_type: The media type of the item.
"""
provider = self.mass.get_provider(provider_instance_id_or_domain)
if not isinstance(provider, MusicProvider):
self.logger.debug(
"Skipping failure record for %s: not a loaded music provider",
provider_instance_id_or_domain,
)
return
prov_key = provider.domain if provider.is_streaming_provider else provider.instance_id
await self.mass.music.database.insert_or_replace(
DB_TABLE_AUDIO_ANALYSIS_FAILURES,
{
"media_type": media_type.value,
"item_id": item_id,
"provider": prov_key,
"aa_provider_domain": aa_provider_domain,
"reason": reason,
"analysis_version": analysis_version,
"next_retry": int(retry_at.timestamp()) if retry_at is not None else None,
},
)

async def clear_analysis_failure(
self,
item_id: str,
provider_instance_id_or_domain: str,
aa_provider_domain: str,
media_type: MediaType = MediaType.TRACK,
) -> None:
"""
Delete a recorded analysis failure (e.g. after a later success).

No-op when the provider does not resolve to a loaded music provider.

:param item_id: Provider-native item ID from streamdetails.item_id.
:param provider_instance_id_or_domain: Music provider instance ID or domain.
:param aa_provider_domain: Domain of the AA provider whose failure to clear.
:param media_type: The media type of the item.
"""
provider = self.mass.get_provider(provider_instance_id_or_domain)
if not isinstance(provider, MusicProvider):
self.logger.debug(
"Skipping failure clear for %s: not a loaded music provider",
provider_instance_id_or_domain,
)
return
prov_key = provider.domain if provider.is_streaming_provider else provider.instance_id
await self.mass.music.database.delete(
DB_TABLE_AUDIO_ANALYSIS_FAILURES,
{
"item_id": item_id,
"provider": prov_key,
"aa_provider_domain": aa_provider_domain,
"media_type": media_type.value,
},
)

async def get_audio_analysis(
self,
Expand Down Expand Up @@ -675,6 +764,62 @@
analysis_version=provider.analysis_version,
)

@api_command("audio_analysis/failures")
async def get_failures(self, aa_domain: str | None = None) -> list[dict[str, Any]]:
"""
Return recorded analysis failures, optionally filtered by AA provider domain.

:param aa_domain: When given, only failures for this AA provider domain are returned.
"""
match = {"aa_provider_domain": aa_domain} if aa_domain is not None else None
rows = await self.mass.music.database.get_rows(
DB_TABLE_AUDIO_ANALYSIS_FAILURES, match, limit=0
)
return [
{
"item_id": r["item_id"],
"provider": r["provider"],
"aa_provider_domain": r["aa_provider_domain"],
"reason": r["reason"],
"next_retry": r["next_retry"],
"timestamp_created": r["timestamp_created"],
}
for r in rows
]

@api_command("audio_analysis/failures/clear")
async def clear_failures(
self,
item_id: str | None = None,
provider: str | None = None,
aa_domain: str | None = None,
) -> int:
"""
Delete recorded failures matching the given filters; returns the number deleted.

At least one filter is required; a call with all filters None deletes nothing.

:param item_id: Provider-native item ID to clear.
:param provider: Stored music-provider key (domain or instance_id) to clear.
:param aa_domain: AA provider domain to clear.
"""
match: dict[str, Any] = {}
if item_id is not None:
match["item_id"] = item_id
if provider is not None:
match["provider"] = provider
if aa_domain is not None:
match["aa_provider_domain"] = aa_domain
if not match:
return 0
rows = await self.mass.music.database.get_rows(
DB_TABLE_AUDIO_ANALYSIS_FAILURES, match, limit=0
)
count = len(rows)
if count:
await self.mass.music.database.delete(DB_TABLE_AUDIO_ANALYSIS_FAILURES, match)
return count

async def _run_background_scan(self) -> None:
"""Run the scan as decode-once-fan-out streaming over candidate tracks."""
providers = self.providers
Expand Down Expand Up @@ -895,18 +1040,19 @@
"""
Return tracks that need (re)analysis for one or more AA providers.

A track is a candidate for a given AA provider domain when it has no
analysis row for that domain, or when its stored row predates the
provider's current analysis_version (a NULL stored version, from
pre-versioning rows, is also treated as stale). This mirrors the
per-track version gate in AudioAnalysisProvider.start_analysis so a
provider bumping its analysis_version triggers a background re-scan.
A track is a candidate for a given AA provider domain when it has no analysis row for
that domain, when its stored row predates the provider's current analysis_version (a
NULL stored version, from pre-versioning rows, is also treated as stale), and when no
blocking failure row exists (a failure at the current-or-newer analysis_version whose
retry is NULL or still in the future). The version check mirrors the per-track gate in
AudioAnalysisProvider.start_analysis so a provider bumping its analysis_version triggers
a background re-scan.

:param aa_provider_versions: Mapping of AA provider domain to the
provider's current analysis_version.
:param aa_provider_versions: Mapping of AA provider domain to the provider's current
analysis_version.
:param limit: Maximum number of candidate rows to return (0 for no limit).
:returns: Rows {item_id, provider_instance, missing_domains} where
missing_domains lists the AA provider domains needing analysis.
:returns: Rows {item_id, provider_instance, missing_domains} where missing_domains
lists the AA provider domains needing analysis.
"""
if not aa_provider_versions:
return []
Expand All @@ -915,8 +1061,10 @@
if not filesystem_domains:
return []

# CROSS JOIN (track x possible domain), keep pairs with no up-to-date analysis
# row, GROUP_CONCAT the missing domains per track.
# CROSS JOIN (track x possible domain), keep pairs with no up-to-date analysis row and
# no blocking failure row, then GROUP_CONCAT the missing domains per track. An analysis
# row counts as up-to-date only when its analysis_version is non-NULL and >= the
# provider's current version, so missing and stale-version rows both surface.
aa_domains = list(aa_provider_versions)
fs_inline = ", ".join(f"'{d}'" for d in filesystem_domains)
aa_select_terms = " UNION ALL ".join(
Expand All @@ -925,6 +1073,7 @@
)
params: dict[str, Any] = {
"media_type": MediaType.TRACK.value,
"now": int(utc_timestamp()),
**{f"aa_{i}": d for i, d in enumerate(aa_domains)},
**{f"ver_{i}": aa_provider_versions[d] for i, d in enumerate(aa_domains)},
}
Expand All @@ -948,6 +1097,15 @@
f" AND aa.analysis_version IS NOT NULL "
f" AND aa.analysis_version >= possible.current_version"
f" ) "
f" AND NOT EXISTS ("
f" SELECT 1 FROM {DB_TABLE_AUDIO_ANALYSIS_FAILURES} f "
f" WHERE f.item_id = pm.provider_item_id "
f" AND f.provider = pm.provider_instance "
f" AND f.aa_provider_domain = possible.aa_provider_domain "
f" AND f.media_type = :media_type "
f" AND f.analysis_version >= possible.current_version "
f" AND (f.next_retry IS NULL OR f.next_retry > :now)"
f" ) "
f"GROUP BY pm.provider_item_id, pm.provider_instance"
)
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
Expand All @@ -966,12 +1124,7 @@
return results

async def _count_candidates_missing_analysis(self, aa_domain: str, current_version: int) -> int:
"""
Count filesystem candidate tracks needing (re)analysis for aa_domain.

A track is counted when it has no analysis row for the domain, or when
its stored analysis_version is NULL or less than current_version.
"""
"""Count filesystem candidate tracks lacking a current analysis row or blocking failure."""
filesystem_domains = self._available_filesystem_domains()
if not filesystem_domains:
return 0
Expand All @@ -988,6 +1141,15 @@
f" AND aa.media_type = :media_type "
f" AND aa.analysis_version IS NOT NULL "
f" AND aa.analysis_version >= :current_version"
f" ) "
f" AND NOT EXISTS ("
f" SELECT 1 FROM {DB_TABLE_AUDIO_ANALYSIS_FAILURES} f "
f" WHERE f.item_id = pm.provider_item_id "
f" AND f.provider = pm.provider_instance "
f" AND f.aa_provider_domain = :aa_domain "
f" AND f.media_type = :media_type "
f" AND f.analysis_version >= :current_version "
f" AND (f.next_retry IS NULL OR f.next_retry > :now)"
f" )"
)
return await self.mass.music.database.get_count_from_query(
Expand All @@ -996,6 +1158,7 @@
"media_type": MediaType.TRACK.value,
"aa_domain": aa_domain,
"current_version": current_version,
"now": int(utc_timestamp()),
},
)

Expand Down
19 changes: 19 additions & 0 deletions music_assistant/models/audio_analysis.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""
Data model for audio analysis results stored by Audio Analysis providers.

Expand All @@ -10,6 +10,7 @@
from __future__ import annotations

from dataclasses import dataclass, fields
from datetime import datetime
from typing import Any

import numpy as np
Expand All @@ -18,6 +19,24 @@
from mashumaro.config import BaseConfig


class AudioAnalysisError(Exception):
"""Raised by an Audio Analysis provider to fail the current analysis."""

def __init__(self, reason: str, retry_at: datetime | None = None) -> None:
"""
Initialize the error.

:param reason: Human-readable failure reason.
:param retry_at: Timezone-aware datetime when a retry is allowed; None (default)
means do not retry.
"""
if retry_at is not None and retry_at.tzinfo is None:
raise ValueError("retry_at must be timezone-aware")
super().__init__(reason)
self.reason = reason
self.retry_at = retry_at


@dataclass(kw_only=True)
class AudioAnalysisData(DataClassDictMixin):
"""Shared audio analysis attributes produced by Audio Analysis providers."""
Expand Down
Loading