Skip to content

feat: offline playlist & album downloader#191

Open
tbrackbill wants to merge 9 commits into
dddevid:masterfrom
tbrackbill:pr/offline-downloader
Open

feat: offline playlist & album downloader#191
tbrackbill wants to merge 9 commits into
dddevid:masterfrom
tbrackbill:pr/offline-downloader

Conversation

@tbrackbill
Copy link
Copy Markdown
Contributor

@tbrackbill tbrackbill commented May 16, 2026

Note: This is a self-contained optional feature — if you'd rather not merge it, no hard feelings. The existing codebase is completely unaffected if you close it.

Summary

Adds a full offline download system for playlists and albums, built on top of the existing OfflineService skeleton.

  • Download queue — playlists/albums are queued sequentially with configurable parallel song downloads per batch (default 3, max 5)
  • Intent-based tracking — two new ValueNotifier<Set<String>> fields (queuedPlaylistIds, downloadedPlaylistIds) drive badges independently of individual song file presence, so cross-playlist songs don't create false positives
  • Reactive badges — outline check (queued/in-progress) and solid check (fully downloaded) appear on playlist list tiles, album header, and playlist header; individual song tiles get a download dot
  • Offline album art — cover art is saved both per-song and indexed by coverArtId so album/playlist views can load artwork offline
  • Resume on restart — queued playlists are persisted to SharedPreferences and re-queued at startup if interrupted
  • Wake lock — keeps screen on during downloads (user-configurable), always disabled on completion or cancel
  • Download status screen — per-song progress log accessible from the active download indicator
  • Settings panel — parallel download count slider, screen-on toggle, delete-all button with size display

Files changed

Area What changed
offline_service.dart Queue processor, intent notifiers, wake lock, disk reconciliation
playlist_screen.dart / album_screen.dart Download button, state machine, badges
playlists_screen.dart / library_screen.dart List-level badges
song_tile.dart Per-song download indicator
album_artwork.dart Offline art fallback
download_playlist_status_screen.dart New: per-song progress log
settings_storage_tab.dart New: download settings UI
subsonic_service.dart getDownloadUrl() for original-file endpoint

Summary by CodeRabbit

  • New Features

    • Enhanced offline download system with queue-based management and visual status indicators
    • New "Playlist Downloads" screen to track offline content progress
    • Download status icons showing completed and queued playlists
    • Resume interrupted downloads on app startup
    • Options to cancel and remove offline content
  • Chores

    • Updated build version

Review Change Stack

tbrackbill and others added 9 commits May 15, 2026 15:26
OfflineService:
- Add downloadedSongIds ValueNotifier (Set<String>) seeded from SharedPrefs
  on initialize(), updated on download success/delete so UI reacts without polling
- Add downloadLog ValueNotifier (List<DownloadLogEntry>) with per-song
  queued/downloading/done/failed status, reset each batch
- Extend DownloadState with currentSong and failedSongs for active-download panel
- Add getPlaylistDownloadStatus() helper returning (downloaded, total)
- Add DownloadStatus enum and DownloadLogEntry model

SongTile:
- _buildTrailing() wrapped in ValueListenableBuilder on downloadedSongIds;
  shows 14px green check_circle badge when song is locally downloaded

PlaylistScreen / AlbumScreen:
- Artwork wrapped in ValueListenableBuilder on downloadedSongIds; shows
  green check_circle badge when every song in the collection is downloaded

Settings (Offline Downloads section):
- Active Downloads row: shows current song artist-title and X/Y progress
  while a batch runs, otherwise "No downloads in progress"
- Playlist Downloads row: shows total downloaded-song count from reactive set;
  navigation to per-playlist breakdown wired in feature/download-detail-screens

Build:
- android/gradle.properties: add android.newDsl=false (AGP 8.11 compat)
- Dockerfile.release + build-release-apk.sh: retained for reference but
  native build via /rw/flutter is the preferred approach going forward

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add green check badge to _PlaylistTile when all songs downloaded
- Create DownloadPlaylistStatusScreen showing downloaded/total per playlist
- Wire Settings > Playlist Downloads row to navigate to new screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Switch downloadSong() from /stream to /download so files are always
  the original, non-transcoded version and match song.size exactly
- Persist expected file sizes to SharedPrefs before each download so
  reconciliation works even if the app is killed mid-batch
- initialize() now scans the offline dir and recovers any valid files
  that landed on disk but weren't indexed (handles interrupted downloads)
- isSongDownloaded() uses stored expected size when available, 64 KB
  floor as fallback for songs where size is unknown
- One automatic retry pass for failed songs at end of each batch
- deleteSong() and deleteAllDownloads() clean up the expected sizes map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- queuePlaylistDownload() replaces direct startBackgroundDownload calls;
  multiple playlists/albums stack in a sequential download queue processed
  one batch at a time
- queuedPlaylistIds ValueNotifier drives an outline check_circle_outline
  badge in playlists list and Library view when a playlist is queued but
  not yet fully downloaded; fills to check_circle when complete
- Queued state (including full song list JSON) is persisted to SharedPrefs
  so the badge survives app restarts
- initialize() loads queued playlist data and unmarks any that are now
  fully on disk after the reconciliation scan
- PlayerProvider resumes incomplete queued downloads automatically on
  startup via resumeIncompleteDownloads()
- deleteAllDownloads() clears the queue and queued playlist data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Playlist/album download spinner now driven by queuedPlaylistIds
  ValueNotifier instead of local _isDownloading state, so the spinner
  stays visible for the full duration of the download — including when
  additional playlists are queued while one is already in progress
- Also save cover art indexed by song.coverArt ID (art_{id}.jpg) so
  AlbumArtwork can resolve it offline for album/playlist grid views;
  previously art was only stored by song ID and never found by the widget

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace fragile nested ValueListenableBuilder with explicit listeners:
- _allDownloaded and _isQueued driven by addListener on OfflineService
  notifiers; state is updated via _updateDownloadState() called on load
  and on every notifier change, ensuring correct initial state
- Button has 3 clean states: cloud_download (idle) → spinner (queued/
  downloading, tap cancels) → cloud_done green (complete, tap removes)
- Cancel: removes playlist from queue and stops active download
- Remove: confirmation dialog then deletes all songs in playlist/album
- Added cancelPlaylistDownload() and deletePlaylistDownloads() to OfflineService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- cancelPlaylistDownload: only kill the active download when the target
  playlist is currently running; cancelling a queued-but-not-started
  entry no longer stops an unrelated active download
- _updateDownloadState: disk-check fallback now guarded by _isQueued
  (previous state) so it only fires on the queued→not-queued transition,
  not on every song-download event for every open screen
- deletePlaylistDownloads: accepts List<Song> and deletes art_{id}.jpg
  files alongside song files so stale offline art doesn't persist after
  removing downloads
