Skip to content

Add Statistics Controller with playlog analytics - #5700

Draft
dmoo500 wants to merge 12 commits into
music-assistant:devfrom
dmoo500:feat/statistics-controller
Draft

Add Statistics Controller with playlog analytics#5700
dmoo500 wants to merge 12 commits into
music-assistant:devfrom
dmoo500:feat/statistics-controller

Conversation

@dmoo500

@dmoo500 dmoo500 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

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.

⚠️ Note: Currently blocked by a playlog database design issue - the 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

  • Top Items: Get top played tracks/albums/artists/playlists by period (Today/Week/Month/Year/All Time)
  • Artist Distribution: Aggregate play counts by artist (extracted from track plays via json_each)
  • Listening Activity: Hour/weekday heatmap showing when you listen most
  • Listening Time: Estimated total listening time by period
  • Decade Distribution: Music by decade statistics
  • Listening Clock: 24-hour polar chart of listening patterns

Implementation Details

  • Uses SQL aggregation on DB_TABLE_PLAYLOG with period filtering
  • Artist data extracted from track plays using CTE + json_each on artists JSON column
  • LEFT JOIN to DB_TABLE_ARTISTS by LOWER(name) for case-insensitive matching + artwork
  • All endpoints support user isolation via get_current_user()
  • New i18n strings in controllers/statistics/strings.json

Types of changes

  • New feature (non-breaking change which adds functionality) — new-feature

Checklist

  • The code change is tested and works locally.
  • pre-commit run --all-files passes.
  • pytest passes, and tests have been added/updated under tests/ where applicable.
  • For changes to shared models, the companion PR in music-assistant/models is linked.
  • For changes affecting the UI, the companion PR in music-assistant/frontend is linked.
  • I have read and complied with the project's AI Policy for any AI-assisted contributions.

Related PRs

Moos, Daniel added 3 commits August 14, 2026 22:53
- 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
Copilot AI balanced review requested due to automatic review settings August 15, 2026 10:55
@musicassistant-bot

musicassistant-bot Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

@dmoo500 The title or description of this pull request needs a fix:

  • Checklist item not ticked: pytest passes, and tests have been added/updated under tests/ where applicable.

Release notes are generated from the title, and the template carries what reviewers need, so please edit them before this is reviewed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py Outdated
Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py Outdated
Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py Outdated
Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py
- 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
Copilot AI review requested due to automatic review settings August 15, 2026 12:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 unrestricted COUNT(*) 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_before boundary; played_after_timestamp cannot fetch the next older page. Add stable pagination through recently_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 %w returns Sunday as 0, while the companion HeatmapPoint contract 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_by supports genre, 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 []

Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py Outdated
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.
Copilot AI review requested due to automatic review settings August 15, 2026 12:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_id is provider-scoped unless playlog.provider is library, whereas album_tracks.track_id is 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 limit before 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_VERSION remains 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 %w returns Sunday as 0, while the shared HeatmapPoint API 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 limit before the user provider filter below, so excluded providers can consume every ranked slot and make this return fewer than limit (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)

Comment thread music_assistant/controllers/statistics/controller.py Outdated
Comment thread music_assistant/controllers/statistics/controller.py Outdated
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.
Copilot AI review requested due to automatic review settings August 15, 2026 13:09
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_VERSION bump, but the version remains 49; older clients otherwise cannot gate use of this statistics contract (see music_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 %w encodes Sunday as 0, while the linked HeatmapPoint API 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 limit before 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 []

Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py Outdated
Comment thread music_assistant/controllers/statistics/controller.py Outdated
Copilot AI review requested due to automatic review settings August 15, 2026 13:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.provider to select the provider, so changing an instance ID such as spotify--account to spotify can 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 a User, not its ID, so the database cannot bind this object to :user_id and this endpoint fails. Extract user.user_id and 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_count and selecting arbitrary library metadata. Resolve through provider_mappings using ap.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] LIMIT is applied before the user's provider filter, so disallowed providers consume the top slots and this can return fewer than limit despite 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_id is only unique within a provider; collapsing rows to DISTINCT item_id loses 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] LIMIT is applied before the user's provider filter, so disallowed providers consume the top slots and this can return fewer than limit despite 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)

Comment thread music_assistant/controllers/statistics/controller.py Outdated
- 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
Copilot AI review requested due to automatic review settings August 15, 2026 13:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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--account to spotify can 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)

Comment thread music_assistant/controllers/statistics/controller.py
Copilot AI review requested due to automatic review settings August 15, 2026 13:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 genre as 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_filter after this limited query lets excluded artists occupy the top-N slots, and direct comparison also rejects library artist rows. Filter provider access before ORDER 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 []

Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py
Comment thread music_assistant/controllers/statistics/controller.py Outdated
Comment thread music_assistant/controllers/statistics/controller.py Outdated
Copilot AI review requested due to automatic review settings August 15, 2026 14:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_plays writes 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--instance to 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.name and manifest.description; every other controller follows that shape (for example controllers/players/strings.json:12-15 and controllers/dashboard/strings.json:2-5). These flat keys generate core.statistics.statistics*, so the manifest cannot resolve localized labels; nest them under manifest and regenerate translations/en.json.
  "statistics": "Statistics",
  "statistics_description": "View listening statistics and analytics",

Comment thread music_assistant/controllers/statistics/controller.py Outdated
@OzGav OzGav added this to the 2.11.0 milestone Aug 15, 2026
Replace delegation to recently_played() with direct playlog query
to show complete user history without availability filtering
Copilot AI review requested due to automatic review settings August 15, 2026 16:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.name and manifest.description (for example controllers/tasks/strings.json:9-12 and controllers/dashboard/strings.json:2-5), but these keys generate core.statistics.statistics*, leaving the standard manifest translation keys absent. Move them under a manifest object and regenerate translations/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
Copilot AI review requested due to automatic review settings August 15, 2026 17:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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=...) and play_history(played_after_timestamp=...) permanently grow server memory. Use self.mass.cache with 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_query defaults 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_query applies 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_timestamp only 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 to music.recently_played as proposed in the linked RFC).
        Get paginated play history with optional time range filtering.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants