Compare commits

..

100 Commits

Author SHA1 Message Date
zarzet 57051bd649 fix: handle .mp4 as alias for .m4a throughout download pipeline 2026-04-14 21:12:14 +07:00
zarzet d6fca6ca55 feat: carry extension download metadata through host pipeline and avoid FLAC-only genre/label pre-embed on non-FLAC files 2026-04-14 21:12:14 +07:00
zarzet 153ec2d9e5 chore: bump version to 4.3.1+126 2026-04-14 21:12:14 +07:00
zarzet be90e85d94 fix: show filter button in all/singles modes when tracks are empty but filters are active 2026-04-14 21:12:14 +07:00
zarzet 4af089f56c feat: improve Tidal metadata (copyright, album artist), remove Qobuz metadata search fallback, fix DATE/YAR tag sync 2026-04-14 21:12:14 +07:00
zarzet 62519d2d1c feat: add preserveNativeOutputExtensions capability for extensions 2026-04-14 21:12:14 +07:00
zarzet 27c0880e87 feat: convert M4A to FLAC when extension doesn't prefer native M4A output
When an extension's preferred output isn't .m4a, downloaded M4A streams
are now automatically converted to FLAC via FFmpeg instead of being
preserved. This applies to both SAF and non-SAF download paths.
2026-04-14 21:12:14 +07:00
zarzet f312b74b30 fix: ensure non-null search provider fallback and update default labels to Tidal
- Add monochrome.tf and samidy.com Tidal API mirrors
- Guarantee resolvedProvider is never null by defaulting to 'tidal'
- Replace stale 'Deezer' default label with 'Tidal' (Deezer moved to extension)
- Show dynamic provider target in auto label for search dropdown
2026-04-14 21:12:14 +07:00
zarzet bd49e307ef fix: reset OutputExt on extension→extension fallback too 2026-04-14 21:12:14 +07:00
zarzet e904a836c1 fix: reset OutputExt on extension→built-in fallback 2026-04-14 21:12:14 +07:00
zarzet 763c9478f1 fix: normalize extension codec for built-in fallback, remove dead Tidal ISRC 2026-04-14 21:12:13 +07:00
zarzet 427bdf74dc chore: reduce Gradle memory, add extension network timeout, fix tr locale 2026-04-14 21:12:13 +07:00
zarzet 373a276c54 fix: respect user provider choice over source extension priority 2026-04-14 21:12:13 +07:00
github-actions[bot] dccadf1f87 chore: update AltStore source to v4.3.1 2026-04-14 13:58:11 +00:00
github-actions[bot] d9933fe038 chore: update AltStore source to v4.3.0 2026-04-13 16:39:57 +00:00
zarzet d47ac0934d chore: bump version to 4.3.0 and fix SAF document file race condition
- Bump app version to 4.3.0 (build 125)
- Extract createOrReuseDocumentFile() to handle SAF auto-rename races
  between findFile() and createFile(), preferring the exact-named sibling
  and discarding duplicate documents
2026-04-13 23:35:03 +07:00
zarzet dbba4d6630 feat: propagate download cancel to extension HTTP requests and fix SAF filename extension mismatch
- Bind cancel context to all extension HTTP calls (fetch, httpGet, httpPost,
  httpRequest, fileDownload, authExchangeCodeWithPKCE) so in-flight requests
  are aborted when user cancels a download
- Make initDownloadCancel idempotent: return existing context if entry already
  exists and preserve pre-cancelled state
- Force SAF output filename to match actual file extension when extension
  returns a different format than requested (e.g. FLAC requested but M4A produced)