- AlbumArtwork: skip File.existsSync() when downloadedSongIds is empty,
  avoiding synchronous disk stats on every build for non-downloading users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace file-presence checks with a dedicated downloadedPlaylistIds
notifier so cross-playlist songs don't produce false solid-check badges.
The solid check now reflects explicit download intent rather than
whether individual audio files happen to exist on disk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…badges

- Move WakelockPlus.disable() outside retry block so it always fires
- cancelBackgroundDownload() before wiping index in deleteAllDownloads
- Remove shared art_* deletion from deletePlaylistDownloads (cross-playlist safe)
- Swap downloadedSongIds listeners to downloadedPlaylistIds in playlist/album screens
- Simplify _updateDownloadState to intent-based contains() lookup
- Version 1.0.13+4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 16, 2026

📝 Walkthrough

Walkthrough

This PR introduces a stateful, queue-driven offline download system with reactive UI updates and adds release build infrastructure. The offline service now validates downloads by expected file size, tracks playlist-level completion intent, and resumes incomplete batches on app startup. Screens transition from polling/flag-based UI to listener-driven state, displaying download progress and status badges throughout the app. Release Docker and Bash tooling automate APK production.

Changes

Offline Download State Management & UI

Layer / File(s) Summary
OfflineService state models and core logic
lib/services/offline_service.dart
DownloadStatus enum, DownloadLogEntry model, and expanded DownloadState with current song and failed-song tracking. Four new reactive notifiers (downloadedSongIds, downloadLog, queuedPlaylistIds, downloadedPlaylistIds) drive UI state. Reworked initialize() loads expected-size indexing, reconciles SharedPreferences against on-disk .mp3 files, restores queued/downloaded playlist metadata, and unmarked completed playlists. Implements queuePlaylistDownload(), _processQueue(), and resumeIncompleteDownloads() for sequential batch-aware downloads.
Song download and validation logic
lib/services/offline_service.dart, lib/services/subsonic_service.dart
downloadSong() now persists expected size before download, uses /rest/download endpoint (via new SubsonicService.getDownloadUrl()), validates file size, and updates reactive downloadedSongIds on success. Cover art indexed by sanitized coverArtId for offline lookup. isSongDownloaded validates file size with 64KB fallback. Deletion keeps disk and in-memory expected-size indexes consistent.
Background download batch execution and retry
lib/services/offline_service.dart
Batch setup resets downloadLog and seeds downloadState from already-downloaded items. Per-item download updates log/state tracking current song and failures. One-pass retry for failed songs, then cleanup via clearCurrentSong. Active download stop and queued-state cleanup on deleteAllDownloads().
Player startup download resumption
lib/providers/player_provider.dart
PlayerProvider._initializePlayer() calls resumeIncompleteDownloads(_subsonicService) after offline service initialization.
PlaylistScreen state-driven download UI
lib/screens/playlist_screen.dart
Refactored from _isDownloading flag to reactive _allDownloaded/_isQueued state via listeners on OfflineService playlist notifiers. _updateDownloadState() syncs state after playlist load. queuePlaylistDownload() replaces background download, with _cancelDownload() and _removeDownloads() helpers. _buildDownloadButton() renders conditional icons. Artwork overlay and app bar action button both updated to reactive state.
AlbumScreen state-driven download UI
lib/screens/album_screen.dart
Mirror refactor to PlaylistScreen: reactive state (_allDownloaded, _isQueued), listeners on playlist notifiers, _updateDownloadState() sync, queuePlaylistDownload(), download/cancel/remove helpers, _buildDownloadButton(), artwork overlay via ValueListenableBuilder, and swapped app bar action.
New DownloadPlaylistStatusScreen
lib/screens/download_playlist_status_screen.dart
Stateless screen displaying per-playlist download progress using LibraryProvider and OfflineService().downloadedSongIds via ValueListenableBuilder. Shows download counts, completion status, and check icons per playlist.
Settings download status rows and navigation
lib/screens/settings_storage_tab.dart
Added "Active Downloads" row (from downloadState, progress/track display, disabled tap) and "Playlist Downloads" row (from downloadedSongIds, count display, navigates to DownloadPlaylistStatusScreen).
Library and playlist list download status UI
lib/screens/library_screen.dart, lib/screens/playlists_screen.dart
LibraryScreen renders nested ValueListenableBuilders over downloaded/queued playlist ID sets for status icons; calls cancelPlaylistDownload() before deletion. PlaylistsScreen similarly cancels downloads on deletion and replaces static trailing chevron with reactive status icons.
Widget-level offline artwork and download badges
lib/widgets/album_artwork.dart, lib/widgets/song_tile.dart
AlbumArtwork checks downloadedSongIds and returns Image.file for offline cover art with offline-specific cache keys, falling back to placeholder. SongTile._buildTrailing wraps trailing row in ValueListenableBuilder over downloadedSongIds, conditionally inserting green checkmark when song is downloaded.

Release Build Infrastructure

Layer / File(s) Summary
Release Docker build and extract automation
Dockerfile.release, build-release-apk.sh
Dockerfile.release provisions OpenJDK 17, Android SDK 35, build-tools 35.0.0, NDK r27, CMake 3.22.1, and Flutter stable, then executes flutter pub get and flutter build apk --release with BuildKit cache mounts. build-release-apk.sh builds the image, extracts app-release.apk from a temporary container, and copies it to a configurable output directory as musly-release.apk.
Gradle and version configuration
android/gradle.properties, pubspec.yaml
android.newDsl set to false and pubspec.yaml version bumped from 1.0.13+1 to 1.0.13+4.

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly Related PRs

  • dddevid/Musly#115: Initial AlbumScreen album download button implementation; this PR's refactor from _isDownloading background flow to queue-based state-driven UI directly extends that work.

🐰 A rabbit hops through downloads with glee,
No more flags to chase, just reactive spree!
State flows true, from queue to screen—
Offline playlists best yet seen!
Docker seals the release with care,
APK artifacts, everywhere!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: offline playlist & album downloader' accurately summarizes the main objective of the pull request: adding an offline download system for playlists and albums.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🧹 Nitpick comments (4)
lib/screens/download_playlist_status_screen.dart (1)

14-14: 💤 Low value

Consider listen: false for LibraryProvider if playlists are stable.

The screen uses Provider.of<LibraryProvider>(context) without listen: false, which rebuilds the entire screen whenever LibraryProvider notifies listeners. If playlists rarely change while this screen is visible, adding listen: false would prevent unnecessary rebuilds since the ValueListenableBuilder already handles download state reactively.

⚡ Optional performance tweak
-    final libraryProvider = Provider.of<LibraryProvider>(context);
+    final libraryProvider = Provider.of<LibraryProvider>(context, listen: false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/screens/download_playlist_status_screen.dart` at line 14, The screen
calls Provider.of<LibraryProvider>(context) which causes the widget to rebuild
on provider updates; change this to Provider.of<LibraryProvider>(context,
listen: false) (or otherwise obtain the provider with listen: false) so that the
top-level download_playlist_status_screen widget does not rebuild
unnecessarily—update the usage where the variable libraryProvider is assigned in
download_playlist_status_screen.dart to pass listen: false.
lib/services/offline_service.dart (2)

162-168: 💤 Low value

Unsafe cast may throw if persisted data is malformed.

If _keyQueuedPlaylistData contains corrupted or unexpected JSON structure, the cast (v as List).cast<Map<String, dynamic>>() will throw a CastError at runtime. Consider wrapping this in a try-catch like the outer block or validating structure before casting.

Proposed safer parsing
       try {
         final raw = json.decode(queuedDataJson) as Map<String, dynamic>;
-        _queuedPlaylistData = raw.map(
-          (k, v) => MapEntry(k, (v as List).cast<Map<String, dynamic>>()),
-        );
+        _queuedPlaylistData = {};
+        for (final entry in raw.entries) {
+          if (entry.value is List) {
+            final songs = <Map<String, dynamic>>[];
+            for (final item in entry.value as List) {
+              if (item is Map<String, dynamic>) songs.add(item);
+            }
+            _queuedPlaylistData[entry.key] = songs;
+          }
+        }
       } catch (_) {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/services/offline_service.dart` around lines 162 - 168, The current
deserialization of queuedDataJson naively casts entries which can throw a
CastError; update the parsing for json.decode(queuedDataJson) so you validate
types before casting: ensure the decoded value is a Map<String, dynamic>,
iterate its entries, check each value is a List, and for each list item verify
it's a Map<String, dynamic> (skip or drop invalid items) before building
_queuedPlaylistData; wrap the whole operation in a try-catch to preserve the
existing failure-silent behavior and fall back to an empty map or previous state
if parsing fails.

752-752: 💤 Low value

Redundant _downloadQueue.clear() call.

_downloadQueue.clear() is called twice: once at line 733 and again at line 752. The second call is unnecessary.

Remove duplicate clear
       _expectedSizes = {};
       _queuedPlaylistData = {};
-      _downloadQueue.clear();
       queuedPlaylistIds.value = {};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/services/offline_service.dart` at line 752, The second redundant call to
_downloadQueue.clear() should be removed: locate the method that manipulates
_downloadQueue (the block calling _downloadQueue.clear() twice) and delete the
later invocation of _downloadQueue.clear() so the queue is only cleared once
(keep the first clear where it currently executes and ensure no other logic
depends on a second clear).
lib/providers/player_provider.dart (1)

1127-1132: 💤 Low value

Fire-and-forget download resumption has no error handling.

The .then() callback invokes resumeIncompleteDownloads but any exceptions are silently swallowed. If _subsonicService is not yet configured or the resume fails, there's no feedback. Consider adding .catchError() for logging.

Proposed fix with error handling
     // Resume any playlists that were queued for download but interrupted
     _offlineService.initialize().then((_) {
       _offlineService.resumeIncompleteDownloads(_subsonicService);
-    });
-
+    }).catchError((e) {
+      debugPrint('Failed to resume incomplete downloads: $e');
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/providers/player_provider.dart` around lines 1127 - 1132, The
fire-and-forget call to _offlineService.initialize().then((_) {
_offlineService.resumeIncompleteDownloads(_subsonicService); }) lacks error
handling and can swallow exceptions (including cases where _subsonicService is
null); change this to await or to a promise chain that catches errors (e.g., use
.then(...).catchError(...) or an async function with try/catch) and log any
errors using the module's logger so failures in _offlineService.initialize() or
_offlineService.resumeIncompleteDownloads(_subsonicService) are reported; also
validate _subsonicService before calling resumeIncompleteDownloads and log a
clear message if it is not configured.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@build-release-apk.sh`:
- Around line 9-23: The script uses a fixed CONTAINER_NAME ("musly-apk-build")
which can collide across concurrent runs; change CONTAINER_NAME in
build-release-apk.sh to a unique value (e.g., include $$, a timestamp, or a
generated UUID) and use that variable consistently for docker create, docker cp
and docker rm; also add a trap cleanup that removes the container name on exit
to avoid leftover containers (references: CONTAINER_NAME, docker create --name,
docker cp, docker rm -f).

In `@Dockerfile.release`:
- Around line 41-43: Replace the non-deterministic clone of Flutter that uses
--branch stable with a pinned SDK tag or commit so releases are reproducible:
update the RUN that clones into /opt/flutter to fetch a specific release (e.g.,
use --branch vX.Y.Z or clone then git checkout <tag_or_commit>) and keep the git
config --global --add safe.directory /opt/flutter; choose and document the exact
Flutter tag/commit to pin (replace "stable" with the chosen tag in the RUN
command or add an explicit git checkout after clone).

In `@lib/screens/album_screen.dart`:
- Around line 111-178: Replace hardcoded English UI strings in _downloadAlbum,
_removeDownloads, and _buildDownloadButton with localized values from
AppLocalizations (e.g. call AppLocalizations.of(context) inside these
methods/builders and use resource keys instead of literal text); specifically
localize the SnackBar message in _downloadAlbum, the AlertDialog title/content
and button labels in _removeDownloads, and all IconButton tooltip texts in
_buildDownloadButton, adding new localization keys
(queuedSongs/removeDownloadsTitle/removeDownloadsContent/cancelLabel/removeLabel/downloadedTooltip/downloadingTooltip/downloadAlbumTooltip
or similar) to your ARB/locale files and use those keys via AppLocalizations in
the referenced methods.

In `@lib/screens/library_screen.dart`:
- Around line 649-650: The call to
OfflineService().cancelPlaylistDownload(item.id) can throw and prevent
libraryProvider.deletePlaylist(item.id) from running; wrap the
cancelPlaylistDownload call in a try/catch that catches and logs the error
(including context and item.id) but does not rethrow, then always call
libraryProvider.deletePlaylist(item.id) so playlist deletion proceeds regardless
of offline cleanup failure.

In `@lib/screens/playlist_screen.dart`:
- Around line 303-371: Replace hardcoded English UI strings in
_downloadPlaylist, _removeDownloads, _cancelDownload (SnackBar text, AlertDialog
title/content/button labels) and the tooltips in _buildDownloadButton with
localized values from AppLocalizations (e.g., final loc =
AppLocalizations.of(context)!). Use loc.* getters for the snackbar message
("Queued X songs for download…"), the dialog title/content and button labels
("Remove downloads?", "Cancel", "Remove"), and the three tooltips ("Downloaded —
tap to remove", "Downloading — tap to cancel", "Download playlist"); ensure
pluralization or interpolation uses loc methods if available and pass
songs.length into the localized string where needed.
- Around line 420-463: The primary header (SliverAppBar) artwork lacks the
downloaded badge because the badge rendering is only inside the reorder-only
branch; update the SliverAppBar artwork builder to use the same download-state
logic as the ValueListenableBuilder currently referencing
OfflineService().downloadedPlaylistIds (and the allDownloaded boolean) so the
check icon is shown in both places: reuse the same condition
(downloaded.contains(widget.playlistId) / allDownloaded) and render the
Positioned check badge around the artwork (same styling used where
_playlist!.coverArt / AlbumArtwork is rendered) so the header shows the
completed-download badge outside of reorder mode as well.

In `@lib/screens/playlists_screen.dart`:
- Around line 160-161: The cancelPlaylistDownload call can throw and prevent
deletion; wrap the call to OfflineService().cancelPlaylistDownload(playlist.id)
in its own try/catch so failures are logged/handled but do not stop execution,
then always call libraryProvider.deletePlaylist(playlist.id) (or rethrow only
after deletion if needed); reference the symbols cancelPlaylistDownload,
deletePlaylist and playlist.id and ensure the catch does not swallow important
errors (log or report them) before proceeding to delete.

In `@lib/screens/settings_storage_tab.dart`:
- Around line 718-756: Localize the hardcoded strings in
_buildPlaylistStatusRow: replace the title 'Playlist Downloads' with
AppLocalizations.of(context).playlistDownloads and the subtitle '${ids.length}
songs downloaded' with the pluralized/localized call
AppLocalizations.of(context).songsDownloaded(ids.length) (or your project's
plural API), and add the two keys to AppLocalizations: playlistDownloads and
songsDownloaded(int count); keep the rest of the ListTile (leading, trailing,
navigation to DownloadPlaylistStatusScreen, and usage of
_offlineService.downloadedSongIds) unchanged.
- Around line 670-716: The ListTile title and fallback subtitle in
_buildActiveDownloadsRow are hardcoded; replace the strings with localized
values from AppLocalizations (use AppLocalizations.of(context)!.activeDownloads
for the title and AppLocalizations.of(context)!.noDownloadsInProgress for the
'No downloads in progress' fallback used when state.isDownloading is false), and
add the corresponding keys activeDownloads and noDownloadsInProgress to your
localization resource files and generated AppLocalizations class so the compiler
picks them up.

In `@lib/services/offline_service.dart`:
- Around line 606-619: The retry loop updates downloadState.failedSongs but
never updates per-song entries in downloadLog; modify the retry block around
downloadSong(song, subsonicService) in the same method so that after each
attempt you call _updateLogEntry (or the appropriate helper) for that song to
set its status to "retrying" before the attempt and then to "downloaded" on
success or "failed" on final failure, and ensure downloadState.value.failedSongs
and downloadLog stay in sync (use Song identity or song.id to locate the log
entry and update progress/timestamps accordingly); keep respecting
_isBackgroundDownloadActive and only update the log when the attempt completes.
- Around line 404-406: getDownloadUrl currently always builds a Subsonic
/rest/download URL which breaks Jellyfin/YouTube backends; update
SubsonicService.getDownloadUrl to mirror getStreamUrl by returning
_jellyfin!.getDownloadUrl(songId) if _jellyfin != null, returning
_youtube!.getDownloadUrl(songId) if _youtube != null, and only then falling back
to _buildUrl('download', {'id': songId}); also add corresponding getDownloadUrl
implementations to JellyfinService and YoutubeService so those backends return
proper download URLs.

In `@lib/services/subsonic_service.dart`:
- Around line 423-427: getDownloadUrl currently always returns a Subsonic
download URL via _buildUrl('download', ...) and omits backend checks; update
getDownloadUrl to mirror the pattern used in getStreamUrl/getCoverArtUrl: if
_jellyfin is present delegate to _jellyfin (e.g., call its stream/download
helper or fallback to getStreamUrl(songId)), if _youtube is present delegate to
_youtube (or fallback to getStreamUrl(songId)), otherwise return
_buildUrl('download', {'id': songId}); keep references to getDownloadUrl,
getStreamUrl, getCoverArtUrl, _jellyfin, _youtube and _buildUrl to locate the
change.

In `@lib/widgets/album_artwork.dart`:
- Around line 192-202: The widget currently reads
OfflineService().downloadedSongIds.value directly, so _buildImageNatural and
_buildImage won't rebuild when downloads complete; wrap each offline-art check
(the block that calls
OfflineService().getLocalCoverArtPathByCoverArtId(coverArt) and returns
Image.file with ValueKey('offline_natural_$coverArt') / similar key) in a
ValueListenableBuilder<Set<String>> that listens to
OfflineService().downloadedSongIds and returns the same Image.file (or
placeholder) based on the latest value so the widget rebuilds automatically when
downloadedSongIds updates.

---

Nitpick comments:
In `@lib/providers/player_provider.dart`:
- Around line 1127-1132: The fire-and-forget call to
_offlineService.initialize().then((_) {
_offlineService.resumeIncompleteDownloads(_subsonicService); }) lacks error
handling and can swallow exceptions (including cases where _subsonicService is
null); change this to await or to a promise chain that catches errors (e.g., use
.then(...).catchError(...) or an async function with try/catch) and log any
errors using the module's logger so failures in _offlineService.initialize() or
_offlineService.resumeIncompleteDownloads(_subsonicService) are reported; also
validate _subsonicService before calling resumeIncompleteDownloads and log a
clear message if it is not configured.

In `@lib/screens/download_playlist_status_screen.dart`:
- Line 14: The screen calls Provider.of<LibraryProvider>(context) which causes
the widget to rebuild on provider updates; change this to
Provider.of<LibraryProvider>(context, listen: false) (or otherwise obtain the
provider with listen: false) so that the top-level
download_playlist_status_screen widget does not rebuild unnecessarily—update the
usage where the variable libraryProvider is assigned in
download_playlist_status_screen.dart to pass listen: false.

In `@lib/services/offline_service.dart`:
- Around line 162-168: The current deserialization of queuedDataJson naively
casts entries which can throw a CastError; update the parsing for
json.decode(queuedDataJson) so you validate types before casting: ensure the
decoded value is a Map<String, dynamic>, iterate its entries, check each value
is a List, and for each list item verify it's a Map<String, dynamic> (skip or
drop invalid items) before building _queuedPlaylistData; wrap the whole
operation in a try-catch to preserve the existing failure-silent behavior and
fall back to an empty map or previous state if parsing fails.
- Line 752: The second redundant call to _downloadQueue.clear() should be
removed: locate the method that manipulates _downloadQueue (the block calling
_downloadQueue.clear() twice) and delete the later invocation of
_downloadQueue.clear() so the queue is only cleared once (keep the first clear
where it currently executes and ensure no other logic depends on a second
clear).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f87b418d-46a2-4a4f-b199-221a4ca2d3ad

📥 Commits

Reviewing files that changed from the base of the PR and between 3948ed9 and b325811.

📒 Files selected for processing (15)
  • Dockerfile.release
  • android/gradle.properties
  • build-release-apk.sh
  • lib/providers/player_provider.dart
  • lib/screens/album_screen.dart
  • lib/screens/download_playlist_status_screen.dart
  • lib/screens/library_screen.dart
  • lib/screens/playlist_screen.dart
  • lib/screens/playlists_screen.dart
  • lib/screens/settings_storage_tab.dart
  • lib/services/offline_service.dart
  • lib/services/subsonic_service.dart
  • lib/widgets/album_artwork.dart
  • lib/widgets/song_tile.dart
  • pubspec.yaml

Comment thread build-release-apk.sh
Comment on lines +9 to +23
CONTAINER_NAME="musly-apk-build"

cd "$(dirname "$0")"

echo "==> Building Docker image: $IMAGE_TAG"
sudo docker build -f Dockerfile.release -t "$IMAGE_TAG" .

echo "==> Extracting APK from image"
sudo docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
sudo docker create --name "$CONTAINER_NAME" "$IMAGE_TAG" echo done
mkdir -p "$OUTPUT_DIR"
sudo docker cp \
"$CONTAINER_NAME:/app/build/app/outputs/flutter-apk/app-release.apk" \
"$OUTPUT_DIR/musly-release.apk"
sudo docker rm -f "$CONTAINER_NAME"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a unique container name to avoid cross-run collisions.

A fixed CONTAINER_NAME can break concurrent builds and may delete another run’s container.

Proposed change
-CONTAINER_NAME="musly-apk-build"
+CONTAINER_NAME="musly-apk-build-$$-$(date +%s)"
+cleanup() { sudo docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true; }
+trap cleanup EXIT
...
-sudo docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
 sudo docker create --name "$CONTAINER_NAME" "$IMAGE_TAG" echo done
...
-sudo docker rm -f "$CONTAINER_NAME"
🧰 Tools
🪛 Shellcheck (0.11.0)

[warning] 18-18: Use semicolon or linefeed before 'done' (or quote to make it literal).

(SC1010)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@build-release-apk.sh` around lines 9 - 23, The script uses a fixed
CONTAINER_NAME ("musly-apk-build") which can collide across concurrent runs;
change CONTAINER_NAME in build-release-apk.sh to a unique value (e.g., include
$$, a timestamp, or a generated UUID) and use that variable consistently for
docker create, docker cp and docker rm; also add a trap cleanup that removes the
container name on exit to avoid leftover containers (references: CONTAINER_NAME,
docker create --name, docker cp, docker rm -f).

Comment thread Dockerfile.release
Comment on lines +41 to +43
RUN git clone --depth 1 --branch stable \
https://github.com/flutter/flutter.git /opt/flutter \
&& git config --global --add safe.directory /opt/flutter
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What do Flutter release docs recommend for reproducible CI builds: pinning a specific Flutter version/tag vs using the moving "stable" branch?

💡 Result:

For reproducible CI builds, Flutter best practices strongly recommend pinning a specific Flutter version or tag rather than using the moving "stable" branch [1][2]. While the "stable" channel is the recommended choice for production app releases [3], it is a moving target that receives updates. Relying on it in CI can lead to non-deterministic builds where your environment changes unexpectedly between pipeline runs, potentially causing build failures or regressions due to underlying SDK changes [1][2]. To ensure reproducibility and stability in CI/CD pipelines, the following approaches are recommended: 1. Pinning a Specific Version: Explicitly define the Flutter version (e.g., 3.41.5) in your CI configuration or a version file [2][4]. This ensures that every build uses the exact same SDK environment, regardless of when the pipeline runs [1]. 2. Using Version Management Tools: Tools like FVM (Flutter Version Manager) are widely used to manage Flutter SDK versions per project [5][6]. You can pin your project to a specific version in a configuration file (such as.fvmrc), which your CI pipeline can then read to install the correct SDK version automatically [2]. 3. Matrix Testing: If you need to ensure compatibility across different versions, use a CI matrix strategy [2]. This allows you to test your application against multiple specific versions (e.g., current stable, next stable, and beta) in a single pipeline run, rather than relying on a single moving branch [2]. By pinning your version, you gain control over when to upgrade your SDK, allowing you to test changes in a controlled manner before updating your production pipeline [7][1].

Citations:


Pin Flutter to an explicit version for reproducible release artifacts.

Using --branch stable creates non-deterministic builds since the stable branch updates over time. Flutter best practices explicitly recommend pinning to a specific version in CI/CD to ensure reproducibility and gain control over when to upgrade the SDK.

Proposed change
+ARG FLUTTER_VERSION=3.29.3
-RUN git clone --depth 1 --branch stable \
-        https://github.com/flutter/flutter.git /opt/flutter \
+RUN git clone --depth 1 --branch ${FLUTTER_VERSION} \
+        https://github.com/flutter/flutter.git /opt/flutter \
     && git config --global --add safe.directory /opt/flutter
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
RUN git clone --depth 1 --branch stable \
https://github.com/flutter/flutter.git /opt/flutter \
&& git config --global --add safe.directory /opt/flutter
ARG FLUTTER_VERSION=3.29.3
RUN git clone --depth 1 --branch ${FLUTTER_VERSION} \
https://github.com/flutter/flutter.git /opt/flutter \
&& git config --global --add safe.directory /opt/flutter
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile.release` around lines 41 - 43, Replace the non-deterministic clone
of Flutter that uses --branch stable with a pinned SDK tag or commit so releases
are reproducible: update the RUN that clones into /opt/flutter to fetch a
specific release (e.g., use --branch vX.Y.Z or clone then git checkout
<tag_or_commit>) and keep the git config --global --add safe.directory
/opt/flutter; choose and document the exact Flutter tag/commit to pin (replace
"stable" with the chosen tag in the RUN command or add an explicit git checkout
after clone).

Comment on lines 111 to +178
Future<void> _downloadAlbum() async {
if (_songs.isEmpty) return;

final subsonicService = Provider.of<SubsonicService>(
context,
listen: false,
);
if (_songs.isEmpty || _album == null) return;
final offlineService = OfflineService();
final subsonicService = Provider.of<SubsonicService>(context, listen: false);
await offlineService.initialize();

setState(() => _isDownloading = true);

offlineService.startBackgroundDownload(_songs, subsonicService).then((_) {
if (mounted) {
setState(() => _isDownloading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Downloaded ${_songs.length} songs from ${_album!.name}',
),
duration: const Duration(seconds: 3),
),
);
}
});

offlineService.queuePlaylistDownload(_album!.id, _songs, subsonicService);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Downloading ${_songs.length} songs in background…'),
content: Text('Queued ${_songs.length} songs for download…'),
duration: const Duration(seconds: 2),
),
);
}
}

Future<void> _cancelDownload() async {
if (_album == null) return;
await OfflineService().cancelPlaylistDownload(_album!.id);
}

Future<void> _removeDownloads() async {
if (_songs.isEmpty || _album == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove downloads?'),
content: Text('Remove all ${_songs.length} downloaded songs from "${_album!.name}"?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Remove', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && mounted) {
await OfflineService().cancelPlaylistDownload(_album!.id);
await OfflineService().deletePlaylistDownloads(_songs);
}
}

Widget _buildDownloadButton(BuildContext context) {
if (_allDownloaded) {
return IconButton(
tooltip: 'Downloaded — tap to remove',
onPressed: _removeDownloads,
icon: const Icon(Icons.cloud_done, color: Colors.green),
);
}
if (_isQueued) {
return IconButton(
tooltip: 'Downloading — tap to cancel',
onPressed: _cancelDownload,
icon: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
return IconButton(
tooltip: 'Download album',
onPressed: _downloadAlbum,
icon: const Icon(CupertinoIcons.cloud_download),
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize the new album download strings.

The queued snackbar, remove-downloads dialog, and tooltips are all hardcoded English. Since this screen already depends on AppLocalizations, these new states should use localized strings too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/screens/album_screen.dart` around lines 111 - 178, Replace hardcoded
English UI strings in _downloadAlbum, _removeDownloads, and _buildDownloadButton
with localized values from AppLocalizations (e.g. call
AppLocalizations.of(context) inside these methods/builders and use resource keys
instead of literal text); specifically localize the SnackBar message in
_downloadAlbum, the AlertDialog title/content and button labels in
_removeDownloads, and all IconButton tooltip texts in _buildDownloadButton,
adding new localization keys
(queuedSongs/removeDownloadsTitle/removeDownloadsContent/cancelLabel/removeLabel/downloadedTooltip/downloadingTooltip/downloadAlbumTooltip
or similar) to your ARB/locale files and use those keys via AppLocalizations in
the referenced methods.

Comment on lines +649 to 650
await OfflineService().cancelPlaylistDownload(item.id);
await libraryProvider.deletePlaylist(item.id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not block playlist deletion when download cancel fails.

Line 649 can fail independently, and then Line 650 never runs. That makes playlist deletion fragile if offline cleanup errors.

Proposed fix
-              try {
-                await OfflineService().cancelPlaylistDownload(item.id);
-                await libraryProvider.deletePlaylist(item.id);
+              try {
+                try {
+                  await OfflineService().cancelPlaylistDownload(item.id);
+                } catch (_) {
+                  // Best-effort cancel; continue deleting playlist.
+                }
+                await libraryProvider.deletePlaylist(item.id);
                 if (context.mounted) {
                   ScaffoldMessenger.of(context).showSnackBar(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await OfflineService().cancelPlaylistDownload(item.id);
await libraryProvider.deletePlaylist(item.id);
try {
try {
await OfflineService().cancelPlaylistDownload(item.id);
} catch (_) {
// Best-effort cancel; continue deleting playlist.
}
await libraryProvider.deletePlaylist(item.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/screens/library_screen.dart` around lines 649 - 650, The call to
OfflineService().cancelPlaylistDownload(item.id) can throw and prevent
libraryProvider.deletePlaylist(item.id) from running; wrap the
cancelPlaylistDownload call in a try/catch that catches and logs the error
(including context and item.id) but does not rethrow, then always call
libraryProvider.deletePlaylist(item.id) so playlist deletion proceeds regardless
of offline cleanup failure.

Comment on lines 303 to +371
Future<void> _downloadPlaylist() async {
final songs = _playlist?.songs;
if (songs == null || songs.isEmpty) return;

final subsonicService = Provider.of<SubsonicService>(
context,
listen: false,
);
final offlineService = OfflineService();
final subsonicService = Provider.of<SubsonicService>(context, listen: false);
await offlineService.initialize();

setState(() => _isDownloading = true);

offlineService.startBackgroundDownload(songs, subsonicService).then((_) {
if (mounted) {
setState(() => _isDownloading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Downloaded ${songs.length} songs from ${_playlist!.name}',
),
duration: const Duration(seconds: 3),
),
);
}
});

offlineService.queuePlaylistDownload(widget.playlistId, songs, subsonicService);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Downloading ${songs.length} songs in background…'),
content: Text('Queued ${songs.length} songs for download…'),
duration: const Duration(seconds: 2),
),
);
}
}

Future<void> _cancelDownload() async {
await OfflineService().cancelPlaylistDownload(widget.playlistId);
}

Future<void> _removeDownloads() async {
final songs = _playlist?.songs ?? [];
if (songs.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove downloads?'),
content: Text('Remove all ${songs.length} downloaded songs from "${_playlist!.name}"?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Remove', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && mounted) {
await OfflineService().cancelPlaylistDownload(widget.playlistId);
await OfflineService().deletePlaylistDownloads(songs);
}
}

Widget _buildDownloadButton(BuildContext context) {
if (_allDownloaded) {
return IconButton(
tooltip: 'Downloaded — tap to remove',
onPressed: _removeDownloads,
icon: const Icon(Icons.cloud_done, color: Colors.green),
);
}
if (_isQueued) {
return IconButton(
tooltip: 'Downloading — tap to cancel',
onPressed: _cancelDownload,
icon: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
return IconButton(
tooltip: 'Download playlist',
onPressed: _downloadPlaylist,
icon: const Icon(CupertinoIcons.cloud_download),
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize the new download UI copy.

These snackbars, dialog labels, and tooltips are hardcoded in English even though this screen already uses AppLocalizations. They will stay untranslated in non-English locales.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/screens/playlist_screen.dart` around lines 303 - 371, Replace hardcoded
English UI strings in _downloadPlaylist, _removeDownloads, _cancelDownload
(SnackBar text, AlertDialog title/content/button labels) and the tooltips in
_buildDownloadButton with localized values from AppLocalizations (e.g., final
loc = AppLocalizations.of(context)!). Use loc.* getters for the snackbar message
("Queued X songs for download…"), the dialog title/content and button labels
("Remove downloads?", "Cancel", "Remove"), and the three tooltips ("Downloaded —
tap to remove", "Downloading — tap to cancel", "Download playlist"); ensure
pluralization or interpolation uses loc methods if available and pass
songs.length into the localized string where needed.

Comment on lines +718 to +756
Widget _buildPlaylistStatusRow() {
return ValueListenableBuilder<Set<String>>(
valueListenable: _offlineService.downloadedSongIds,
builder: (context, ids, _) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF5856D6), Color(0xFF7B68EE)],
),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
CupertinoIcons.music_note_list,
color: Colors.white,
size: 18,
),
),
title: const Text('Playlist Downloads', style: TextStyle(fontSize: 16)),
subtitle: Text(
'${ids.length} songs downloaded',
style: TextStyle(
fontSize: 12,
color: _isDark ? AppTheme.darkSecondaryText : AppTheme.lightSecondaryText,
),
),
trailing: const Icon(CupertinoIcons.chevron_right, size: 16),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const DownloadPlaylistStatusScreen(),
),
),
);
},
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Localize user-facing strings for i18n consistency.

The "Playlist Downloads" title (line 739) and "songs downloaded" text (line 741) are hardcoded in English, inconsistent with the AppLocalizations pattern used throughout this file.

🌐 Proposed fix to add localization

Add the following keys to AppLocalizations:

  • playlistDownloads → "Playlist Downloads"
  • songsDownloaded(int count) → returns e.g., "5 songs downloaded"

Then update the code:

-          title: const Text('Playlist Downloads', style: TextStyle(fontSize: 16)),
+          title: Text(AppLocalizations.of(context)!.playlistDownloads, style: const TextStyle(fontSize: 16)),
           subtitle: Text(
-            '${ids.length} songs downloaded',
+            AppLocalizations.of(context)!.songsDownloaded(ids.length),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/screens/settings_storage_tab.dart` around lines 718 - 756, Localize the
hardcoded strings in _buildPlaylistStatusRow: replace the title 'Playlist
Downloads' with AppLocalizations.of(context).playlistDownloads and the subtitle
'${ids.length} songs downloaded' with the pluralized/localized call
AppLocalizations.of(context).songsDownloaded(ids.length) (or your project's
plural API), and add the two keys to AppLocalizations: playlistDownloads and
songsDownloaded(int count); keep the rest of the ListTile (leading, trailing,
navigation to DownloadPlaylistStatusScreen, and usage of
_offlineService.downloadedSongIds) unchanged.

Comment on lines +404 to 406
// Use /download (original file, no transcoding) so size matches song.size
final url = subsonicService.getDownloadUrl(song.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if getDownloadUrl has Jellyfin/YouTube handling like other URL methods
rg -n "getDownloadUrl|getStreamUrl" --type=dart lib/services/subsonic_service.dart -A 5

Repository: dddevid/Musly

Length of output: 807


🏁 Script executed:

rg -n "getDownloadUrl" --type=dart

Repository: dddevid/Musly

Length of output: 234


🏁 Script executed:

# Check the downloadSong method and its calling context
rg -n "downloadSong|startBackgroundDownload|_processQueue" --type=dart lib/services/offline_service.dart -B 3 -A 10

Repository: dddevid/Musly

Length of output: 4302


🏁 Script executed:

# Find where queuePlaylistDownload is called
rg -n "queuePlaylistDownload" --type=dart -B 3 -A 3

Repository: dddevid/Musly

Length of output: 1758


🏁 Script executed:

# Check if SubsonicService has _jellyfin and _youtube properties and how they're set
rg -n "_jellyfin|_youtube" --type=dart lib/services/subsonic_service.dart -B 1 -A 1 | head -60

Repository: dddevid/Musly

Length of output: 2296


Add backend-specific handling to getDownloadUrl to match getStreamUrl pattern.

getDownloadUrl at line 425 in subsonic_service.dart builds a Subsonic /rest/download URL unconditionally, even when _jellyfin or _youtube is non-null. Other URL-building methods like getStreamUrl (lines 430–433) check for Jellyfin and YouTube backends first. Downloads will fail for Jellyfin and YouTube users without this fix.

Apply the same backend-specific pattern:

String getDownloadUrl(String songId) {
  if (_jellyfin != null) return _jellyfin!.getDownloadUrl(songId);
  if (_youtube != null) return _youtube!.getDownloadUrl(songId);
  return _buildUrl('download', {'id': songId});
}

Then implement getDownloadUrl on JellyfinService and YoutubeService.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/services/offline_service.dart` around lines 404 - 406, getDownloadUrl
currently always builds a Subsonic /rest/download URL which breaks
Jellyfin/YouTube backends; update SubsonicService.getDownloadUrl to mirror
getStreamUrl by returning _jellyfin!.getDownloadUrl(songId) if _jellyfin !=
null, returning _youtube!.getDownloadUrl(songId) if _youtube != null, and only
then falling back to _buildUrl('download', {'id': songId}); also add
corresponding getDownloadUrl implementations to JellyfinService and
YoutubeService so those backends return proper download URLs.

Comment on lines +606 to +619
// One automatic retry pass for any failed songs
final toRetry = List<Song>.from(downloadState.value.failedSongs);
if (toRetry.isNotEmpty && _isBackgroundDownloadActive) {
debugPrint('Retrying ${toRetry.length} failed song(s)...');
final retryFailed = <Song>[];
for (final song in toRetry) {
if (!_isBackgroundDownloadActive) break;
final success = await downloadSong(song, subsonicService);
if (!success) retryFailed.add(song);
}
downloadState.value = downloadState.value.copyWith(
failedSongs: retryFailed,
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Retry pass does not update downloadLog status.

The retry loop updates downloadState.failedSongs but doesn't call _updateLogEntry to reflect retry status in the download log. The UI showing per-song progress (via downloadLog) will still show these songs as "failed" even if they succeed on retry.

Proposed fix to update log entries on retry
     final toRetry = List<Song>.from(downloadState.value.failedSongs);
     if (toRetry.isNotEmpty && _isBackgroundDownloadActive) {
       debugPrint('Retrying ${toRetry.length} failed song(s)...');
       final retryFailed = <Song>[];
       for (final song in toRetry) {
         if (!_isBackgroundDownloadActive) break;
+        // Find the log entry index for this song
+        final logIdx = downloadLog.value.indexWhere((e) => e.song.id == song.id);
+        if (logIdx != -1) _updateLogEntry(logIdx, DownloadStatus.downloading);
         final success = await downloadSong(song, subsonicService);
-        if (!success) retryFailed.add(song);
+        if (!success) {
+          retryFailed.add(song);
+        } else if (logIdx != -1) {
+          _updateLogEntry(logIdx, DownloadStatus.done);
+        }
       }
       downloadState.value = downloadState.value.copyWith(
         failedSongs: retryFailed,
       );
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// One automatic retry pass for any failed songs
final toRetry = List<Song>.from(downloadState.value.failedSongs);
if (toRetry.isNotEmpty && _isBackgroundDownloadActive) {
debugPrint('Retrying ${toRetry.length} failed song(s)...');
final retryFailed = <Song>[];
for (final song in toRetry) {
if (!_isBackgroundDownloadActive) break;
final success = await downloadSong(song, subsonicService);
if (!success) retryFailed.add(song);
}
downloadState.value = downloadState.value.copyWith(
failedSongs: retryFailed,
);
}
// One automatic retry pass for any failed songs
final toRetry = List<Song>.from(downloadState.value.failedSongs);
if (toRetry.isNotEmpty && _isBackgroundDownloadActive) {
debugPrint('Retrying ${toRetry.length} failed song(s)...');
final retryFailed = <Song>[];
for (final song in toRetry) {
if (!_isBackgroundDownloadActive) break;
// Find the log entry index for this song
final logIdx = downloadLog.value.indexWhere((e) => e.song.id == song.id);
if (logIdx != -1) _updateLogEntry(logIdx, DownloadStatus.downloading);
final success = await downloadSong(song, subsonicService);
if (!success) {
retryFailed.add(song);
} else if (logIdx != -1) {
_updateLogEntry(logIdx, DownloadStatus.done);
}
}
downloadState.value = downloadState.value.copyWith(
failedSongs: retryFailed,
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/services/offline_service.dart` around lines 606 - 619, The retry loop
updates downloadState.failedSongs but never updates per-song entries in
downloadLog; modify the retry block around downloadSong(song, subsonicService)
in the same method so that after each attempt you call _updateLogEntry (or the
appropriate helper) for that song to set its status to "retrying" before the
attempt and then to "downloaded" on success or "failed" on final failure, and
ensure downloadState.value.failedSongs and downloadLog stay in sync (use Song
identity or song.id to locate the log entry and update progress/timestamps
accordingly); keep respecting _isBackgroundDownloadActive and only update the
log when the attempt completes.

Comment on lines +423 to +427
/// Returns the /download URL for a song — always original file, no transcoding.
/// Use this for offline downloads so file size matches song.size exactly.
String getDownloadUrl(String songId) {
return _buildUrl('download', {'id': songId});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing Jellyfin/YouTube backend handling.

Other URL methods like getStreamUrl (line 429-442) and getCoverArtUrl (line 392-421) check for _jellyfin and _youtube backends and delegate appropriately. This method unconditionally builds a Subsonic URL, which will fail for non-Subsonic backends.

Proposed fix to add backend checks
   /// Returns the /download URL for a song — always original file, no transcoding.
   /// Use this for offline downloads so file size matches song.size exactly.
   String getDownloadUrl(String songId) {
+    if (_jellyfin != null) return _jellyfin!.getStreamUrl(songId);
+    if (_youtube != null) return _youtube!.getStreamUrl(songId);
     return _buildUrl('download', {'id': songId});
   }

Note: Jellyfin and YouTube may not have a distinct "download" vs "stream" concept, so falling back to getStreamUrl preserves functionality. Consider adding dedicated download methods to those services if they have different download endpoints.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/services/subsonic_service.dart` around lines 423 - 427, getDownloadUrl
currently always returns a Subsonic download URL via _buildUrl('download', ...)
and omits backend checks; update getDownloadUrl to mirror the pattern used in
getStreamUrl/getCoverArtUrl: if _jellyfin is present delegate to _jellyfin
(e.g., call its stream/download helper or fallback to getStreamUrl(songId)), if
_youtube is present delegate to _youtube (or fallback to getStreamUrl(songId)),
otherwise return _buildUrl('download', {'id': songId}); keep references to
getDownloadUrl, getStreamUrl, getCoverArtUrl, _jellyfin, _youtube and _buildUrl
to locate the change.

Comment on lines +192 to +202
if (OfflineService().downloadedSongIds.value.isNotEmpty) {
final offlinePath = OfflineService().getLocalCoverArtPathByCoverArtId(coverArt);
if (offlinePath != null) {
return Image.file(
File(offlinePath),
key: ValueKey('offline_natural_$coverArt'),
fit: BoxFit.contain,
errorBuilder: (ctx, err, stack) => _buildPlaceholder(isDark),
);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cd lib/widgets && wc -l album_artwork.dart

Repository: dddevid/Musly

Length of output: 80


🏁 Script executed:

cat -n lib/widgets/album_artwork.dart | sed -n '190,210p'

Repository: dddevid/Musly

Length of output: 844


🏁 Script executed:

cat -n lib/widgets/album_artwork.dart | sed -n '243,260p'

Repository: dddevid/Musly

Length of output: 716


🏁 Script executed:

find . -name "*.dart" -type f | xargs grep -l "class OfflineService"

Repository: dddevid/Musly

Length of output: 93


🏁 Script executed:

find . -name "*.dart" -type f | xargs grep -l "downloadedSongIds"

Repository: dddevid/Musly

Length of output: 246


🏁 Script executed:

cat -n lib/services/offline_service.dart | head -100

Repository: dddevid/Musly

Length of output: 4201


🏁 Script executed:

cat -n lib/services/offline_service.dart | grep -A 5 -B 5 "downloadedSongIds"

Repository: dddevid/Musly

Length of output: 4142


🏁 Script executed:

cat -n lib/widgets/album_artwork.dart | sed -n '180,225p'

Repository: dddevid/Musly

Length of output: 2050


🏁 Script executed:

cat -n lib/widgets/album_artwork.dart | sed -n '230,280p'

Repository: dddevid/Musly

Length of output: 2223


Widget won't refresh when offline artwork becomes available after downloads complete.

Lines 192 and 245 read downloadedSongIds.value directly without listening. When a download finishes and downloadedSongIds updates, this widget won't rebuild to display the newly-available local artwork unless forced by an external rebuild.

Wrap both blocks in ValueListenableBuilder<Set<String>> listening to OfflineService().downloadedSongIds:

Fix pattern
-    if (OfflineService().downloadedSongIds.value.isNotEmpty) {
-      final offlinePath = OfflineService().getLocalCoverArtPathByCoverArtId(coverArt);
+    return ValueListenableBuilder<Set<String>>(
+      valueListenable: OfflineService().downloadedSongIds,
+      builder: (context, _, __) {
+        final offlinePath = OfflineService()
+            .getLocalCoverArtPathByCoverArtId(coverArt);
         if (offlinePath != null) {
           return Image.file(
             File(offlinePath),
             ...
           );
         }
-    }
+        // fallback to network
+      },
+    );

Apply to both _buildImageNatural and _buildImage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/widgets/album_artwork.dart` around lines 192 - 202, The widget currently
reads OfflineService().downloadedSongIds.value directly, so _buildImageNatural
and _buildImage won't rebuild when downloads complete; wrap each offline-art
check (the block that calls
OfflineService().getLocalCoverArtPathByCoverArtId(coverArt) and returns
Image.file with ValueKey('offline_natural_$coverArt') / similar key) in a
ValueListenableBuilder<Set<String>> that listens to
OfflineService().downloadedSongIds and returns the same Image.file (or
placeholder) based on the latest value so the widget rebuilds automatically when
downloadedSongIds updates.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant