Add Statistics Controller with playlog analytics - #5700
Conversation
- Implement StatisticsController for playlog analytics - Add get_top_items() supporting all MediaTypes - Query aggregates playlog data with SQL for performance - Return TopItemResult with ItemMapping and play_count - Fix provider instance IDs to domain-only for imageproxy - Add period filtering (today, week, month, year, all_time)
- Add controller name and description translations - Required for settings page and translations system
|
@dmoo500 The title or description of this pull request needs a fix:
Release notes are generated from the title, and the template carries what reviewers need, so please edit them before this is reviewed. |
There was a problem hiding this comment.
Pull request overview
Adds a core statistics controller for playlog-based listening analytics.
Changes:
- Adds statistics aggregation API endpoints.
- Registers and configures the controller.
- Adds statistics translations.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
music_assistant/translations/en.json |
Adds generated English translations. |
music_assistant/mass.py |
Loads and registers the controller. |
music_assistant/controllers/statistics/strings.json |
Defines statistics strings. |
music_assistant/controllers/statistics/controller.py |
Implements analytics endpoints. |
music_assistant/controllers/statistics/__init__.py |
Exports the controller. |
music_assistant/constants.py |
Marks statistics as configurable. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Remove user_id parameters from all API methods (security: prevent unauthorized access to other users' data) - Fix artist aggregation to group by item_id/provider instead of name (prevents merging distinct artists) - Remove provider instance ID stripping (preserves correct provider references) - Remove fake image path generation (only use real metadata images) - Add TODO for timezone-aware period calculations
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Suppressed comments (4)
music_assistant/controllers/statistics/controller.py:339
- [CRITICAL] The heatmap has the same event inflation as
plays_over_time: track playback can add artist/album playlog rows, and this unrestrictedCOUNT(*)treats each side-effect row as another listen. Filter to actual playable events before grouping the activity buckets.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:465
- [PROBLEM] This method is documented as paginated, but it exposes neither an offset/cursor nor a
played_beforeboundary;played_after_timestampcannot fetch the next older page. Add stable pagination throughrecently_played, or describe this as a bounded recent-history query instead.
) -> list[ItemMapping]:
"""
Get paginated play history with optional time range filtering.
music_assistant/controllers/statistics/controller.py:335
- [PROBLEM] SQLite
%wreturns Sunday as 0, while the companionHeatmapPointcontract defines Monday as 0, so every activity point is assigned to the wrong weekday. Normalize the SQLite value to the model's numbering.
This issue also appears on line 463 of the same file.
CAST(strftime('%w', datetime(timestamp, 'unixepoch')) AS INTEGER) as weekday,
music_assistant/controllers/statistics/controller.py:382
- [PROBLEM] The public contract says
group_bysupportsgenre, but every non-artist request silently returns an empty result. Either implement genre aggregation or remove/reject that advertised option so clients can distinguish unsupported input from a valid empty dataset.
# Group by artist using artist plays
# Estimate listening time: each artist play ≈ 3 minutes (180 seconds)
if group_by != "artist":
# Only artist grouping supported for now
return []
Security fix - get_played_item_ids() had the same issue as other methods: exposed user_id parameter allowed querying other users' data with only LIBRARY_READ scope. Fixed by deriving user_id from get_current_user() like all other statistics endpoints.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Suppressed comments (6)
music_assistant/controllers/statistics/controller.py:438
- [CRITICAL]
playlog.item_idis provider-scoped unlessplaylog.providerislibrary, whereasalbum_tracks.track_idis always a library database ID. This misses provider-scoped plays and can attribute coincidentally numeric provider IDs to unrelated library tracks; resolve provider items through provider mappings (or restrict this join to library rows) before joining album tracks.
INNER JOIN {DB_TABLE_ALBUM_TRACKS} as album_tracks
ON playlog.item_id = album_tracks.track_id
INNER JOIN {DB_TABLE_ALBUMS} as albums
ON album_tracks.album_id = albums.item_id
music_assistant/controllers/statistics/controller.py:217
- [PROBLEM] The database applies
limitbefore the user provider filter below, so excluded providers can consume every ranked slot and hide lower-ranked allowed artists. Apply the provider restriction in SQL before ordering and limiting.
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
music_assistant/controllers/statistics/controller.py:56
- [PROBLEM] These new client-visible commands are registered while
API_SCHEMA_VERSIONremains 49, so clients cannot gate use of the statistics API against server capability. Bump the schema version alongside the companion model/frontend changes (music_assistant/constants.py:49-51).
@api_command("statistics/top_items", required_scope=Scope.LIBRARY_READ)
music_assistant/controllers/statistics/controller.py:127
- [PROBLEM] This strips the provider instance from persisted images, but image resolution uses that exact provider ID to call the owning provider; multi-instance or provider-specific paths can therefore resolve through the wrong instance or fail. Preserve the serialized provider unchanged.
# Fix provider instance IDs to domain-only
if "provider" in image_dict and "--" in image_dict["provider"]:
image_dict["provider"] = image_dict["provider"].split("--")[0]
image = MediaItemImage.from_dict(image_dict)
music_assistant/controllers/statistics/controller.py:335
- [PROBLEM] SQLite
%wreturns Sunday as 0, while the sharedHeatmapPointAPI contract defines Monday as 0, so weekday consumers receive shifted activity data. Normalize the SQLite value to the documented convention.
CAST(strftime('%w', datetime(timestamp, 'unixepoch')) AS INTEGER) as weekday,
music_assistant/controllers/statistics/controller.py:106
- [PROBLEM] The database applies
limitbefore the user provider filter below, so excluded providers can consume every ranked slot and make this return fewer thanlimit(even empty) while allowed rows exist. Apply the provider restriction in SQL before ordering and limiting.
This issue also appears on line 217 of the same file.
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
Artists table has no provider column. Use name directly from playlog table instead of attempting JOIN. Still groups by (item_id, provider) to avoid merging distinct artists with same name.
New statistics API commands added: - statistics/top_items - statistics/artist_distribution - statistics/listening_activity - statistics/listening_time - statistics/decade_distribution - statistics/listening_clock - statistics/plays_over_time - statistics/played_item_ids
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Suppressed comments (9)
music_assistant/mass.py:1136
- [PROBLEM] New API commands and shared-model responses require an
API_SCHEMA_VERSIONbump, but the version remains 49; older clients otherwise cannot gate use of this statistics contract (seemusic_assistant/constants.py:49-51).
self.statistics,
music_assistant/controllers/statistics/controller.py:300
- [CRITICAL] This counts every playlog row as a play, but one track listen also writes side-effect artist and potentially album rows, so the trend overcounts listening events. Restrict the query to actual playable event media types (or use a dedicated play-event source).
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:339
- [CRITICAL] This heatmap also counts side-effect artist/album playlog rows, so a single listen can increment a bucket several times. Filter to the media types that represent actual listening events before grouping.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:217
- [PROBLEM] The query is limited before lines 226-228 apply the user's provider filter, which can discard all selected rows while omitting eligible artists ranked below them. Move the provider restriction into the query before
ORDER BY/LIMIT.
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
music_assistant/controllers/statistics/controller.py:382
- [PROBLEM] The public docstring promises
group_by='genre', but every non-artist value silently returns an empty success response. Either implement genre grouping or constrain/reject the parameter so callers do not interpret unsupported input as no listening history.
if group_by != "artist":
# Only artist grouping supported for now
return []
music_assistant/controllers/statistics/controller.py:127
- [PROBLEM] Stripping an image's provider instance can route resolution through the wrong account: image loading calls
mass.get_provider(image.provider), and provider-specific paths may only resolve on the original instance. Preserve the serialized provider value.
# Fix provider instance IDs to domain-only
if "provider" in image_dict and "--" in image_dict["provider"]:
image_dict["provider"] = image_dict["provider"].split("--")[0]
music_assistant/controllers/statistics/controller.py:335
- [PROBLEM] SQLite
%wencodes Sunday as 0, while the linkedHeatmapPointAPI model specifies Monday as 0, so consumers label every activity bucket with the wrong weekday. Convert the SQLite value to the model's numbering.
CAST(strftime('%w', datetime(timestamp, 'unixepoch')) AS INTEGER) as weekday,
music_assistant/controllers/statistics/controller.py:106
- [PROBLEM] The database applies
limitbefore lines 116-117 remove disallowed providers, so a user's top results can be empty or truncated even when allowed-provider items exist below the global cutoff. Apply the provider filter in SQL before ordering and limiting.
This issue also appears on line 217 of the same file.
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
music_assistant/controllers/statistics/controller.py:161
- [PROBLEM] This registered API command always reports an empty distribution, making “not implemented” indistinguishable from a user with no genre data and permanently adding a nonfunctional contract. Implement genre extraction or remove the command until it is supported.
This issue also appears on line 380 of the same file.
# TODO: Implement genre extraction from playlog
# For now, return empty list - requires genre metadata in playlog
return []
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (7)
music_assistant/controllers/statistics/controller.py:126
- [PROBLEM] The provider-instance stripping that was previously removed is present again: image resolution uses
image.providerto select the provider, so changing an instance ID such asspotify--accounttospotifycan select the wrong account. Preserve the serialized provider value.
# Fix provider instance IDs to domain-only
if "provider" in image_dict and "--" in image_dict["provider"]:
image_dict["provider"] = image_dict["provider"].split("--")[0]
music_assistant/controllers/statistics/controller.py:538
- [CRITICAL]
get_current_user()returns aUser, not its ID, so the database cannot bind this object to:user_idand this endpoint fails. Extractuser.user_idand handle a missing user as the other methods do.
user_id = get_current_user()
music_assistant/controllers/statistics/controller.py:206
- [CRITICAL] Grouping by provider identity does not make this name-only join safe: two library artists with the same name make every play join multiple rows, inflating
play_countand selecting arbitrary library metadata. Resolve throughprovider_mappingsusingap.item_id,ap.provider, and media type instead of matching display names.
LEFT JOIN {DB_TABLE_ARTISTS} a ON LOWER(a.name) = LOWER(ap.name)
music_assistant/controllers/statistics/controller.py:217
- [PROBLEM]
LIMITis applied before the user's provider filter, so disallowed providers consume the top slots and this can return fewer thanlimitdespite allowed artists existing. Apply the provider filter in the query before ordering and limiting.
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
music_assistant/controllers/statistics/controller.py:540
- [PROBLEM]
item_idis only unique within a provider; collapsing rows toDISTINCT item_idloses identity and can make a temporal filter treat an unrelated item from another provider as played. Return and compare both provider and item ID, preferably via a typed mapping.
SELECT DISTINCT item_id
music_assistant/controllers/statistics/controller.py:161
- [PROBLEM] This registered API command promises genre distribution but always returns an empty list, so clients cannot distinguish no listening data from an unimplemented endpoint. Implement it before registration, or remove the command until it is supported.
# TODO: Implement genre extraction from playlog
# For now, return empty list - requires genre metadata in playlog
return []
music_assistant/controllers/statistics/controller.py:106
- [PROBLEM]
LIMITis applied before the user's provider filter, so disallowed providers consume the top slots and this can return fewer thanlimitdespite allowed items existing. Apply the provider filter in SQL before ordering and limiting.
This issue also appears on line 217 of the same file.
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
- Fix get_played_item_ids to extract user_id from User object - Return (provider, item_id) tuples instead of bare item_id to avoid collapsing items from different providers - Fix weekday mapping: SQLite %w uses Sunday=0, convert to Monday=0 using (weekday + 6) % 7 transformation
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (8)
music_assistant/controllers/statistics/controller.py:207
- [CRITICAL] Grouping by provider identity does not fix this name-only join: if two library artists share a name, each play row joins both records, doubles
COUNT(*), and selects an arbitrary library ID/metadata. Join through provider mappings on(provider, item_id)—or directly by library ID—before joining artists.
LEFT JOIN {DB_TABLE_ARTISTS} a ON LOWER(a.name) = LOWER(ap.name)
GROUP BY ap.item_id, ap.provider
music_assistant/controllers/statistics/controller.py:382
- [PROBLEM] The public API documents and accepts
group_by="genre", but every non-artist value silently returns an empty result. Either implement genre grouping or restrict the API to artist grouping and reject unsupported values.
if group_by != "artist":
# Only artist grouping supported for now
return []
music_assistant/controllers/statistics/controller.py:217
- [PROBLEM] The database limit is applied before the provider access filter below, so denied providers can consume the selected rows and hide lower-ranked allowed artists. Apply the provider predicate inside this SQL query before limiting.
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
music_assistant/controllers/statistics/controller.py:300
- [CRITICAL] This query still counts every playlog media type, including artist and container rows written as side-effect credits for one playback, so a single listen contributes multiple points. Aggregate only actual playback-event rows or use a dedicated play-event source.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:339
- [CRITICAL] This heatmap also counts side-effect artist/container playlog rows, causing one playback to increment multiple cells. Restrict it to actual playback events or query a dedicated event source.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:127
- [PROBLEM] Preserve the serialized image provider instance: truncating
spotify--accounttospotifycan route image resolution through the wrong provider instance and return a broken or unauthorized image.
if "provider" in image_dict and "--" in image_dict["provider"]:
image_dict["provider"] = image_dict["provider"].split("--")[0]
music_assistant/controllers/statistics/controller.py:161
- [PROBLEM] This registered API command is guaranteed to return no data even though its name and docstring promise genre distribution. Implement it before registering the command, or remove the command until it is supported.
This issue also appears on line 380 of the same file.
# TODO: Implement genre extraction from playlog
# For now, return empty list - requires genre metadata in playlog
return []
music_assistant/controllers/statistics/controller.py:106
- [PROBLEM] The database limit is applied before the provider access filter below, so denied providers can consume the selected rows and make an allowed user's top list incomplete or empty. Add the allowed-provider predicate to the SQL before
ORDER BY/LIMIT.
This issue also appears on line 217 of the same file.
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Suppressed comments (6)
music_assistant/controllers/statistics/controller.py:300
- [CRITICAL] This aggregation counts every playlog row and ignores
user.provider_filter. A completed track also credits artist/container rows (music/controller.py:1452-1461), so the chart overcounts listens and includes activity from providers unavailable to the user; restrict the SQL to actual playable events and allowed providers before grouping.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:339
- [CRITICAL] This heatmap counts side-effect artist/container playlog rows as separate listens and does not apply
user.provider_filter, so its totals can be inflated and include inaccessible providers. Filter to actual playable events and allowed providers in SQL before deriving the buckets.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:382
- [PROBLEM] The API documents
genreas a supported grouping, but every non-artist request silently returns an empty result. Either implement genre grouping or remove/reject that advertised option so callers can distinguish unsupported input from no listening data.
if group_by != "artist":
# Only artist grouping supported for now
return []
music_assistant/controllers/statistics/controller.py:221
- [CRITICAL] Applying
provider_filterafter this limited query lets excluded artists occupy the top-N slots, and direct comparison also rejectslibraryartist rows. Filter provider access beforeORDER BY/LIMIT, resolving library mappings as needed, so this returns the actual top allowed artists.
rows = await self.mass.music.database.get_rows_from_query(query, params, limit=limit)
available_providers = ("library", *get_global_cache_value("available_providers", []))
user = get_current_user()
user_provider_filter = user.provider_filter if user and user.provider_filter else None
music_assistant/controllers/statistics/controller.py:127
- [PROBLEM] Stripping the provider instance from a stored image can make image resolution select the wrong account or fail, because image paths are provider-specific; preserve the serialized provider unchanged.
# Fix provider instance IDs to domain-only
if "provider" in image_dict and "--" in image_dict["provider"]:
image_dict["provider"] = image_dict["provider"].split("--")[0]
music_assistant/controllers/statistics/controller.py:161
- [PROBLEM] This registers a public API command that is guaranteed to return an empty result regardless of its arguments. Implement genre extraction before exposing the command, or remove the endpoint until it has functional behavior.
This issue also appears on line 380 of the same file.
# TODO: Implement genre extraction from playlog
# For now, return empty list - requires genre metadata in playlog
return []
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (6)
music_assistant/controllers/statistics/controller.py:292
- [CRITICAL] This counts every playlog row, including artist/album/container rows created as side effects of one track play (
_credit_artist_playswrites an artist row at the same timestamp), so the trend reports multiple plays for one listen. Aggregate only actual playback-event rows, ideally from the dedicated event source introduced with the playlog redesign.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:331
- [CRITICAL] The activity heatmap also counts side-effect artist/album/container rows, so a single track listen increments multiple cells. Restrict this aggregation to actual playback events rather than all playlog media types.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:374
- [PROBLEM] The documented
group_by='genre'option silently returns no data, so valid-looking client requests produce a misleading empty chart. Either implement genre grouping or reject/remove that option explicitly.
if group_by != "artist":
# Only artist grouping supported for now
return []
music_assistant/controllers/statistics/controller.py:123
- [PROBLEM] Preserve the image's provider instance ID: stripping
spotify--account/filesystem_local--instanceto a domain can make image resolution select a different provider instance and return the wrong image or fail for provider-local paths.
# Fix provider instance IDs to domain-only
if "provider" in image_dict and "--" in image_dict["provider"]:
image_dict["provider"] = image_dict["provider"].split("--")[0]
image = MediaItemImage.from_dict(image_dict)
music_assistant/controllers/statistics/controller.py:157
- [PROBLEM] This advertised API always returns an empty list, making unsupported genre analytics indistinguishable from a user with no data. Implement the aggregation or remove the public command until it is supported.
This issue also appears on line 372 of the same file.
# TODO: Implement genre extraction from playlog
# For now, return empty list - requires genre metadata in playlog
return []
music_assistant/controllers/statistics/strings.json:3
- [PROBLEM] Core-controller manifest translations must use
manifest.nameandmanifest.description; every other controller follows that shape (for examplecontrollers/players/strings.json:12-15andcontrollers/dashboard/strings.json:2-5). These flat keys generatecore.statistics.statistics*, so the manifest cannot resolve localized labels; nest them undermanifestand regeneratetranslations/en.json.
"statistics": "Statistics",
"statistics_description": "View listening statistics and analytics",
Replace delegation to recently_played() with direct playlog query to show complete user history without availability filtering
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (6)
music_assistant/controllers/statistics/controller.py:292
- [CRITICAL] This counts every playlog row, including container rows and artist-credit rows created as side effects of one track play (
player_queues/media_resolver.py:810-833,music/controller.py:2644-2682), so a single listen is counted multiple times. Restrict the aggregation to actual playable events or use the dedicated play-event source introduced by the storage redesign.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:331
- [CRITICAL] The heatmap also counts container and artist-credit side-effect rows as separate listens, so its hour/weekday totals are inflated and can place one playback in multiple time buckets. Aggregate only actual playable events from the redesigned play-event source.
COUNT(*) as play_count
FROM {DB_TABLE_PLAYLOG}
WHERE userid = :user_id
AND timestamp >= :cutoff_timestamp
music_assistant/controllers/statistics/controller.py:374
- [PROBLEM] The public contract advertises grouping by artist or genre, but every non-artist request silently returns no data. Implement genre grouping or reject/remove that option and update the companion client contract so a valid-looking request cannot masquerade as empty history.
if group_by != "artist":
# Only artist grouping supported for now
return []
music_assistant/controllers/statistics/controller.py:123
- [PROBLEM] This strips the persisted provider instance from the image again, so image resolution can select the wrong account (or no provider) for values such as
spotify--account; preserve the serialized provider reference unchanged.
if "provider" in image_dict and "--" in image_dict["provider"]:
image_dict["provider"] = image_dict["provider"].split("--")[0]
music_assistant/controllers/statistics/controller.py:157
- [PROBLEM] This registered API command always reports an empty distribution, making an unsupported endpoint indistinguishable from a user with no genre history. Implement the aggregation before exposing it, or remove the command and companion client call until genre data is available.
This issue also appears on line 372 of the same file.
# TODO: Implement genre extraction from playlog
# For now, return empty list - requires genre metadata in playlog
return []
music_assistant/controllers/statistics/strings.json:3
- [PROBLEM] Core-controller translations consistently use
manifest.nameandmanifest.description(for examplecontrollers/tasks/strings.json:9-12andcontrollers/dashboard/strings.json:2-5), but these keys generatecore.statistics.statistics*, leaving the standard manifest translation keys absent. Move them under amanifestobject and regeneratetranslations/en.json.
"statistics": "Statistics",
"statistics_description": "View listening statistics and analytics",
Implement @cache_statistics decorator with user-scoped cache keys to reduce database load on repeated queries
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (6)
music_assistant/controllers/statistics/controller.py:46
- [PROBLEM] This function-local dictionary never evicts expired keys, so high-cardinality calls such as
play_count(item_id=...)andplay_history(played_after_timestamp=...)permanently grow server memory. Useself.mass.cachewith the user ID in the key, or a bounded TTL cache that removes expired entries.
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
cache: dict[str, tuple[float, Any]] = {}
music_assistant/controllers/statistics/controller.py:165
- [CRITICAL] Stripping the provider instance changes how non-HTTP image paths are resolved: image loading calls
mass.get_provider(image.provider), so this can select the wrong account or make the image unavailable. Preserve the provider identifier stored in the playlog.
# Fix provider instance IDs to domain-only
if "provider" in image_dict and "--" in image_dict["provider"]:
image_dict["provider"] = image_dict["provider"].split("--")[0]
music_assistant/controllers/statistics/controller.py:347
- [CRITICAL]
get_rows_from_querydefaults to 500 rows, but a month of hourly data can contain 720 buckets; because this query sorts ascending, the latest buckets are silently omitted. Disable the helper cap so the requested time series is complete.
rows = await self.mass.music.database.get_rows_from_query(query, params)
music_assistant/controllers/statistics/controller.py:632
- [CRITICAL]
get_rows_from_queryapplies its default 500-row cap here, so users with more than 500 distinct recently played items receive an arbitrary subset and temporal filters can treat played tracks as unplayed. Fetch the complete distinct result.
rows = await self.mass.music.database.get_rows_from_query(query, params)
music_assistant/controllers/statistics/controller.py:633
- [PROBLEM] Because this method is registered as an API command, this set is serialized as an arbitrarily ordered JSON array (
helpers/json.py:33-36), making the response nondeterministic. Return a sorted list and update the return annotation, or remove API exposure if set semantics are only needed internally.
return {(row["provider"], row["item_id"]) for row in rows}
music_assistant/controllers/statistics/controller.py:508
- [PROBLEM] This endpoint is documented as paginated, but it has neither an offset nor a “played before” cursor; with descending order,
played_after_timestamponly narrows results to newer entries and cannot fetch page two of older history. Add an offset/before cursor and pass it to the query (or delegate tomusic.recently_playedas proposed in the linked RFC).
Get paginated play history with optional time range filtering.
What does this implement/fix?
Implements a new Statistics Controller that provides playlog analytics for tracks, albums, artists, and playlists. Users can view their listening history with charts showing top items, artist distribution, listening patterns, and more.
This addresses part of the "Statistics view" feature request from issue #21.
UNIQUE(item_id, provider, media_type, userid)constraint causes repeat plays to overwrite instead of creating new entries. See this Discord discussion for proposed solutions. Statistics currently show last play time rather than total play counts.Related issue (if applicable):
Features
Implementation Details
DB_TABLE_PLAYLOGwith period filteringDB_TABLE_ARTISTSby LOWER(name) for case-insensitive matching + artworkcontrollers/statistics/strings.jsonTypes of changes
new-featureChecklist
pre-commit run --all-filespasses.pytestpasses, and tests have been added/updated undertests/where applicable.music-assistant/modelsis linked.music-assistant/frontendis linked.Related PRs