- Map ALAC/AAC quality to .m4a instead of falling through to default .flac
2026-04-13 23:35:03 +07:00
zarzet 7405855e01 fix: handle extension oauth callback on ios 2026-04-13 23:35:03 +07:00
zarzet ed020c9303 feat: native M4A ReplayGain tag writing and SAF picker error handling 2026-04-13 23:35:03 +07:00
zarzet 378742e37a refactor: remove author field from extension manifest and UI 2026-04-13 23:35:03 +07:00
zarzet c79bee534e fix: align default search tab layout with primary provider selector using Row+Expanded 2026-04-13 23:35:03 +07:00
zarzet 1d6df75829 fix: preserve existing M4A metadata during embed and enable BuildConfig generation 2026-04-13 23:35:03 +07:00
zarzet b7f51b5f14 feat: expose extension utils, preserve M4A native container, and bump to v4.2.3+124 2026-04-13 23:35:03 +07:00
zarzet 1c8e9df727 feat: add artist search filter and normalize search filter handling 2026-04-13 23:35:03 +07:00
zarzet 01540fe3fc fix: improve ALAC M4A quality parsing 2026-04-13 23:35:03 +07:00
zarzet 071db2f109 refactor: move deezer search flow to extension 2026-04-13 23:35:02 +07:00
zarzet e097d3f605 fix: stabilize shared extension link handling 2026-04-13 23:35:02 +07:00
zarzet 277f783f62 feat: add default search tab preference 2026-04-13 23:35:02 +07:00
zarzet 7637aaf168 fix: fallback extra metadata genre 2026-04-13 23:35:02 +07:00
zarzet c4878470bf chore: thank Ldav Nico and Feuerstern on donate page 2026-04-13 23:35:02 +07:00
zarzet a3725e8c48 feat: add keep android open link 2026-04-13 23:35:02 +07:00
zarzet 917ba842f5 fix: align metadata sanitization and lyrics editing 2026-04-13 23:32:19 +07:00
zarzet dac17ead33 chore: bump app to v4.2.2 2026-04-13 23:32:19 +07:00
zarzet 6845ebe04c refactor: move deezer to extension 2026-04-13 23:32:18 +07:00
zarzet eff709480d fix: preserve flat singles output for extension releases 2026-04-13 23:32:18 +07:00
zarzet 67833424cc fix: align re-enrich matching with autofill metadata 2026-04-13 23:32:18 +07:00
zarzet 5c48e1b476 fix: persist downloaded metadata and refine metadata navigation 2026-04-13 23:32:18 +07:00
zarzet 5e17c9f238 feat: add configurable extension download fallback 2026-04-13 23:32:18 +07:00
zarzet 7d330fb2ec fix: preserve composer metadata across qobuz and history 2026-04-13 23:32:18 +07:00
zarzet cd6a4594fa chore: bump app version to v4.2.1 2026-04-13 23:32:17 +07:00
zarzet bcf727f4ec fix: remove stale audio service manifest entries causing crashes on some devices 2026-04-13 23:32:17 +07:00
zarzet 4c4553913f fix: harden gomobile extension bindings and m4a cover retention 2026-04-13 23:32:17 +07:00
zarzet f0013fac16 fix: preserve local convert format and library entries 2026-04-13 23:32:17 +07:00
zarzet ce4be0ba97 feat: enrich composer and track totals metadata 2026-04-13 23:32:17 +07:00
zarzet 4bac38ef2a fix: preserve embedded metadata details 2026-04-13 23:32:17 +07:00
zarzet 4b213f47d9 ci: pin iOS release builds to macOS 15 and Xcode 26.1.1 2026-04-13 23:32:16 +07:00
zarzet a1010f72f2 fix: patch device_info_plus iOS build for older Xcode SDKs 2026-04-13 23:32:16 +07:00
zarzet 21077a26d0 feat: add additional search/metadata API with separate rate limiting 2026-04-13 23:32:16 +07:00
zarzet b50eec5a47 perf: incremental download queue lookup updates, async cover cleanup, and native JSON decoding on iOS
- Embed DownloadQueueLookup into DownloadQueueState; add updatedForIndices() for O(changed) incremental updates during frequent progress ticks instead of full O(n) rebuild
- downloadQueueLookupProvider now reads pre-computed lookup from state directly
- Replace sync file deletion in DownloadedEmbeddedCoverResolver with unawaited async cleanup to avoid blocking the main thread
- Parse JSON payloads on iOS native side (parseJsonPayload) so event sinks and method channel responses return native objects, avoiding redundant Dart-side JSON decode
- Use .cast<String, dynamic>() instead of Map.from() in _decodeMapResult for zero-copy map handling
2026-04-13 23:32:16 +07:00
zarzet 38a8b715f8 perf: reduce UI jank via memoization, compute isolates, SQL-backed playlist picker, and viewport-aware image caching
- Move explore JSON decode/encode to compute() isolate to avoid blocking main thread
- Memoize search sort results (artists/albums/playlists/tracks) in HomeTab; invalidate on new query
- Extract _DownloadedOrRemoteCover StatefulWidget with proper embedded-cover lifecycle management
- Replace O(playlists x tracks) in-memory playlist picker check with SQL loadPlaylistPickerSummaries query
- Add FutureProvider.family (libraryPlaylistPickerSummariesProvider) invalidated on all playlist mutations
- Memoize _buildQueueHistoryStats, localPathMatchKeys, and localSingleItems in QueueTab
- Add coverCacheWidthForViewport util; apply memCacheWidth/cacheWidth based on real DPR across all album/playlist/track screens
- Convert sync file ops in TrackMetadataScreen to async; use mtime+size as validation token
- Fetch Deezer album nb_tracks in parallel via fetchAlbumTrackCounts
2026-04-13 23:32:16 +07:00
zarzet 2b47537bb5 fix: route Qobuz API calls through authenticated gateway to resolve 401 errors 2026-04-13 23:32:16 +07:00
zarzet a5cf241846 refactor: consolidate FLAC/MP3/Opus metadata embedding into unified _embedMetadataToFile 2026-04-13 23:32:16 +07:00
zarzet 53a4773480 feat: add skipLyrics manifest field for extensions to opt out of lyrics fetching 2026-04-13 23:32:16 +07:00
zarzet 89603af1f1 chore: remove redundant comments and update donor list 2026-04-13 23:32:15 +07:00
zarzet 2143084d3c fix: resolve missing track/disc numbers from search downloads and suppress FFmpeg log noise
- Tidal: use actual API track_number/disc_number when request values are 0
  (fixes search/popular downloads having no track position in metadata)
- Extension enrichment: copy TrackNumber/DiscNumber back from enriched results
- Extension fallback download: add request metadata fallback for non-source
  extensions (Album, AlbumArtist, ReleaseDate, ISRC, TrackNumber, DiscNumber)
- FFmpeg: add -v error -hide_banner to all commands (embed, convert, CUE split)
  to eliminate banner, build config, and full metadata/lyrics dump in logcat
- ebur128: add framelog=quiet to suppress per-frame loudness measurements
  while keeping the summary needed for ReplayGain parsing
- Track metadata screen: separate embedded lyrics check from online fetch,
  show file-only state with manual online fetch button
2026-04-13 23:32:15 +07:00
zarzet 0e265193b8 fix: improve extension runtime safety, HTTP response URL, SongLink parsing, and recommended service for extensions
- Add 'url' field (final URL after redirects) to all extension HTTP responses and fix fetch polyfill to return final URL instead of original request URL
- Fix RunWithTimeout race condition: increase force-timeout from 1s to 60s to prevent concurrent VM access crashes, add nil guards
- Use lockReadyVM() for thread-safe VM access in GetPlaylistWithExtensionJSON and InvokeAction
- Handle mixed JSON types (string, null, array) in SongLink resolve API SongUrls field
- Fix recommended download service not showing for extension-based searches in download picker
2026-04-13 23:32:15 +07:00
zarzet c7e9749ce4 fix: resolve label and copyright from file metadata on info screen
The info screen was not reading label/copyright from the actual file metadata, so these fields were always empty for local library items and download history items that lacked them in-memory. Now _resolveAudioMetadata() extracts and displays them without requiring a manual save first.
2026-04-13 23:32:15 +07:00
zarzet e21cffff0b fix: validate ISRC in track metadata screen to prevent ID leakage
Sanitize the isrc getter to only return valid ISRC codes (12-char format per ISO 3901). Invalid values such as Spotify/Deezer/Tidal IDs that may leak into the ISRC field are now silently discarded, preventing them from being displayed or embedded into file tags.
2026-04-13 23:32:15 +07:00
zarzet d9e20040be fix: correct track/disc defaults, forward extension metadata, and fix service ID display
- Default track/disc number to 0 (unknown) instead of 1, letting the
  backend use the service-provided value or skip the field entirely
- Add releaseDate to ExploreItem so explore downloads carry release info
- Pass discNumber and releaseDate from extension album/playlist tracks
- Fix isDeezer detection using service field instead of substring match
- Add _displayServiceTrackId() to properly strip prefixes for all services
2026-04-13 23:32:15 +07:00
zarzet 6689173525 chore: bump version to 4.2.0 (build 121) 2026-04-13 23:32:14 +07:00
zarzet f37e4704a6 feat: add ReplayGain scanning, APEv2 tag support, and fix metadata bugs
ReplayGain (track + album):
- Scan track loudness via FFmpeg ebur128 filter (-18 LUFS reference)
- Duration-weighted power-mean for album gain computation
- Support for FLAC (native Vorbis), MP3 (ID3v2 TXXX), Opus, M4A
- Album RG auto-finalizes when all album tracks complete
- Retryable gate: blocks finalization while failed/skipped items exist
- SAF support: lossy album RG writes via temp file + writeTempToSaf
- New embedReplayGain setting (off by default) with UI toggle

APEv2 tag support:
- Full APEv2 reader/writer with header+items+footer format
- Merge-based editing with override keys for explicit deletions
- Binary cover art embedding (Cover Art (Front) item)
- Library scanner support for .ape/.wv/.mpc files
- ReplayGain fields in APE read/write/edit pipeline

Bug fixes (26):
- setArtistComments wiping fields on empty string value
- APEv2 rewrite corrupting files with ID3v1 trailer
- APE edit replacing entire tag instead of merging
- ReplayGain lost on manual MP3/Opus/M4A metadata edit
- Editor metadata save losing custom tags (preserveMetadata)
- Album RG accumulator not cleaned on queue mutation
- Album gain using unweighted mean instead of power-mean
- writeAlbumReplayGainTags return value silently ignored
- SAF album RG writing to deleted temp path
- Cancelled tracks polluting album gain computation
- APE ReplayGain not wired end-to-end
- APE field deletion not working in merge
- APE cover edit was a no-op
- Album RG duplicate entries on retry
- APE apeKeysFromFields missing track/disc/lyrics mappings
- Album RG entries purged by removeItem before computation
- FFmpeg converters discarding empty metadata values
- _appendVorbisArtistEntries skipping empty value (null vs empty)
- Album RG write-back fails for SAF lossy files
- Album RG partial finalization on failed tracks
- FLAC ClearEmpty flag destroying tags on partial callers
- clearCompleted not retriggering album RG checks
- ReadFileMetadata MP3/Ogg missing label and copyright
- Cover embed on CUE split destroying split artist tags
- Album RG gain format inconsistent (missing + prefix)
- FLAC reader/editor missing tag aliases (ALBUMARTIST, LABEL, etc.)
- dart:math log shadowed by logger.dart export
2026-04-13 23:32:14 +07:00
zarzet 65dbd5c8e4 fix: remove deleted local library item from provider state after file deletion
When deleting a non-CUE local library track from the metadata screen,
only the file was removed but the library database entry and provider
state were left untouched, causing the track to persist in the library UI.
Now calls removeItem() on localLibraryProvider after deleteFile().
2026-04-13 23:32:14 +07:00
zarzet d034144e9c feat: add resolve API with SongLink fallback, fix multi-artist tags (#288), and cleanup
Resolve API (api.zarz.moe):
- Refactor songlink.go: Spotify URLs use resolve API, non-Spotify uses SongLink API
- Add SongLink fallback when resolve API fails for Spotify (two-layer resilience)
- Remove dead code: page parser, XOR-obfuscated keys, legacy helpers

Multi-artist tag fix (#288):
- Add RewriteSplitArtistTags() in Go to rewrite ARTIST/ALBUMARTIST as split Vorbis comments
- Wire method channel handler in Android (MainActivity.kt) and iOS (AppDelegate.swift)
- Add PlatformBridge.rewriteSplitArtistTags() in Dart
- Call native FLAC rewriter after FFmpeg embed when split_vorbis mode is active
- Extract deezerTrackArtistDisplay() helper to use Contributors in album/playlist tracks

Code cleanup:
- Remove unused imports, dead code, and redundant comments across Go and Dart
- Fix build: remove stale getQobuzDebugKey() reference in deezer_download.go
2026-04-13 23:32:14 +07:00
zarzet 7c4309955e feat: add separate filename format for singles and EPs (#271)
Add singleFilenameFormat setting so singles/EPs can use a different filename template than albums. The format editor is reused with custom title/description. Dart selects the correct format based on track.isSingle before passing to Go, so no backend changes needed. Also fix isSingle getter to include all EPs regardless of totalTracks. Closes #271
2026-04-13 23:32:13 +07:00
zarzet 63e90d13d4 fix: match system navigation bar color with app theme
Set systemNavigationBarColor to surfaceContainer (matching the in-app
NavigationBar) via AppBarTheme.systemOverlayStyle. Handles light, dark,
AMOLED and dynamic color schemes automatically.

Closes zarzet/SpotiFLAC-Mobile#284
2026-04-13 23:32:13 +07:00
zarzet bfb0cad603 feat: add field selection dialog for bulk re-enrich metadata
Add a bottom sheet dialog that lets users choose which metadata field
groups to update during bulk re-enrich (cover, lyrics, album/album
artist, track/disc number, date/ISRC, genre/label/copyright).

Backend (Go):
- Filter FLAC Metadata struct and FFmpeg metadata map by selected
  update_fields so non-selected groups preserve existing file values
- Guard Deezer extended metadata fetch with shouldUpdateField(extra)
- Title/Artist are never overwritten by re-enrich (search keys only)
- enrichedMeta response only includes selected field groups

Frontend (Dart):
- New re_enrich_field_dialog.dart bottom sheet with checkboxes
- FFmpegService embed methods gain preserveMetadata param that uses
  -map_metadata 0 instead of -1 to preserve non-selected tags
- Hide selection overlay/bar before showing dialog, restore on cancel
- Fix setState-after-dispose guard in cancel branches

Cleanup:
- Remove dead code in library_tracks_folder_screen.dart
- Fix use_build_context_synchronously in main_shell.dart
- Suppress false-positive use_null_aware_elements lints
- Update l10n label from 'Title, Artist, Album' to 'Album, Album Artist'
2026-04-13 23:32:13 +07:00
zarzet cc10a917dc fix: prefer local file for cover/lyrics save and update build dependencies
- Cover art: extract from downloaded file first, fall back to URL download
- Lyrics: check embedded lyrics/sidecar LRC before fetching online
- Add audioFilePath param to FetchAndSaveLyrics (Go, Kotlin, Swift, Dart)
- Handle SAF content:// URIs for lyrics extraction in Kotlin bridge
- Update Go 1.25.7 -> 1.25.8, Gradle 9.3.1 -> 9.4.1, Kotlin 2.2.21 -> 2.3.20
- Update NDK r27d -> r28b, Flutter FVM 3.41.4 -> 3.41.5
- Upgrade all Flutter and Go module dependencies to latest
2026-04-13 23:32:13 +07:00
zarzet 5e833c1f75 refactor: remove legacy API clients, Yoinkify fallback, and unused lyrics provider
- Delete dead metadata client and extract shared types to metadata_types.go
- Remove Yoinkify download fallback from Deezer, use MusicDL only
- Clean up retired settings fields and metadataSource
- Remove dead l10n keys for retired provider
- Add migration to strip retired provider from existing users' lyrics config
2026-04-13 23:32:13 +07:00
zarzet 8c576ac7e4 chore: bump version to 4.1.3 (build 120) 2026-04-13 23:32:13 +07:00
zarzet 92160537c0 fix: Samsung SAF library scan, Qobuz album cover, M4A metadata save and log improvements
- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback
  to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg)
- Propagate displayNameHint to all format scanners so fd numbers (214, 207)
  no longer appear as track names when /proc/self/fd/ paths are used
- Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after
  first failure, reducing error log noise and improving scan speed on Samsung
- Fix Qobuz download returning wrong album cover when track exists on
  multiple albums by preferring req.CoverURL over API default
- Fix FFmpeg M4A metadata save failing with 'codec not currently supported
  in container' by forcing mp4 muxer instead of ipod when cover art present
- Clean up FLAC SAF temp file after metadata write-back (was leaking)
- Update LRC lyrics tag to credit Paxsenix API
- Remove log message truncation, defer to UI preview truncation instead
2026-04-13 23:32:13 +07:00
zarzet 120ecaa0e5 feat: add artist tag mode setting with split Vorbis support and improve library scan progress
- Add artist_tag_mode setting (joined / split_vorbis) for FLAC/Opus multi-artist tags
- Split 'Artist A, Artist B' into separate ARTIST= Vorbis comments when split mode is enabled
- Join repeated ARTIST/ALBUMARTIST Vorbis comments when reading metadata
- Propagate artistTagMode through download pipeline, re-enrich, and metadata editor
- Improve library scan progress: separate polling intervals, finalizing state, indeterminate progress
- Add initial progress snapshot on library scan stream connect
- Use req.ArtistName consistently for Qobuz downloads instead of track.Performer.Name
- Add l10n keys for artist tag mode, library files unit, and scan finalizing status
2026-04-13 23:32:12 +07:00
zarzet fd3a34303e feat: add stable cover cache keys, Qobuz album-search fallback, metadata filters and extended sort options
- Introduce coverCacheKey parameter through Go backend and Kotlin bridge for stable SAF cover caching
- Add MetadataFromFilename flag to skip filename-only metadata and retry via temp-file copy
- Add Qobuz album-search fallback between API search and store scraping
- Extract buildReEnrichFFmpegMetadata to skip empty metadata fields
- Add metadata completeness filter (complete, missing year/genre/album artist)
- Add sort modes: artist, album, release date, genre (asc/desc)
- Prune stale library cover cache files after full scan
- Skip empty values and zero track/disc numbers in FFmpeg metadata
- Add new l10n keys for metadata filter and sort options
2026-04-13 23:32:12 +07:00
zarzet d89b70e155 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-04-13 23:32:12 +07:00
zarzet e3b63c1d27 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-04-13 23:32:12 +07:00
zarzet 96301c0dbf feat: replace batch operation snackbars with progress dialog
Add reusable BatchProgressDialog widget with circular/linear progress
indicators, cancel support, and track detail display. Uses ValueNotifier
pattern to communicate progress from caller to dialog across navigator
routes.
2026-04-13 23:32:12 +07:00
zarzet a2458c1292 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-04-13 23:32:11 +07:00
zarzet 1737e12dd2 fix: add attached_pic disposition to ALAC cover art embedding 2026-04-13 23:32:11 +07:00
zarzet b770d7d9ca i18n: extract hardcoded strings into l10n keys
Move hardcoded UI strings across multiple screens and the notification
service into ARB-backed l10n keys so they can be translated via Crowdin.
Adds 62 new keys covering sort labels, dialog copy, metadata error
snackbars, folder-picker errors, home-tab error states, extensions home
feed selector, and all notification titles/bodies. NotificationService
now caches an AppLocalizations instance (injected from MainShell via
didChangeDependencies) and falls back to English literals when no locale
is available.
2026-04-13 23:32:11 +07:00
zarzet b712b9f509 refactor: route spotify URLs through extensions 2026-04-13 23:32:11 +07:00
zarzet 51496cd34e chore: bump version to 4.1.2+119 2026-04-13 23:32:11 +07:00
zarzet 2b2c2bc90a feat: improve track matching 2026-04-13 23:32:11 +07:00
zarzet e2a489ec92 feat: add haptic feedback when swiping library tabs 2026-04-13 23:32:11 +07:00
zarzet 4f46dd947d feat: add play button to playlist/library track tiles
Show a play IconButton (matching local album style) next to the
more-options button when a track has a local file available.
Uses PlaybackController.playTrackList to resolve and open the file.
2026-04-13 23:32:11 +07:00
zarzet fbb8d30db0 fix: use START_NOT_STICKY for DownloadService to prevent auto-restart
Prevents Android from automatically recreating the download service
after it is killed, avoiding duplicate or orphaned download processes.
2026-04-13 23:32:10 +07:00
Zarz Eleutherius c0637006af Merge pull request #313 from AlexRabbit/main
Extension OAuth + store: flatten action JSON, open auth URLs, spotifl…
2026-04-13 23:30:56 +07:00
Alex 3fc371b8c4 Extension OAuth + store: flatten action JSON, open auth URLs, spotiflac:// callback
Third-party extensions (e.g. Spotify PKCE addons) need three things the current app does not fully provide:

Extension button results – The Go runtime returned { success, result: { message, open_auth_url, … } } while Flutter read message / open_auth_url only on the outer map, so OAuth buttons appeared to do nothing. InvokeAction now merges the extension’s return object onto the top-level JSON (arrays/non-objects still use result).

Flutter – extension_detail_page: unwrap nested result for compatibility, merge setting_updates into saved extension settings (for copyable OAuth URLs), and launchUrl when open_auth_url is set.

Mobile OAuth return – spotiflac://callback?code=…&state=<extension_id> was not handled on Android (manifest + MainActivity) or iOS (AppDelegate open URL + cold-start launchOptions). This wires SetExtensionAuthCodeByID + invokeExtensionAction(..., "completeSpotifyLogin") so PKCE extensions can finish login after the browser redirect.

Extension store HTTP – Add Cache-Control: no-cache on registry and extension package downloads to reduce stale CDN/proxy responses.

Testing: Install a metadata extension that uses PKCE; tap Connect; confirm browser opens, return via spotiflac://callback, and tokens complete without pasting the code manually.

extension InvokeAction JSON was nested under result while the Flutter settings UI only read the top level, so OAuth-related buttons never showed messages or opened the browser. This PR flattens that payload, merges optional setting_updates, launches open_auth_url, adds spotiflac://callback handling on Android and iOS, and sends no-cache on store HTTP fetches. Needed for extensions like SpoitiLists (Spotify Web API + PKCE).
2026-04-12 02:40:31 -06:00
Zarz Eleutherius ee5b3824e9 Merge pull request #297 from mikropsoft/patch-1
Update app_tr.arb
2026-04-09 16:29:33 +07:00
github-actions[bot] be6a856773 chore: update AltStore source to v4.2.2 2026-04-06 07:38:35 +00:00
github-actions[bot] e41c299d49 chore: update AltStore source to v4.2.1 2026-04-04 15:02:40 +00:00
Zarz Eleutherius 981786b4a2 Merge pull request #298 from Amonoman/main
Center images in layout
2026-04-04 19:06:24 +07:00
Amonoman eeb6f11808 Center the images 2026-04-04 13:27:33 +02:00
𝗛𝗼𝗹𝗶 8e361e14b4 Update app_tr.arb 2026-04-04 12:05:33 +03:00
github-actions[bot] d58d46eb1f chore: update AltStore source to v4.2.0 2026-04-04 09:04:47 +00:00
Zarz Eleutherius 562a17f7ae Merge pull request #295 from Amonoman/main
Update logo
2026-04-04 01:24:34 +07:00
Amonoman b035e66540 Update logo 2026-04-03 18:28:16 +02:00
github-actions[bot] 38792a753e chore: update AltStore source to v4.1.3 2026-03-30 11:43:03 +00:00
github-actions[bot] d5b34b4f15 chore: update AltStore source to v4.1.2 2026-03-29 11:31:37 +00:00
github-actions[bot] 2a45c8dcdb chore: update AltStore source to v4.1.1 2026-03-27 15:47:00 +00:00
zarzet e7a2166a4f Merge branch 'dev' 2026-03-27 22:34:15 +07:00
github-actions[bot] f54597e655 chore: update AltStore source to v4.1.0 2026-03-26 10:47:40 +00:00
26 changed files with 2771 additions and 1473 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|---|---|---|---|---|
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | [Monochrome](https://monochrome.tf) |
---
+1 -1
View File
@@ -1,2 +1,2 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "3.9.0",
"versionDate": "2026-03-25",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
"version": "4.3.1",
"versionDate": "2026-04-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.3.1/SpotiFLAC-v4.3.1-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 34477323
"size": 34773598
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+66 -2
View File
@@ -118,9 +118,16 @@ type ExtDownloadResult struct {
AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
}
@@ -951,6 +958,19 @@ func isBuiltInDownloadProvider(providerID string) bool {
}
}
func normalizeQualityForBuiltIn(quality string) string {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "alac", "hi_res_lossless", "lossless":
return "HI_RES_LOSSLESS"
case "atmos", "ac3", "dolby_atmos":
return "LOSSLESS"
case "aac", "aac-legacy":
return "LOSSLESS"
default:
return quality
}
}
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
deezerID := ""
tidalID := ""
@@ -1319,8 +1339,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.Source != "" &&
!isBuiltInProvider(strings.ToLower(req.Source)) &&
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
selectedProvider == req.Source {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s' matching selected provider, trying it first\n", req.Source)
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
@@ -1402,6 +1422,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if result.DiscNumber > 0 {
resp.DiscNumber = result.DiscNumber
}
if result.TotalTracks > 0 {
resp.TotalTracks = result.TotalTracks
}
if result.TotalDiscs > 0 {
resp.TotalDiscs = result.TotalDiscs
}
if result.ReleaseDate != "" {
resp.ReleaseDate = result.ReleaseDate
}
@@ -1411,8 +1437,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if result.ISRC != "" {
resp.ISRC = result.ISRC
}
if result.Genre != "" {
resp.Genre = result.Genre
}
if result.Label != "" {
resp.Label = result.Label
}
if result.Copyright != "" {
resp.Copyright = result.Copyright
}
if result.Composer != "" {
resp.Composer = result.Composer
}
if result.LyricsLRC != "" {
resp.LyricsLRC = result.LyricsLRC
}
}
if req.TrackName != "" && resp.Title == "" {
resp.Title = req.TrackName
}
if req.ArtistName != "" && resp.Artist == "" {
resp.Artist = req.ArtistName
}
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
@@ -1431,9 +1478,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
resp.DiscNumber = req.DiscNumber
}
if req.TotalTracks > 0 && resp.TotalTracks == 0 {
resp.TotalTracks = req.TotalTracks
}
if req.TotalDiscs > 0 && resp.TotalDiscs == 0 {
resp.TotalDiscs = req.TotalDiscs
}
if req.CoverURL != "" && resp.CoverURL == "" {
resp.CoverURL = req.CoverURL
}
if req.Composer != "" && resp.Composer == "" {
resp.Composer = req.Composer
}
return resp, nil
}
@@ -1490,13 +1546,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInDownloadProvider(providerIDNormalized) {
req.OutputExt = ""
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extra metadata from ISRC: %s\n", req.ISRC)
enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
}
origQuality := req.Quality
req.Quality = normalizeQualityForBuiltIn(req.Quality)
result, err := tryBuiltInProvider(providerIDNormalized, req)
req.Quality = origQuality
if err == nil && result.Success {
result.Service = providerIDNormalized
if req.Label != "" {
@@ -1547,6 +1607,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
req.OutputExt = ""
outputPath := buildOutputPathForExtension(req, ext)
if req.ItemID != "" {
StartItemProgress(req.ItemID)
@@ -1859,6 +1920,9 @@ func canEmbedGenreLabel(filePath string) bool {
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
return false
}
if strings.ToLower(filepath.Ext(path)) != ".flac" {
return false
}
if !filepath.IsAbs(path) {
return false
}
+7
View File
@@ -185,6 +185,10 @@ func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
t.Fatalf("failed to create temp m4a file: %v", err)
}
if canEmbedGenreLabel("relative.flac") {
t.Fatal("expected relative path to be rejected")
@@ -195,6 +199,9 @@ func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
t.Fatal("expected missing file to be rejected")
}
if canEmbedGenreLabel(tempM4A) {
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
}
if !canEmbedGenreLabel(tempFile) {
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
}
+50 -1
View File
@@ -5,6 +5,7 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -136,12 +137,60 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
return runtime
}
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return fallback
}
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
if !ok {
return fallback
}
seconds := parseExtensionTimeoutSeconds(raw)
if seconds <= 0 {
return fallback
}
if seconds < 5 {
seconds = 5
}
if seconds > 300 {
seconds = 300
}
return time.Duration(seconds) * time.Second
}
func parseExtensionTimeoutSeconds(raw interface{}) int {
switch v := raw.(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float32:
return int(v)
case float64:
return int(v)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return parsed
default:
return 0
}
}
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
+1 -10
View File
@@ -2655,17 +2655,8 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
}
}
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
if track == nil {
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
track = nil
}
}
if track == nil {
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
errMsg := "could not find matching track on Qobuz without identifier match"
if err != nil {
errMsg = err.Error()
}
+7 -9
View File
@@ -429,11 +429,9 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
t.Fatal("ISRC fallback should not run without an ISRC")
return nil, nil
}
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
}
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
t.Fatal("metadata fallback should not run")
return nil, nil
}
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
@@ -448,11 +446,11 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
}
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
if err != nil {
t.Fatalf("expected no error, got %v", err)
if err == nil {
t.Fatalf("expected error, got track %+v", track)
}
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
t.Fatalf("unexpected resolved track: %+v", track)
if track != nil {
t.Fatalf("expected nil track, got %+v", track)
}
}
+39 -15
View File
@@ -51,6 +51,7 @@ type TidalTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
ISRC string `json:"isrc"`
Copyright string `json:"copyright"`
AudioQuality string `json:"audioQuality"`
TrackNumber int `json:"trackNumber"`
VolumeNumber int `json:"volumeNumber"`
@@ -135,6 +136,7 @@ type tidalPublicAlbum struct {
Type string `json:"type"`
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
Copyright string `json:"copyright"`
URL string `json:"url"`
NumberOfTracks int `json:"numberOfTracks"`
Explicit bool `json:"explicit"`
@@ -306,6 +308,29 @@ func tidalTrackArtistsDisplay(track *TidalTrack) string {
return strings.TrimSpace(track.Artist.Name)
}
func tidalTrackAlbumArtistDisplay(track *TidalTrack) string {
if track == nil {
return ""
}
if len(track.Artists) > 0 {
names := make([]string, 0, len(track.Artists))
for _, artist := range track.Artists {
if strings.ToUpper(strings.TrimSpace(artist.Type)) != "MAIN" {
continue
}
if trimmed := strings.TrimSpace(artist.Name); trimmed != "" {
names = append(names, trimmed)
}
}
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
return strings.TrimSpace(track.Artist.Name)
}
func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string {
if album == nil {
return ""
@@ -354,7 +379,7 @@ func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata {
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
@@ -377,7 +402,7 @@ func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata {
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
@@ -407,6 +432,7 @@ func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata {
Artists: tidalAlbumArtistsDisplay(album),
ArtistId: artistID,
Images: tidalImageURL(album.Cover, "1280x1280"),
Copyright: strings.TrimSpace(album.Copyright),
}
}
@@ -688,6 +714,10 @@ func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *
func (t *TidalDownloader) GetAvailableAPIs() []string {
return []string{
"https://eu-central.monochrome.tf",
"https://us-west.monochrome.tf",
"https://api.monochrome.tf",
"https://monochrome-api.samidy.com",
"https://tidal-api.binimum.org",
"https://tidal.kinoplus.online",
"https://triton.squid.wtf",
@@ -1736,6 +1766,7 @@ type TidalDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
Copyright string
LyricsLRC string // LRC content for embedding in converted files
}
@@ -2049,18 +2080,6 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
}
}
if !gotTidalID && req.ISRC != "" {
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
trackID = directTrack.ID
gotTidalID = true
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
} else if directErr != nil {
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
}
}
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
@@ -2356,6 +2375,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if actualDiscNumber == 0 {
actualDiscNumber = track.VolumeNumber
}
copyright := strings.TrimSpace(req.Copyright)
if copyright == "" {
copyright = strings.TrimSpace(track.Copyright)
}
metadata := Metadata{
Title: req.TrackName,
@@ -2371,7 +2394,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Copyright: copyright,
Composer: req.Composer,
}
@@ -2482,6 +2505,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
Copyright: copyright,
LyricsLRC: lyricsLRC,
}, nil
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 71 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '4.3.0';
static const String buildNumber = '125';
static const String version = '4.3.1';
static const String buildNumber = '126';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
File diff suppressed because it is too large Load Diff
+1440 -587
View File
File diff suppressed because it is too large Load Diff
+211 -10
View File
@@ -574,6 +574,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (trimmed.startsWith('content://')) return true;
return trimmed.endsWith('.flac') ||
trimmed.endsWith('.m4a') ||
trimmed.endsWith('.mp4') ||
trimmed.endsWith('.aac') ||
trimmed.endsWith('.mp3') ||
trimmed.endsWith('.opus') ||
@@ -595,6 +596,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
!hasResolvedSpecs &&
(trimmedPath.endsWith('.flac') ||
trimmedPath.endsWith('.m4a') ||
trimmedPath.endsWith('.mp4') ||
trimmedPath.endsWith('.aac') ||
trimmedPath.startsWith('content://'));
@@ -2359,6 +2361,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final normalized = preferred.startsWith('.')
? preferred.toLowerCase()
: '.${preferred.toLowerCase()}';
if (normalized == '.mp4') {
return '.m4a';
}
const allowed = <String>{'.flac', '.m4a', '.mp3', '.opus'};
if (allowed.contains(normalized)) {
return normalized;
@@ -2369,6 +2374,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return null;
}
bool _extensionPreservesNativeOutputExt(String service, String ext) {
final normalizedService = service.trim().toLowerCase();
final normalizedExt = ext.trim().toLowerCase();
if (normalizedService.isEmpty || normalizedExt.isEmpty) return false;
final extensionState = ref.read(extensionProvider);
return extensionState.extensions.any(
(ext) =>
ext.enabled &&
ext.hasDownloadProvider &&
ext.id.toLowerCase() == normalizedService &&
ext.preservedNativeOutputExtensions.contains(normalizedExt),
);
}
String _determineOutputExt(String quality, String service) {
final extensionPreferred = _extensionPreferredOutputExt(service);
if (extensionPreferred != null) {
@@ -2387,6 +2407,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String _mimeTypeForExt(String ext) {
switch (ext.toLowerCase()) {
case '.m4a':
case '.mp4':
return 'audio/mp4';
case '.mp3':
return 'audio/mpeg';
@@ -3689,6 +3710,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
) {
final backendTrackNum = _parsePositiveInt(backendResult['track_number']);
final backendDiscNum = _parsePositiveInt(backendResult['disc_number']);
final backendTotalTracks = _parsePositiveInt(backendResult['total_tracks']);
final backendTotalDiscs = _parsePositiveInt(backendResult['total_discs']);
final backendYear = normalizeOptionalString(
backendResult['release_date'] as String?,
);
@@ -3716,7 +3739,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
backendIsrc != null ||
backendCoverUrl != null ||
backendAlbumArtist != null ||
backendComposer != null;
backendComposer != null ||
backendTotalTracks != null ||
backendTotalDiscs != null;
if (!hasOverrides) {
return baseTrack;
@@ -3735,12 +3760,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isrc: backendIsrc ?? baseTrack.isrc,
trackNumber: backendTrackNum ?? baseTrack.trackNumber,
discNumber: backendDiscNum ?? baseTrack.discNumber,
totalDiscs: baseTrack.totalDiscs,
totalDiscs: backendTotalDiscs ?? baseTrack.totalDiscs,
releaseDate: backendYear ?? baseTrack.releaseDate,
deezerId: baseTrack.deezerId,
availability: baseTrack.availability,
albumType: baseTrack.albumType,
totalTracks: baseTrack.totalTracks,
totalTracks: backendTotalTracks ?? baseTrack.totalTracks,
composer: backendComposer ?? baseTrack.composer,
source: baseTrack.source,
);
@@ -4873,6 +4898,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final actualService =
((result['service'] as String?)?.toLowerCase()) ??
item.service.toLowerCase();
final preferredOutputExt = _extensionPreferredOutputExt(actualService);
final shouldPreserveNativeM4a =
preferredOutputExt == '.m4a' ||
_extensionPreservesNativeOutputExt(actualService, '.m4a');
final decryptionDescriptor =
DownloadDecryptionDescriptor.fromDownloadResult(result);
trackToDownload = _buildTrackForMetadataEmbedding(
@@ -4998,6 +5027,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isM4aFile =
filePath != null &&
(filePath.endsWith('.m4a') ||
filePath.endsWith('.mp4') ||
(mimeType != null && mimeType.contains('mp4')));
final isFlacFile =
filePath != null &&
@@ -5013,7 +5043,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (shouldForceTidalSafM4aHandling) {
_log.w(
'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; preserving it as M4A instead.',
'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; converting it back to FLAC.',
);
}
@@ -5130,7 +5160,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
}
} else {
} else if (shouldPreserveNativeM4a) {
_log.d('M4A file detected (SAF), preserving native container...');
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
@@ -5188,6 +5218,85 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (_) {}
}
}
} else {
_log.d('M4A file detected (SAF), converting to FLAC...');
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
String? flacPath;
try {
final length = await File(tempPath).length();
if (length < 1024) {
_log.w('Temp M4A is too small (<1KB), skipping conversion');
} else {
updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.95,
);
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
if (flacPath != null) {
_log.d('Converted to FLAC (temp): $flacPath');
_log.d(
'Embedding metadata and cover to converted FLAC...',
);
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
await _embedMetadataToFile(
flacPath,
finalTrack,
format: 'flac',
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
downloadService: item.service,
writeExternalLrc: false,
);
final newFileName = '${safBaseName ?? 'track'}.flac';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt('.flac'),
srcPath: flacPath,
);
if (newUri != null) {
if (newUri != currentFilePath) {
await _deleteSafFile(currentFilePath);
}
filePath = newUri;
finalSafFileName = newFileName;
} else {
_log.w('Failed to write FLAC to SAF, keeping M4A');
}
} else {
_log.w(
'FFmpeg conversion returned null, keeping M4A file',
);
}
}
} catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e');
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
if (flacPath != null) {
try {
await File(flacPath).delete();
} catch (_) {}
}
}
}
}
} else {
if (quality == 'HIGH') {
@@ -5264,7 +5373,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} else {
} else if (shouldPreserveNativeM4a) {
_log.d('M4A file detected, preserving native container...');
try {
@@ -5273,7 +5382,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (!await file.exists()) {
_log.e('File does not exist at path: $filePath');
} else {
if (!targetPath.toLowerCase().endsWith('.m4a')) {
if (!(targetPath.toLowerCase().endsWith('.m4a') ||
targetPath.toLowerCase().endsWith('.mp4'))) {
final renamedPath = targetPath.replaceAll(
RegExp(r'\.[^.]+$'),
'.m4a',
@@ -5318,6 +5428,84 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (e) {
_log.w('Native M4A handling failed: $e');
}
} else {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try {
final file = File(currentFilePath);
if (!await file.exists()) {
_log.e('File does not exist at path: $filePath');
} else {
final length = await file.length();
_log.i('File size before conversion: ${length / 1024} KB');
if (length < 1024) {
_log.w(
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
);
} else {
updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(
currentFilePath,
);
if (flacPath != null) {
filePath = flacPath;
_log.d('Converted to FLAC: $flacPath');
_log.d(
'Embedding metadata and cover to converted FLAC...',
);
try {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (backendGenre != null ||
backendLabel != null ||
backendCopyright != null) {
_log.d(
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
);
}
await _embedMetadataToFile(
flacPath,
finalTrack,
format: 'flac',
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
downloadService: item.service,
);
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
}
} else {
_log.w(
'FFmpeg conversion returned null, keeping M4A file',
);
}
}
}
} catch (e) {
_log.w(
'FFmpeg conversion process failed: $e, keeping M4A file',
);
}
}
}
} else if (metadataEmbeddingEnabled &&
@@ -5632,12 +5820,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendYear = result['release_date'] as String?;
final backendTrackNum = result['track_number'] as int?;
final backendDiscNum = result['disc_number'] as int?;
final backendTotalTracks = result['total_tracks'] as int?;
final backendTotalDiscs = result['total_discs'] as int?;
final backendBitDepth = result['actual_bit_depth'] as int?;
final backendSampleRate = result['actual_sample_rate'] as int?;
final backendISRC = result['isrc'] as String?;
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
final backendComposer = result['composer'] as String?;
final effectiveGenre =
normalizeOptionalString(backendGenre) ??
normalizeOptionalString(genre) ??
@@ -5658,6 +5849,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
filePath.startsWith('content://') ||
lowerFilePath.endsWith('.flac') ||
lowerFilePath.endsWith('.m4a') ||
lowerFilePath.endsWith('.mp4') ||
lowerFilePath.endsWith('.aac') ||
lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') ||
@@ -5745,11 +5937,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackNumber: (backendTrackNum != null && backendTrackNum > 0)
? backendTrackNum
: trackToDownload.trackNumber,
totalTracks: trackToDownload.totalTracks,
totalTracks:
(backendTotalTracks != null && backendTotalTracks > 0)
? backendTotalTracks
: trackToDownload.totalTracks,
discNumber: (backendDiscNum != null && backendDiscNum > 0)
? backendDiscNum
: trackToDownload.discNumber,
totalDiscs: trackToDownload.totalDiscs,
totalDiscs:
(backendTotalDiscs != null && backendTotalDiscs > 0)
? backendTotalDiscs
: trackToDownload.totalDiscs,
duration: trackToDownload.duration,
releaseDate: (backendYear != null && backendYear.isNotEmpty)
? backendYear
@@ -5758,7 +5956,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
genre: effectiveGenre,
composer: trackToDownload.composer,
composer:
(backendComposer != null && backendComposer.isNotEmpty)
? backendComposer
: trackToDownload.composer,
label: effectiveLabel,
copyright: effectiveCopyright,
),
+14
View File
@@ -179,6 +179,20 @@ class Extension {
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
List<String> get preservedNativeOutputExtensions {
final value = capabilities['preserveNativeOutputExtensions'];
if (value is! List) return const [];
final normalized = <String>[];
for (final item in value) {
if (item is! String) continue;
final trimmed = item.trim().toLowerCase();
if (trimmed.isEmpty) continue;
normalized.add(trimmed.startsWith('.') ? trimmed : '.$trimmed');
}
return normalized;
}
}
class SearchFilter {
+7 -6
View File
@@ -609,17 +609,16 @@ class TrackNotifier extends Notifier<TrackState> {
.map((ext) => ext.id)
.firstOrNull;
}
resolvedProvider ??= 'tidal';
}
final isEnabledExtensionProvider =
resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
);
if (resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
if (resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
!isEnabledExtensionProvider &&
@@ -639,10 +638,10 @@ class TrackNotifier extends Notifier<TrackState> {
.where((ext) => ext.enabled && ext.hasCustomSearch)
.map((ext) => ext.id)
.firstOrNull;
resolvedProvider ??= 'tidal';
}
if (resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
if (resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
extensionState.extensions.any(
@@ -663,7 +662,9 @@ class TrackNotifier extends Notifier<TrackState> {
final effectiveBuiltInProvider =
resolvedProvider == 'tidal' || resolvedProvider == 'qobuz'
? resolvedProvider
: builtInSearchProvider;
: (builtInSearchProvider?.isNotEmpty == true
? builtInSearchProvider
: 'tidal');
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
state = TrackState(
+25 -24
View File
@@ -480,7 +480,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
))) {
return explicit;
}
return _defaultSearchExtension(extensions)?.id;
return _defaultSearchExtension(extensions)?.id ?? 'tidal';
}
String? _sanitizeSearchFilterForProvider(
@@ -524,8 +524,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
_canonicalSearchFilterId(candidate.label!) ==
canonicalFilter) ||
(candidate.icon != null &&
_canonicalSearchFilterId(candidate.icon!) ==
canonicalFilter),
_canonicalSearchFilterId(candidate.icon!) == canonicalFilter),
)
.firstOrNull;
return match?.id;
@@ -1289,28 +1288,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
(hasHomeFeedExtension || hasExploreContent) &&
hasExploreContent;
ref.listen<String>(
settingsProvider.select((s) => s.defaultSearchTab),
(previous, next) {
if (previous == next) return;
final selectedSearchFilter = ref.read(
trackProvider.select((s) => s.selectedSearchFilter),
);
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
return;
}
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
previous,
next,
) {
if (previous == next) return;
final selectedSearchFilter = ref.read(
trackProvider.select((s) => s.selectedSearchFilter),
);
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
return;
}
final text = _urlController.text.trim();
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
final text = _urlController.text.trim();
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_lastSearchQuery = null;
_performSearch(text);
});
},
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_lastSearchQuery = null;
_performSearch(text);
});
});
if (hasActualResults &&
isShowingRecentAccess &&
@@ -3554,8 +3553,10 @@ class _SearchProviderDropdown extends ConsumerWidget {
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList();
final primarySearchExtension = _defaultSearchExtension(searchProviders);
final defaultProviderTarget =
primarySearchExtension?.displayName ?? 'Tidal';
final defaultProviderLabel =
primarySearchExtension?.displayName ?? 'Deezer';
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
final defaultProviderIconPath = primarySearchExtension?.iconPath;
final currentProvider =
rawCurrentProvider != null &&
+34
View File
@@ -4333,6 +4333,40 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
if (filterMode == 'all' &&
totalTrackCount == 0 &&
!showFilteringIndicator &&
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
const Spacer(),
if (!_isSelectionMode)
_buildFilterButton(context, unifiedItems),
],
),
),
),
if (filterMode == 'singles' &&
totalTrackCount == 0 &&
!showFilteringIndicator &&
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
const Spacer(),
if (!_isSelectionMode)
_buildFilterButton(context, unifiedItems),
],
),
),
),
if (historyItems.isNotEmpty && hasQueueItems)
SliverToBoxAdapter(
child: Padding(
@@ -742,8 +742,10 @@ class _MetadataSourceSelector extends ConsumerWidget {
final rawSearchProvider = settings.searchProvider?.trim() ?? '';
final isValidBuiltIn = _builtInProviders.containsKey(rawSearchProvider);
final primarySearchExtension = _defaultSearchExtension(extState.extensions);
final defaultProviderTarget =
primarySearchExtension?.displayName ?? 'Tidal';
final defaultProviderLabel =
primarySearchExtension?.displayName ?? 'Deezer';
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
final searchProvider =
isValidBuiltIn ||
extState.extensions.any(
+6
View File
@@ -1978,8 +1978,14 @@ class FFmpegService {
break;
case 'DATE':
vorbis['DATE'] = value;
final yearMatch = RegExp(r'^(\d{4})').firstMatch(value);
if (yearMatch != null &&
(!vorbis.containsKey('YEAR') || vorbis['YEAR']!.isEmpty)) {
vorbis['YEAR'] = yearMatch.group(1)!;
}
break;
case 'YEAR':
vorbis['YEAR'] = value;
if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) {
vorbis['DATE'] = value;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="#000000" d="M0 0h1024v1024H0z"/><g fill="none" stroke="#1DB954" stroke-linecap="round" stroke-linejoin="round" transform="matrix(.9 0 0 .9 51.2 1.2)"><path stroke-width="38" d="M512 148v592"/><path stroke-width="36.1" d="M422 217.4v241.2m180-241.2v241.2"/><path stroke-width="34.2" d="M341 290v96m342-96v96"/><path stroke-width="38" d="m420 642 92 110 92-110M290 762v90q0 72 72 72h300q72 0 72-72v-90"/></g></svg>

After

Width:  |  Height:  |  Size: 488 B

+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none"
version: 4.3.0+125
version: 4.3.1+126
environment:
sdk: ^3.10.0