Compare commits

..

94 Commits

Author SHA1 Message Date
zarzet 23cab16471 feat: enable Tidal ISRC and metadata search 2026-03-18 18:14:01 +07:00
zarzet 0a892011de refactor: migrate lyrics providers to Paxsenix endpoints 2026-03-18 17:11:17 +07:00
zarzet acb1d957d3 feat: add McNuggets Jimmy as supporter 2026-03-18 17:10:44 +07:00
zarzet 4a492aeefc chore: bump version to 3.8.8+114 2026-03-18 01:23:55 +07:00
zarzet eb143a41fc refactor: remove redundant comments and fix setMetadataSource bug
- Fix setMetadataSource always returning 'deezer' regardless of input parameter
- Remove self-evident doc comments that restate method/class names across
  app_theme, dynamic_color_wrapper, cover_cache_manager, history_database,
  library_database, and download_service_picker
- Remove stale migration inline notes (// 12 -> 16, // 20 -> 16, etc.) from app_theme
- Remove trivial section-label comments in queue_tab batch conversion method
- Remove duplicate 'wait up to 5 seconds' comment in main_shell
2026-03-18 01:12:16 +07:00
zarzet 75db2f162b fix: improve extension download reliability and Qobuz API integration
- Add dedicated long-timeout download client (24h) for extension file downloads,
  preventing timeouts on large lossless audio files
- Skip unnecessary SongLink Deezer prelookup when an extension download provider
  handles the track, reducing latency and avoiding spurious API failures
- Prefer native track ID over Spotify ID when a source/provider is set, ensuring
  extension providers receive their own IDs correctly
- Update Qobuz MusicDL API endpoint and switch payload URL to open.qobuz.com
- Extract buildQobuzMusicDLPayload helper and add test coverage
2026-03-18 01:06:22 +07:00
zarzet 855d0e3ffc feat: add zcc09 as supporter (thank you) 2026-03-18 00:19:36 +07:00
zarzet 5ccd06cc68 fix: stabilize library scan IDs, pause queue behavior, and scan race condition
- Generate stable SHA-1 based IDs for SAF-scanned library items to prevent null ID crashes on the Dart side
- Suppress false queue-complete notification when user pauses instead of finishing the queue, and break out of parallel loop immediately when paused with no active downloads
- Use SQLite as the single source of truth for library scan results to fix a race condition where auto-scan could fire before provider state finished loading, dropping unchanged rows
2026-03-17 23:54:49 +07:00
zarzet 66a89d9e8e fix: properly stop active downloads when pausing the queue 2026-03-17 15:26:51 +07:00
zarzet 814deca19d fix: hide queue-as-FLAC button when all selected tracks are already FLAC 2026-03-17 15:19:46 +07:00
zarzet 3bb6754d9c Merge branch 'main' into dev
# Conflicts:
#	lib/constants/app_info.dart
#	lib/main.dart
#	lib/screens/local_album_screen.dart
#	lib/screens/queue_tab.dart
#	lib/screens/settings/donate_page.dart
#	lib/services/local_track_redownload_service.dart
#	pubspec.yaml
2026-03-17 15:10:04 +07:00
zarzet 7d11d67cd2 chore: bump version to 3.8.7+113 2026-03-17 15:07:05 +07:00
zarzet c0bd10cfca fix: skip already-downloaded tracks in library folder download-all 2026-03-17 15:04:45 +07:00
zarzet e003b15ffd fix: skip tracks already in FLAC from queue-as-FLAC selection and fix local album track list widget identity 2026-03-17 15:02:19 +07:00
zarzet ac1c7d31c9 fix: improve Spotify track availability resolution 2026-03-17 14:45:24 +07:00
zarzet 6fc9ffeb23 fix: upgrade Deezer and Tidal cover art to max quality on Dart side
The Dart-side _upgradeToMaxQualityCover only handled Spotify CDN
URLs, causing Deezer covers to stay at 1000x1000 and Tidal at
1280x1280. Add regex-based Deezer upgrade (1800x1800) and Tidal
origin resolution upgrade to match the Go backend logic.

Closes #237
2026-03-16 22:46:45 +07:00
zarzet 9bebed506b fix: honor local library auto-scan cooldown 2026-03-16 22:35:17 +07:00
zarzet c66d13c9fd bump version to 3.8.6+112 2026-03-16 21:02:16 +07:00
github-actions[bot] 8529985a0e chore: update AltStore source to v3.8.6 2026-03-16 13:54:09 +00:00
zarzet a8a3973225 fix: prevent re-download of tracks converted to a different format
When a file is converted externally (e.g. FLAC to OPUS), the
orphan cleanup would delete the history entry because the original
path no longer exists. Now it checks for sibling files with other
audio extensions and updates the stored path instead of deleting.

Also add extension-stripped keys to path_match_keys so that
paths differing only by audio extension still match during local
library scan exclusion and queue deduplication.
2026-03-16 20:38:51 +07:00
zarzet 6710f90e1e feat: add auto-scan option for local library
Add a new 'Auto Scan' setting under Local Library with four modes:
off, every app open (10min cooldown), daily, and weekly. The app
uses WidgetsBindingObserver to trigger incremental scans on launch
and when resuming from background, respecting the configured
cooldown based on the last scan timestamp.
2026-03-16 20:35:59 +07:00
zarzet 929c5f3249 fix: remove double horizontal padding in store tab extension list
The extension list was wrapped in an extra Padding(horizontal: 16)
on top of SettingsGroup's default 16px margin, resulting in 32px
total inset. Remove the outer wrapper to match settings tab width.
2026-03-16 20:35:59 +07:00
zarzet f170ead7b9 docs: add contributors section to README
Add auto-generated contributor avatars via contrib.rocks with a
link to the GitHub contributors page. Include acknowledgement for
translators and bug reporters.
2026-03-16 20:35:59 +07:00
zarzet e63e366228 feat: add mc nuggets jimmy, CJBGR and michahRicie as supporters
Add new supporters to the donate page. michahRicie is highlighted
as a gold supporter.
2026-03-16 20:35:59 +07:00
zarzet 95e755e54e fix: delay iOS folder picker after sheet dismiss and update Afkar hosts 2026-03-16 20:35:59 +07:00
zarzet c719406425 docs: update readme 2026-03-16 20:35:59 +07:00
zarzet 9627ef66cf fix: verify resolved Tidal/Deezer tracks match the download request before downloading
SongLink can return incorrect track IDs (e.g. a different track from the
same album). Qobuz already had verification via qobuzTrackMatchesRequest.
This adds equivalent verification for Tidal and Deezer using a shared
trackMatchesRequest() helper in title_match_utils.go that checks artist,
title, and duration. Mismatched SongLink/ISRC results are now rejected
so the wrong audio is never embedded with Spotify metadata.
2026-03-16 20:35:59 +07:00
zarzet 15f977d98d fix: skip already-downloaded tracks in Download All for albums and playlists
Album and playlist Download All buttons now check download history and local
library before enqueuing, matching the existing behavior in artist discography
and CSV import. Tracks already in library are skipped with a summary snackbar.
2026-03-16 20:35:59 +07:00
zarzet 5b5f043624 docs: add extension store URL setup guide to README 2026-03-16 20:35:59 +07:00
zarzet 529a920b24 bump version to 3.8.5+111 2026-03-16 20:35:59 +07:00
zarzet 09eb6cf206 fix: use album-level artist for Various Artists albums instead of first track's artist
- Extension: fix extractSchemaOrg to find album-level schema (with numTracks) instead of per-track schema
- Extension: add secondaryText2 fallback in parseDescriptiveRows for VA album track artists
- Extension: use headerPrimaryText as primary album artist source, overriding schema.org
- App: album_screen now uses widget.artistName (album-level) instead of tracks.first.artistName
- App: home_tab _parseTrack now populates albumArtist from track data or album-level artist
- Bump Amazon extension to v2.0.1
2026-03-16 20:35:58 +07:00
zarzet af6fa6ea53 fix: extract cover art from M4A/ALAC files for conversion
Add extractCoverFromM4A() that reads the covr atom from the MP4
box tree (moov/udta/meta/ilst/covr/data). Wire it into
ExtractCoverToFile so ALAC-to-FLAC conversion preserves cover art.
2026-03-16 20:35:58 +07:00
zarzet 280b921755 fix: detect embedded lyrics in M4A/ALAC files
Add extractLyricsFromM4A() that walks the MP4 box tree
(moov/udta/meta/ilst/©lyr) to read lyrics. Wire it into
ExtractLyrics so the Embed Lyrics button is hidden when
lyrics already exist in the file.
2026-03-16 20:35:58 +07:00
zarzet 6ebe0c51ce fix: filter batch convert target formats based on source formats
Exclude same-format and lossy-to-lossless targets from the batch
convert sheet so users cannot pick pointless conversions like
FLAC→FLAC. Also clean up redundant inline comments.
2026-03-16 20:35:58 +07:00
zarzet 47bd24c1bd fix: preserve metadata and cover art in ALAC/M4A to FLAC conversion
- Use -map_metadata 0 instead of -map_metadata -1 so FFmpeg copies and
  auto-remaps source tags (M4A/ID3 → Vorbis comments) as a base
- Add _normalizeToVorbisComments() to filter technical fields (BIT_DEPTH,
  SAMPLE_RATE, DURATION) and normalize key variations to standard Vorbis
  comment names before applying overrides
- Switch cover art embedding from METADATA_BLOCK_PICTURE base64 (unreliable
  on Android due to command-line length limits) to -i cover -map 1:v
  -disposition attached_pic (same proven approach as embedMetadata and
  _convertToAlac)
- Drop zero-value track/disc numbers from override map to prevent
  clobbering source metadata with '0' from Go readFileMetadata
2026-03-16 20:35:58 +07:00
zarzet 2b23678c0d feat: add FLAC/ALAC bidirectional lossless conversion support
- Add _convertToAlac() and _convertToFlac() in ffmpeg_service with
  single-pass FFmpeg encoding, metadata tags, and cover art embedding
- Wire lossless formats (ALAC, FLAC) into single-track convert sheet
  with dynamic format list based on source format, hidden bitrate for
  lossless targets, and lossless hint text
- Add lossless conversion to batch convert UI in downloaded_album,
  local_album, and queue_tab screens with lossy-source filtering
- Fix M4A quality probe in Go backend: increase audio sample entry
  buffer from 24 to 32 bytes, read sample rate from correct offset
  (bytes 28-29) and bit depth from samplesize field (bytes 22-23)
- Add l10n keys for lossless confirm dialogs and hints (en, id)
2026-03-16 20:35:58 +07:00
zarzet e8327545ad feat: improve auto-fill track resolution in Edit Metadata sheet
- Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search
- Score-based match selection via _metadataMatchScore instead of provider order
- Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet
- Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers
- Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult
- Propagate copyright through Deezer enrichment chain (exports, extension_providers)
2026-03-16 20:35:58 +07:00
zarzet 89a38af538 fix: resolve all flutter analyze warnings and improve auto-fill enrichment chain
- Fix use_build_context_synchronously in _embedLyrics by capturing l10n
  strings before async gaps (snackbarFailedToWriteStorage,
  snackbarFailedToEmbedLyrics, snackbarUnsupportedAudioFormat)
- Improve auto-fill metadata enrichment to use proper API chain:
  search providers -> convertSpotifyToDeezer (SongLink) for Deezer ID
  -> getDeezerMetadata for ISRC -> getDeezerExtendedMetadata for
  genre/label/copyright. Falls back to ISRC-based Deezer lookup when
  SongLink conversion unavailable.
- flutter analyze now reports 0 issues
2026-03-16 20:35:58 +07:00
zarzet b7f34ec47c feat: selective auto-fill from online in Edit Metadata sheet
Add 'Auto-fill from online' expandable section to the metadata editor
that lets users choose exactly which fields to populate from online
metadata search. Users can select individual fields via filter chips,
use 'All' or 'Empty only' quick-select buttons, then tap 'Fetch & Fill'
to search metadata providers and fill only the selected controllers.

The search uses existing searchTracksWithMetadataProviders API with
ISRC-preferring best-match selection. Extended metadata (genre, label,
copyright) is fetched via Deezer extended metadata API when available.
Cover art is downloaded from the match's cover_url. All results are
previewed in the editor before saving — nothing is written to the file
until the user taps Save.

Add 21 new l10n keys (editMetadata* namespace) for all UI strings.
2026-03-16 20:35:58 +07:00
zarzet 967523bfc6 feat: queue FLAC redownloads for local library tracks
Add LocalTrackRedownloadService with confidence-scored metadata matching
(ISRC, title, artist, album, duration, track/disc number, year) to find
reliable online matches for locally-stored tracks.

Wire up 'Queue FLAC' selection action in both local_album_screen and
queue_tab (library tab). Shows progress snackbar during resolution,
skips ambiguous or low-confidence matches, and reports results.

Add Indonesian (id) translations for all queueFlac l10n keys.
2026-03-16 20:35:58 +07:00
zarzet 29d8a185f9 fix: handle nested legacy iOS Documents path in validation
Detect and recover from stale sandbox container paths embedded inside
the current Documents directory. Extracts helper functions for path
suffix normalization and joining to reduce duplication.
2026-03-16 20:35:57 +07:00
zarzet 4495d4bf4e feat: add Opus 320kbps quality, remove Tidal HIGH tier
- Add YouTubeQualityOpus320 constant and opus_320 parser case in Go backend
- Expand opus supported bitrates to [128, 256, 320] across Go, Dart settings, and UI
- Update default YouTube Opus option from 256 to 320kbps
- Remove Tidal HIGH (lossy 320kbps) quality from Go backend, settings model,
  settings provider, download queue provider (both SAF and non-SAF paths),
  settings UI (quality option, format picker, helper methods), and l10n keys
- Add settings migration v6: auto-migrate users with audioQuality=HIGH to LOSSLESS
- Update and add Go test cases for opus_320 and adjusted max bitrate
- Regenerate l10n files, remove 10 unused downloadLossy* l10n keys
2026-03-16 20:35:57 +07:00
zarzet 67737467e0 ci: auto-update AltStore source (apps.json) on release 2026-03-16 20:35:57 +07:00
renovate[bot] 13845eea04 chore(deps): update dependency flutter to v3.41.4 2026-03-16 20:35:57 +07:00
zarzet 12779778d3 fix(i18n): localize hardcoded strings in bulk playlist download and fix trailing newlines 2026-03-16 20:35:57 +07:00
ViscousPot d4178ad036 feat: add option to download multiple selected playlists 2026-03-16 20:35:57 +07:00
ViscousPot 49ea84384d feat: auto fill playlist name during import 2026-03-16 20:35:57 +07:00
ViscousPot a6d9849468 Update CONTRIBUTING.md 2026-03-16 20:35:57 +07:00
ViscousPot 16100aa0fd add fvm 2026-03-16 20:35:57 +07:00
zarzet 387dd47374 feat: add Qobuz Afkar API provider and prefer request metadata for consistent album grouping 2026-03-16 20:35:57 +07:00
zarzet 6ecb69feae fix: prevent re-download of tracks converted to a different format
When a file is converted externally (e.g. FLAC to OPUS), the
orphan cleanup would delete the history entry because the original
path no longer exists. Now it checks for sibling files with other
audio extensions and updates the stored path instead of deleting.

Also add extension-stripped keys to path_match_keys so that
paths differing only by audio extension still match during local
library scan exclusion and queue deduplication.
2026-03-16 20:28:53 +07:00
zarzet feff985439 feat: add auto-scan option for local library
Add a new 'Auto Scan' setting under Local Library with four modes:
off, every app open (10min cooldown), daily, and weekly. The app
uses WidgetsBindingObserver to trigger incremental scans on launch
and when resuming from background, respecting the configured
cooldown based on the last scan timestamp.
2026-03-16 20:28:45 +07:00
zarzet 2e8fe34824 fix: remove double horizontal padding in store tab extension list
The extension list was wrapped in an extra Padding(horizontal: 16)
on top of SettingsGroup's default 16px margin, resulting in 32px
total inset. Remove the outer wrapper to match settings tab width.
2026-03-16 20:28:37 +07:00
zarzet f58005f406 docs: add contributors section to README
Add auto-generated contributor avatars via contrib.rocks with a
link to the GitHub contributors page. Include acknowledgement for
translators and bug reporters.
2026-03-16 20:28:31 +07:00
zarzet 75abc03a4f feat: add mc nuggets jimmy, CJBGR and michahRicie as supporters
Add new supporters to the donate page. michahRicie is highlighted
as a gold supporter.
2026-03-16 20:28:25 +07:00
zarzet 84381d142a fix: delay iOS folder picker after sheet dismiss and update Afkar hosts 2026-03-16 20:17:37 +07:00
github-actions[bot] f67f52eba9 chore: update AltStore source to v3.8.5 2026-03-15 21:35:25 +00:00
zarzet 3747ffff64 docs: update readme 2026-03-16 04:26:35 +07:00
zarzet ed47efed17 fix: verify resolved Tidal/Deezer tracks match the download request before downloading
SongLink can return incorrect track IDs (e.g. a different track from the
same album). Qobuz already had verification via qobuzTrackMatchesRequest.
This adds equivalent verification for Tidal and Deezer using a shared
trackMatchesRequest() helper in title_match_utils.go that checks artist,
title, and duration. Mismatched SongLink/ISRC results are now rejected
so the wrong audio is never embedded with Spotify metadata.
2026-03-16 04:16:44 +07:00
zarzet c0d72e89d7 fix: skip already-downloaded tracks in Download All for albums and playlists
Album and playlist Download All buttons now check download history and local
library before enqueuing, matching the existing behavior in artist discography
and CSV import. Tracks already in library are skipped with a summary snackbar.
2026-03-16 04:16:44 +07:00
zarzet a4313cfe0f docs: add extension store URL setup guide to README 2026-03-16 04:16:44 +07:00
zarzet c7bef03ee3 bump version to 3.8.5+111 2026-03-16 04:16:44 +07:00
zarzet ce5a9e0cff fix: use album-level artist for Various Artists albums instead of first track's artist
- Extension: fix extractSchemaOrg to find album-level schema (with numTracks) instead of per-track schema
- Extension: add secondaryText2 fallback in parseDescriptiveRows for VA album track artists
- Extension: use headerPrimaryText as primary album artist source, overriding schema.org
- App: album_screen now uses widget.artistName (album-level) instead of tracks.first.artistName
- App: home_tab _parseTrack now populates albumArtist from track data or album-level artist
- Bump Amazon extension to v2.0.1
2026-03-16 04:16:39 +07:00
zarzet 859b823e77 fix: extract cover art from M4A/ALAC files for conversion
Add extractCoverFromM4A() that reads the covr atom from the MP4
box tree (moov/udta/meta/ilst/covr/data). Wire it into
ExtractCoverToFile so ALAC-to-FLAC conversion preserves cover art.
2026-03-16 02:49:48 +07:00
zarzet 7d8cf5f7ca fix: detect embedded lyrics in M4A/ALAC files
Add extractLyricsFromM4A() that walks the MP4 box tree
(moov/udta/meta/ilst/©lyr) to read lyrics. Wire it into
ExtractLyrics so the Embed Lyrics button is hidden when
lyrics already exist in the file.
2026-03-16 02:43:13 +07:00
zarzet 4adaed8da0 fix: filter batch convert target formats based on source formats
Exclude same-format and lossy-to-lossless targets from the batch
convert sheet so users cannot pick pointless conversions like
FLAC→FLAC. Also clean up redundant inline comments.
2026-03-16 02:39:11 +07:00
zarzet 554fe08fcd fix: preserve metadata and cover art in ALAC/M4A to FLAC conversion
- Use -map_metadata 0 instead of -map_metadata -1 so FFmpeg copies and
  auto-remaps source tags (M4A/ID3 → Vorbis comments) as a base
- Add _normalizeToVorbisComments() to filter technical fields (BIT_DEPTH,
  SAMPLE_RATE, DURATION) and normalize key variations to standard Vorbis
  comment names before applying overrides
- Switch cover art embedding from METADATA_BLOCK_PICTURE base64 (unreliable
  on Android due to command-line length limits) to -i cover -map 1:v
  -disposition attached_pic (same proven approach as embedMetadata and
  _convertToAlac)
- Drop zero-value track/disc numbers from override map to prevent
  clobbering source metadata with '0' from Go readFileMetadata
2026-03-16 02:26:53 +07:00
zarzet b8af75bf6e feat: add FLAC/ALAC bidirectional lossless conversion support
- Add _convertToAlac() and _convertToFlac() in ffmpeg_service with
  single-pass FFmpeg encoding, metadata tags, and cover art embedding
- Wire lossless formats (ALAC, FLAC) into single-track convert sheet
  with dynamic format list based on source format, hidden bitrate for
  lossless targets, and lossless hint text
- Add lossless conversion to batch convert UI in downloaded_album,
  local_album, and queue_tab screens with lossy-source filtering
- Fix M4A quality probe in Go backend: increase audio sample entry
  buffer from 24 to 32 bytes, read sample rate from correct offset
  (bytes 28-29) and bit depth from samplesize field (bytes 22-23)
- Add l10n keys for lossless confirm dialogs and hints (en, id)
2026-03-16 02:13:45 +07:00
zarzet 35f2f119db feat: improve auto-fill track resolution in Edit Metadata sheet
- Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search
- Score-based match selection via _metadataMatchScore instead of provider order
- Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet
- Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers
- Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult
- Propagate copyright through Deezer enrichment chain (exports, extension_providers)
2026-03-15 21:12:47 +07:00
zarzet f36096e0ac fix: resolve all flutter analyze warnings and improve auto-fill enrichment chain
- Fix use_build_context_synchronously in _embedLyrics by capturing l10n
  strings before async gaps (snackbarFailedToWriteStorage,
  snackbarFailedToEmbedLyrics, snackbarUnsupportedAudioFormat)
- Improve auto-fill metadata enrichment to use proper API chain:
  search providers -> convertSpotifyToDeezer (SongLink) for Deezer ID
  -> getDeezerMetadata for ISRC -> getDeezerExtendedMetadata for
  genre/label/copyright. Falls back to ISRC-based Deezer lookup when
  SongLink conversion unavailable.
- flutter analyze now reports 0 issues
2026-03-15 20:42:22 +07:00
zarzet 1665e4cd57 feat: selective auto-fill from online in Edit Metadata sheet
Add 'Auto-fill from online' expandable section to the metadata editor
that lets users choose exactly which fields to populate from online
metadata search. Users can select individual fields via filter chips,
use 'All' or 'Empty only' quick-select buttons, then tap 'Fetch & Fill'
to search metadata providers and fill only the selected controllers.

The search uses existing searchTracksWithMetadataProviders API with
ISRC-preferring best-match selection. Extended metadata (genre, label,
copyright) is fetched via Deezer extended metadata API when available.
Cover art is downloaded from the match's cover_url. All results are
previewed in the editor before saving — nothing is written to the file
until the user taps Save.

Add 21 new l10n keys (editMetadata* namespace) for all UI strings.
2026-03-15 20:35:42 +07:00
zarzet 42f0267277 feat: queue FLAC redownloads for local library tracks
Add LocalTrackRedownloadService with confidence-scored metadata matching
(ISRC, title, artist, album, duration, track/disc number, year) to find
reliable online matches for locally-stored tracks.

Wire up 'Queue FLAC' selection action in both local_album_screen and
queue_tab (library tab). Shows progress snackbar during resolution,
skips ambiguous or low-confidence matches, and reports results.

Add Indonesian (id) translations for all queueFlac l10n keys.
2026-03-15 20:18:58 +07:00
zarzet 82f59d32b9 fix: handle nested legacy iOS Documents path in validation
Detect and recover from stale sandbox container paths embedded inside
the current Documents directory. Extracts helper functions for path
suffix normalization and joining to reduce duplication.
2026-03-15 20:18:29 +07:00
zarzet 941347b007 feat: add Opus 320kbps quality, remove Tidal HIGH tier
- Add YouTubeQualityOpus320 constant and opus_320 parser case in Go backend
- Expand opus supported bitrates to [128, 256, 320] across Go, Dart settings, and UI
- Update default YouTube Opus option from 256 to 320kbps
- Remove Tidal HIGH (lossy 320kbps) quality from Go backend, settings model,
  settings provider, download queue provider (both SAF and non-SAF paths),
  settings UI (quality option, format picker, helper methods), and l10n keys
- Add settings migration v6: auto-migrate users with audioQuality=HIGH to LOSSLESS
- Update and add Go test cases for opus_320 and adjusted max bitrate
- Regenerate l10n files, remove 10 unused downloadLossy* l10n keys
2026-03-15 20:16:44 +07:00
zarzet 739c89569f Merge branch 'main' into dev 2026-03-15 19:42:31 +07:00
zarzet 18607597e9 fix: correct AltStore icon URL to assets/images/logo.png 2026-03-15 19:41:25 +07:00
zarzet 7bb808cba5 ci: auto-update AltStore source (apps.json) on release 2026-03-15 19:11:29 +07:00
Zarz Eleutherius 78cd396847 Merge pull request #233 from Amonoman/main
Add AltStore source and update README documentation
2026-03-15 19:07:50 +07:00
Zarz Eleutherius bb342c01e2 Merge pull request #232 from zarzet/renovate/flutter-3.x
chore(deps): update dependency flutter to v3.41.4
2026-03-15 19:02:31 +07:00
renovate[bot] 8a5dc0edfe chore(deps): update dependency flutter to v3.41.4 2026-03-15 12:02:29 +00:00
Amonoman 8540da484f Add AltStore source and update README 2026-03-15 13:02:23 +01:00
zarzet 20f789f8e0 fix(i18n): localize hardcoded strings in bulk playlist download and fix trailing newlines 2026-03-15 19:01:45 +07:00
Zarz Eleutherius 3e89326c95 Merge pull request #229 from ViscousPot/feat/bulk-download-library-playlists
Add bulk download option for selected library playlists
2026-03-15 18:57:06 +07:00
Zarz Eleutherius a7ea4de25a Merge pull request #228 from ViscousPot/feat/auto-fill-playlist-name-for-import
Auto-fill playlist name when importing from Spotify
2026-03-15 18:56:58 +07:00
Zarz Eleutherius aabfbf062e Merge pull request #230 from ViscousPot/feat/improve-dev+build-instructions
Add FVM config and improve dev setup instructions
2026-03-15 18:56:52 +07:00
zarzet 7b9ed3ec8e feat: add Qobuz Afkar API provider and prefer request metadata for consistent album grouping 2026-03-15 18:52:41 +07:00
ViscousPot 6dad66d62d Update CONTRIBUTING.md 2026-03-15 04:37:00 +00:00
ViscousPot 31018230ee add fvm 2026-03-15 04:12:32 +00:00
ViscousPot 54ddc1f59c feat: auto fill playlist name during import 2026-03-15 02:54:02 +00:00
ViscousPot c6856bd1a1 feat: add option to download multiple selected playlists 2026-03-15 02:50:20 +00:00
zarzet 8c18c7b8f1 Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-03-14 23:12:26 +07:00
zarzet 10c5293f64 chore: update VirusTotal hash for v3.8.0 2026-03-14 23:10:19 +07:00
Zarz Eleutherius c347b6999e Merge pull request #218 from ViscousPot/fix/folder-organization-by-playlist-for-library-playlists
Reviewed and approved. The fix correctly passes playlistName to addMultipleToQueue() for library playlists, consistent with playlist_screen.dart pattern.
2026-03-14 12:57:32 +07:00
ViscousPot adc74741ce Update library_tracks_folder_screen.dart 2026-03-14 01:48:34 +00:00
84 changed files with 7981 additions and 2040 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.41.4"
}
+57
View File
@@ -393,6 +393,63 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-altstore:
runs-on: ubuntu-latest
needs: [get-version, build-ios, create-release]
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
permissions:
contents: write
steps:
- name: Checkout main branch
uses: actions/checkout@v6
with:
ref: main
- name: Download iOS IPA
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
- name: Update apps.json
run: |
VERSION="${{ needs.get-version.outputs.version }}"
VERSION_NUM="${VERSION#v}"
DATE=$(date -u +%Y-%m-%d)
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
if [ -z "$IPA_FILE" ]; then
echo "WARNING: IPA file not found, skipping apps.json update"
exit 0
fi
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
if [ ! -f apps.json ]; then
echo "WARNING: apps.json not found on main, skipping"
exit 0
fi
jq --arg ver "$VERSION_NUM" \
--arg date "$DATE" \
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
--argjson size "$IPA_SIZE" \
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
echo "Updated apps.json:"
cat apps.json
- name: Commit and push
run: |
VERSION="${{ needs.get-version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps.json
git diff --cached --quiet && echo "No changes to commit" || \
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
notify-telegram:
runs-on: ubuntu-latest
needs: [get-version, create-release]
+3
View File
@@ -77,3 +77,6 @@ flutter_*.log
# Development tools
tool/
.claude/settings.local.json
# FVM Version Cache
.fvm/
+2 -2
View File
@@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
- SpotFetch metadata fallback integration for Spotify-blocked regions
- New backend client for `spotify.afkarxyz.fun/api`
- New backend client for `sp.afkarxyz.qzz.io/api`
- Automatic fallback in Spotify metadata fetch path when primary source fails
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
- Includes heuristic detection of lyrics stored in Comment fields
@@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
+17 -3
View File
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Install dependencies**
3. **Use FVM (Flutter Version: 3.38.1)**
```bash
fvm use
```
4. **Install dependencies**
```bash
flutter pub get
```
4. **Generate code** (for Riverpod, JSON serialization, etc.)
5. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
5. **Run the app**
6. **Set up Go environment (Go Version: 1.25.7)**
```bash
cd go_backend
mkdir -p ../android/app/libs
gomobile init
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
cd ..
```
7. **Run the app**
```bash
flutter run
```
+27 -6
View File
@@ -26,7 +26,7 @@
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
@@ -40,10 +40,11 @@ Extensions allow the community to add new music sources and features without wai
### Installing Extensions
1. Go to **Store** tab in the app
2. Browse and install extensions with one tap
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
4. Configure extension settings if needed
5. Set provider priority in **Settings > Extensions > Provider Priority**
2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
3. Browse and install extensions with one tap
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
5. Configure extension settings if needed
6. Set provider priority in **Settings > Extensions > Provider Priority**
### Developing Extensions
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
@@ -55,6 +56,9 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
## FAQ
**Q: Why does the Store tab ask me to enter a URL?**
A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
@@ -73,6 +77,11 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
**Q: Why is download not working in my country?**
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
**Q: Can I add SpotiFLAC to AltStore or SideStore?**
A: Yes! You can add the official source to receive updates directly within the app. Just copy this link:
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link.
### Want to support SpotiFLAC-Mobile?
@@ -80,6 +89,18 @@ _If this software is useful and brings you value, consider supporting the projec
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
## Contributors
Thanks to all the amazing people who have contributed to SpotiFLAC Mobile!
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
</a>
We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile.
Interested in contributing? Check out our [Contributing Guide](CONTRIBUTING.md) to get started!
## API Credits
[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)
@@ -87,4 +108,4 @@ _If this software is useful and brings you value, consider supporting the projec
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
> **Star Us**, You will receive all release notifications from GitHub without any delay
@@ -30,6 +30,7 @@ import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
@@ -111,6 +112,13 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun buildStableLibraryId(filePath: String): String {
val digest = MessageDigest.getInstance("SHA-1")
val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
val hex = bytes.joinToString("") { "%02x".format(it) }
return "lib_$hex"
}
data class SafScanProgress(
var totalFiles: Int = 0,
var scannedFiles: Int = 0,
@@ -1263,7 +1271,9 @@ class MainActivity: FlutterFragmentActivity() {
} else {
try {
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
metadataObj.put("filePath", doc.uri.toString())
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", lastModified)
results.put(metadataObj)
} catch (_: Exception) {
@@ -1680,7 +1690,9 @@ class MainActivity: FlutterFragmentActivity() {
} else {
try {
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
metadataObj.put("filePath", doc.uri.toString())
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", safeLastModified)
metadataObj.put("lastModified", safeLastModified)
results.put(metadataObj)
+18
View File
@@ -0,0 +1,18 @@
{
"name": "SpotiFLAC Source",
"identifier": "com.zarzet.spotiflac.source",
"subtitle": "FLAC Downloader for iOS",
"apps": [
{
"name": "SpotiFLAC",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "3.8.6",
"versionDate": "2026-03-16",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-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": 33676960
}
]
}
+8 -5
View File
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
}
type AlbumExtendedMetadata struct {
Genre string
Label string
Genre string
Label string
Copyright string
}
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
@@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
}
result := &AlbumExtendedMetadata{
Genre: strings.Join(genres, ", "),
Label: album.Label,
Genre: strings.Join(genres, ", "),
Label: album.Label,
Copyright: album.Copyright,
}
c.cacheMu.Lock()
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
return result, nil
}
+46 -7
View File
@@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
}
}
if deezerID != "" {
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct.
}
return trackURL, nil
}
// Try resolving Deezer ID from Spotify ID via SongLink
// Try SongLink
spotifyID := strings.TrimSpace(req.SpotifyID)
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
if err == nil && availability.Deezer && availability.DeezerURL != "" {
return availability.DeezerURL, nil
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track.
} else {
return availability.DeezerURL, nil
}
} else {
return availability.DeezerURL, nil
}
}
}
// Try resolving from ISRC
// Try ISRC
isrc := strings.TrimSpace(req.ISRC)
if isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
if err == nil && track != nil {
deezerID = songLinkExtractDeezerTrackID(track)
if deezerID != "" {
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
}
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
}
}
}
@@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
return "", fmt.Errorf("could not resolve Deezer track URL")
}
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
if err != nil {
return nil // Can't verify — don't block the download.
}
resolved := resolvedTrackInfo{
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
Duration: trackResp.Track.DurationMS / 1000,
}
if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
return nil
}
type deezerMusicDLRequest struct {
Platform string `json:"platform"`
URL string `json:"url"`
+106 -30
View File
@@ -135,6 +135,36 @@ type DownloadResult struct {
DecryptionKey string
}
func preferredReleaseMetadata(
req DownloadRequest,
album string,
releaseDate string,
trackNumber int,
discNumber int,
) (string, string, int, int) {
preferredAlbum := strings.TrimSpace(req.AlbumName)
if preferredAlbum == "" {
preferredAlbum = album
}
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
if preferredReleaseDate == "" {
preferredReleaseDate = releaseDate
}
preferredTrackNumber := req.TrackNumber
if preferredTrackNumber == 0 {
preferredTrackNumber = trackNumber
}
preferredDiscNumber := req.DiscNumber
if preferredDiscNumber == 0 {
preferredDiscNumber = discNumber
}
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
}
func buildDownloadSuccessResponse(
req DownloadRequest,
result DownloadResult,
@@ -153,25 +183,16 @@ func buildDownloadSuccessResponse(
artist = req.ArtistName
}
album := result.Album
if album == "" {
album = req.AlbumName
}
releaseDate := result.ReleaseDate
if releaseDate == "" {
releaseDate = req.ReleaseDate
}
trackNumber := result.TrackNumber
if trackNumber == 0 {
trackNumber = req.TrackNumber
}
discNumber := result.DiscNumber
if discNumber == 0 {
discNumber = req.DiscNumber
}
// Preserve requested release metadata when available so mixed-provider
// fallback downloads from the same source album do not get split into
// different albums just because Tidal/Qobuz report variant titles/dates.
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
req,
result.Album,
result.ReleaseDate,
result.TrackNumber,
result.DiscNumber,
)
isrc := result.ISRC
if isrc == "" {
@@ -262,7 +283,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
return
}
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
return
}
@@ -284,8 +305,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Genre != "" || req.Label != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -1314,10 +1338,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
return "", err
}
result := map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
}
result := buildDeezerExtendedMetadataResult(metadata)
jsonBytes, err := json.Marshal(result)
if err != nil {
@@ -1337,7 +1358,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return "", err
}
jsonBytes, err := json.Marshal(track)
result := buildDeezerISRCSearchResult(track)
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
@@ -1345,6 +1367,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return string(jsonBytes), nil
}
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
if metadata == nil {
return map[string]string{
"genre": "",
"label": "",
"copyright": "",
}
}
return map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
"copyright": metadata.Copyright,
}
}
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
if track == nil {
return map[string]interface{}{}
}
result := map[string]interface{}{
"spotify_id": track.SpotifyID,
"artists": track.Artists,
"name": track.Name,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.Images,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"external_urls": track.ExternalURL,
"isrc": track.ISRC,
"album_id": track.AlbumID,
"artist_id": track.ArtistID,
"album_type": track.AlbumType,
}
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
result["id"] = deezerID
result["track_id"] = deezerID
result["success"] = true
}
return result
}
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -1623,6 +1694,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
coverData, err = extractCoverFromM4A(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
@@ -1803,8 +1876,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
// Try to get extended metadata (genre, label) from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
// Try to get extended metadata from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
@@ -1815,7 +1888,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -0,0 +1,59 @@
package gobackend
import "testing"
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
result := buildDeezerExtendedMetadataResult(nil)
if result["genre"] != "" {
t.Fatalf("expected empty genre, got %q", result["genre"])
}
if result["label"] != "" {
t.Fatalf("expected empty label, got %q", result["label"])
}
if result["copyright"] != "" {
t.Fatalf("expected empty copyright, got %q", result["copyright"])
}
}
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
Genre: "Rock",
Label: "EMI",
Copyright: "(C) Queen",
})
if result["genre"] != "Rock" {
t.Fatalf("unexpected genre: %q", result["genre"])
}
if result["label"] != "EMI" {
t.Fatalf("unexpected label: %q", result["label"])
}
if result["copyright"] != "(C) Queen" {
t.Fatalf("unexpected copyright: %q", result["copyright"])
}
}
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
result := buildDeezerISRCSearchResult(&TrackMetadata{
SpotifyID: "deezer:3135556",
Name: "Love Of My Life",
Artists: "Queen",
AlbumName: "A Night at the Opera",
ISRC: "GBUM71029604",
ReleaseDate: "1975-11-21",
})
if result["spotify_id"] != "deezer:3135556" {
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
}
if result["id"] != "3135556" {
t.Fatalf("unexpected id: %v", result["id"])
}
if result["track_id"] != "3135556" {
t.Fatalf("unexpected track_id: %v", result["track_id"])
}
if result["success"] != true {
t.Fatalf("expected success=true, got %v", result["success"])
}
}
+86
View File
@@ -0,0 +1,86 @@
package gobackend
import "testing"
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
TrackName: "Bonus Track",
ArtistName: "Artist",
AlbumName: "Album (Deluxe)",
AlbumArtist: "Artist",
ReleaseDate: "2024-01-01",
TrackNumber: 14,
DiscNumber: 1,
ISRC: "REQ123",
CoverURL: "https://example.com/cover.jpg",
Genre: "Pop",
Label: "Label",
Copyright: "Copyright",
}
result := DownloadResult{
Title: "Bonus Track",
Artist: "Artist",
Album: "Album",
ReleaseDate: "2023-12-01",
TrackNumber: 2,
DiscNumber: 9,
ISRC: "RES456",
}
resp := buildDownloadSuccessResponse(
req,
result,
"tidal",
"ok",
"/tmp/test.flac",
false,
)
if resp.Album != req.AlbumName {
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
}
if resp.ReleaseDate != req.ReleaseDate {
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
}
if resp.TrackNumber != req.TrackNumber {
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
}
if resp.DiscNumber != req.DiscNumber {
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
}
if resp.Artist != result.Artist {
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
}
if resp.ISRC != result.ISRC {
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
}
}
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
DownloadRequest{
AlbumName: "Album (Deluxe Edition)",
ReleaseDate: "2024-01-01",
TrackNumber: 13,
DiscNumber: 2,
},
"Album",
"2023-01-01",
3,
1,
)
if album != "Album (Deluxe Edition)" {
t.Fatalf("album = %q", album)
}
if releaseDate != "2024-01-01" {
t.Fatalf("release date = %q", releaseDate)
}
if trackNumber != 13 {
t.Fatalf("track number = %d", trackNumber)
}
if discNumber != 2 {
t.Fatalf("disc number = %d", discNumber)
}
}
+13 -4
View File
@@ -1065,8 +1065,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
}
// Try Deezer extended metadata for genre/label if we have ISRC
if req.ISRC != "" && (req.Genre == "" || req.Label == "") {
// Try Deezer extended metadata if we have ISRC
if req.ISRC != "" &&
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
@@ -1077,7 +1078,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
}
@@ -1249,7 +1253,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerIDNormalized) {
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
deezerClient := GetDeezerClient()
@@ -1264,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
}
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
+17 -11
View File
@@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
}
type ExtensionRuntime struct {
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
httpClient *http.Client
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
httpClient *http.Client
downloadClient *http.Client
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
storageMu sync.RWMutex
storageCache map[string]interface{}
@@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
return runtime
}
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops.
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
client := &http.Client{
Transport: sharedTransport,
Timeout: 30 * time.Second,
Timeout: timeout,
Jar: jar,
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
@@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
}
return nil
}
runtime.httpClient = client
return runtime
return client
}
type RedirectBlockedError struct {
+6 -1
View File
@@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
resp, err := r.httpClient.Do(req)
client := r.downloadClient
if client == nil {
client = r.httpClient
}
resp, err := client.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
+4 -3
View File
@@ -346,11 +346,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
return min(nextDelay, config.MaxDelay)
}
// Returns 60 seconds as default if header is missing or invalid
// Returns 0 if the header is missing or invalid so callers can keep their
// normal exponential backoff instead of stalling for an arbitrary minute.
func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
if retryAfter == "" {
return 60 * time.Second
return 0
}
if seconds, err := strconv.Atoi(retryAfter); err == nil {
@@ -364,7 +365,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
}
}
return 60 * time.Second
return 0
}
func ReadResponseBody(resp *http.Response) ([]byte, error) {
+107 -64
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
@@ -121,12 +122,12 @@ func GetLyricsProviderOrder() []string {
// GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
}
}
@@ -431,6 +432,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
return now.Add(10 * time.Minute)
}
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
if len(lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if syncType == "" {
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
syncType = "LINE_SYNCED"
} else {
syncType = "UNSYNCED"
}
}
return &LyricsResponse{
Lines: lines,
SyncType: syncType,
Instrumental: false,
PlainLyrics: plainLyrics,
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}, nil
}
func plainLyricsFromTimedLines(lines []LyricsLine) string {
parts := make([]string, 0, len(lines))
for _, line := range lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
parts = append(parts, words)
}
return strings.Join(parts, "\n")
}
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
var lrcPayload string
if err := json.Unmarshal(body, &lrcPayload); err == nil {
trimmed := strings.TrimSpace(lrcPayload)
if trimmed == "" {
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
}
lines := parseSyncedLyrics(trimmed)
if len(lines) > 0 {
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
}
plainLines := plainTextLyricsLines(trimmed)
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
lines := make([]LyricsLine, 0, len(apiResp.Lines))
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
lines = append(lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
for i := 0; i < len(lines)-1; i++ {
nextStart := lines[i+1].StartTimeMs
if nextStart > lines[i].StartTimeMs {
lines[i].EndTimeMs = nextStart
}
}
if len(lines) > 0 {
last := len(lines) - 1
if lines[last].EndTimeMs == 0 {
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
}
}
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
}
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
now := time.Now()
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
@@ -449,7 +543,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
spotifyID = parsed.ID
}
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -462,13 +556,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
}
if resp.StatusCode != 200 {
if resp.StatusCode == http.StatusTooManyRequests {
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
setSpotifyLyricsRateLimitUntil(retryUntil)
}
var payload map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
@@ -479,63 +578,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
result := &LyricsResponse{
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
SyncType: apiResp.SyncType,
Instrumental: false,
PlainLyrics: "",
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
result.Lines = append(result.Lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
if len(result.Lines) > 1 {
for i := 0; i < len(result.Lines)-1; i++ {
nextStart := result.Lines[i+1].StartTimeMs
if nextStart > result.Lines[i].StartTimeMs {
result.Lines[i].EndTimeMs = nextStart
}
}
last := len(result.Lines) - 1
if result.Lines[last].EndTimeMs == 0 {
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
}
}
if len(result.Lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if result.SyncType == "" {
result.SyncType = "LINE_SYNCED"
}
return result, nil
return parseSpotifyLyricsResponseBody(bodyBytes)
}
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
+65 -126
View File
@@ -4,121 +4,25 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
// AppleMusicClient fetches lyrics from Apple Music.
// Uses a scraped JWT token for search and a proxy for lyrics.
// Uses Paxsenix endpoints for search and lyrics.
type AppleMusicClient struct {
httpClient *http.Client
}
// Apple Music token manager — singleton with mutex for thread safety
type appleTokenManager struct {
mu sync.Mutex
token string
}
var globalAppleTokenManager = &appleTokenManager{}
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.token != "" {
return m.token, nil
}
// Step 1: Fetch the Apple Music beta page
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
}
// Step 2: Find the index JS file URL
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
match := indexJsRegex.Find(body)
if match == nil {
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
}
indexJsURL := "https://beta.music.apple.com" + string(match)
// Step 3: Fetch the JS file
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create JS request: %w", err)
}
jsReq.Header.Set("User-Agent", getRandomUserAgent())
jsResp, err := client.Do(jsReq)
if err != nil {
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
}
defer jsResp.Body.Close()
jsBody, err := io.ReadAll(jsResp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
}
// Step 4: Extract JWT token (starts with eyJh)
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
tokenMatch := tokenRegex.Find(jsBody)
if tokenMatch == nil {
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
}
m.token = string(tokenMatch)
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
return m.token, nil
}
func (m *appleTokenManager) clearToken() {
m.mu.Lock()
defer m.mu.Unlock()
m.token = ""
}
type appleMusicSearchResponse struct {
Results struct {
Songs *struct {
Data []struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"data"`
} `json:"songs"`
} `json:"results"`
Resources *struct {
Songs map[string]struct {
Attributes struct {
Name string `json:"name"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
URL string `json:"url"`
Artwork struct {
URL string `json:"url"`
} `json:"artwork"`
} `json:"attributes"`
} `json:"songs"`
} `json:"resources"`
type appleMusicSearchResult struct {
ID string `json:"id"`
SongName string `json:"songName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
Duration int `json:"duration"`
}
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
@@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient {
}
}
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
if len(results) == 0 {
return nil
}
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
if normalizedArtist == "" {
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := 0
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
switch {
case candidateTrack == normalizedTrack:
score += 50
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
score += 25
}
switch {
case candidateArtist == normalizedArtist:
score += 60
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
score += 30
}
if durationSec > 0 && result.Duration > 0 {
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
if diff <= durationToleranceSec {
score += 20
}
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
return &results[bestIndex]
}
// SearchSong searches for a song on Apple Music and returns its ID.
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("empty search query")
}
token, err := globalAppleTokenManager.getToken(c.httpClient)
if err != nil {
return "", fmt.Errorf("apple music token error: %w", err)
}
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf(
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
encodedQuery,
)
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Origin", "https://music.apple.com")
req.Header.Set("Referer", "https://music.apple.com/")
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
@@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
globalAppleTokenManager.clearToken()
return "", fmt.Errorf("apple music token expired")
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
}
var searchResp appleMusicSearchResponse
var searchResp []appleMusicSearchResult
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return "", fmt.Errorf("failed to decode apple music response: %w", err)
}
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.ID) == "" {
return "", fmt.Errorf("no songs found on apple music")
}
return searchResp.Results.Songs.Data[0].ID, nil
return strings.TrimSpace(best.ID), nil
}
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
@@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
durationSec float64,
multiPersonWordByWord bool,
) (*LyricsResponse, error) {
songID, err := c.SearchSong(trackName, artistName)
songID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
+92 -89
View File
@@ -3,6 +3,8 @@ package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
@@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct {
func NewMusixmatchClient() *MusixmatchClient {
return &MusixmatchClient{
httpClient: NewMetadataHTTPClient(15 * time.Second),
baseURL: "http://158.180.60.95",
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
}
}
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
// The Musixmatch proxy returns both search result and lyrics in a single response.
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
return nil, fmt.Errorf("empty track or artist name")
return "", fmt.Errorf("empty track or artist name")
}
encodedArtist := url.QueryEscape(artistName)
encodedTrack := url.QueryEscape(trackName)
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
params := url.Values{}
params.Set("t", trackName)
params.Set("a", artistName)
params.Set("type", lyricsType)
params.Set("format", "lrc")
if durationSec > 0 {
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
}
if strings.TrimSpace(language) != "" {
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
}
fullURL := c.baseURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("musixmatch search failed: %w", err)
return "", fmt.Errorf("musixmatch request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
trimmed := strings.TrimSpace(string(body))
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
}
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
}
var result musixmatchSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
var lrcPayload string
if err := json.Unmarshal(body, &lrcPayload); err == nil {
lrcPayload = strings.TrimSpace(lrcPayload)
if lrcPayload == "" {
return "", fmt.Errorf("empty musixmatch lyrics payload")
}
return lrcPayload, nil
}
return &result, nil
trimmed := strings.TrimSpace(string(body))
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("%s", errMsg)
}
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
return trimmed, nil
}
return "", fmt.Errorf("failed to decode musixmatch response")
}
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
lang := strings.ToLower(strings.TrimSpace(language))
if songID <= 0 || lang == "" {
return nil, fmt.Errorf("invalid song id or language")
if lang == "" {
return nil, fmt.Errorf("invalid language")
}
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
req, err := http.NewRequest("GET", fullURL, nil)
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
return nil, err
}
var result musixmatchSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
lines := parseSyncedLyrics(lrcText)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
}
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "UNSYNCED",
PlainLyrics: result.UnsyncedLyrics.Lyrics,
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
plainLines := plainTextLyricsLines(lrcText)
if len(plainLines) > 0 {
return &LyricsResponse{
Lines: plainLines,
SyncType: "UNSYNCED",
PlainLyrics: lrcText,
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
@@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
result, err := c.searchAndGetLyrics(trackName, artistName)
if err != nil {
return nil, err
}
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
if localizedErr == nil {
return localized, nil
}
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
}
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
if err != nil {
return nil, err
}
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
lines := parseSyncedLyrics(lrcText)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "UNSYNCED",
PlainLyrics: result.UnsyncedLyrics.Lyrics,
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
plainLines := plainTextLyricsLines(lrcText)
if len(plainLines) > 0 {
return &LyricsResponse{
Lines: plainLines,
SyncType: "UNSYNCED",
PlainLyrics: lrcText,
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
return nil, fmt.Errorf("no lyrics found on musixmatch")
+4 -11
View File
@@ -9,8 +9,7 @@ import (
"time"
)
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
// This is a direct public API — no proxy dependency.
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
type NeteaseClient struct {
httpClient *http.Client
}
@@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
return 0, fmt.Errorf("empty search query")
}
searchURL := "http://music.163.com/api/search/pc"
searchURL := "https://lyrics.paxsenix.org/netease/search"
params := url.Values{}
params.Set("s", query)
params.Set("type", "1")
params.Set("limit", "1")
params.Set("offset", "0")
params.Set("q", query)
fullURL := searchURL + "?" + params.Encode()
@@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
lyricsURL := "http://music.163.com/api/song/lyric"
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
params := url.Values{}
params.Set("id", fmt.Sprintf("%d", songID))
params.Set("lv", "1")
params.Set("tv", "1")
params.Set("rv", "1")
fullURL := lyricsURL + "?" + params.Encode()
+39 -95
View File
@@ -1,45 +1,31 @@
package gobackend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
)
// QQMusicClient fetches lyrics from QQ Music.
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
// Uses Paxsenix metadata lookup for lyrics.
type QQMusicClient struct {
httpClient *http.Client
}
type qqMusicSearchResponse struct {
Data struct {
Song struct {
List []struct {
Title string `json:"title"`
Singer []struct {
Name string `json:"name"`
} `json:"singer"`
Album struct {
Name string `json:"name"`
} `json:"album"`
ID int64 `json:"id"`
} `json:"list"`
} `json:"song"`
} `json:"data"`
type qqLyricsMetadataRequest struct {
Artist []string `json:"artist"`
Album string `json:"album,omitempty"`
SongID int64 `json:"songid,omitempty"`
Title string `json:"title"`
Duration int64 `json:"duration,omitempty"`
}
// QQ Music lyrics request payload for paxsenix proxy
type qqLyricsPayload struct {
Artist []string `json:"artist"`
Album string `json:"album"`
ID int64 `json:"id"`
Title string `json:"title"`
type qqLyricsMetadataResponse struct {
Lyrics []paxLyrics `json:"lyrics"`
}
func NewQQMusicClient() *QQMusicClient {
@@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
}
}
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("empty search query")
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
payload := qqLyricsMetadataRequest{
Artist: []string{artistName},
Title: trackName,
}
if durationSec > 0 {
payload.Duration = int64(math.Round(durationSec))
}
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
params := url.Values{}
params.Set("format", "json")
params.Set("inCharset", "utf8")
params.Set("outCharset", "utf8")
params.Set("platform", "yqq.json")
params.Set("new_json", "1")
params.Set("w", query)
fullURL := searchURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("qqmusic search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
}
var searchResp qqMusicSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
}
if len(searchResp.Data.Song.List) == 0 {
return nil, fmt.Errorf("no songs found on qqmusic")
}
song := searchResp.Data.Song.List[0]
var artists []string
for _, singer := range song.Singer {
artists = append(artists, singer.Name)
}
return &qqLyricsPayload{
Artist: artists,
Album: song.Album.Name,
ID: song.ID,
Title: song.Title,
}, nil
}
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
@@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
return bodyStr, nil
}
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
var response qqLyricsMetadataResponse
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
}
if len(response.Lyrics) == 0 {
return "", fmt.Errorf("qq metadata lyrics response was empty")
}
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
}
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
func (c *QQMusicClient) FetchLyrics(
trackName,
@@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
durationSec float64,
multiPersonWordByWord bool,
) (*LyricsResponse, error) {
payload, err := c.searchSong(trackName, artistName)
if err != nil {
return nil, err
}
rawLyrics, err := c.fetchLyricsByPayload(payload)
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
@@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
}
// Try to parse as pax format (word-by-word or line)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
if err != nil {
// If pax parsing fails, try to use as direct LRC text
lrcText = rawLyrics
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
lrcText = fallback
} else {
lrcText = rawLyrics
}
}
lines := parseSyncedLyrics(lrcText)
+174 -6
View File
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
lyrics, err := extractLyricsFromM4A(filePath)
if err == nil && strings.TrimSpace(lyrics) != "" {
return lyrics, nil
}
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".mp3") {
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
@@ -581,6 +589,153 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
func extractLyricsFromM4A(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return "", err
}
fileSize := fi.Size()
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return "", fmt.Errorf("moov not found")
}
bodyStart := moov.offset + moov.headerSize
bodySize := moov.size - moov.headerSize
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
if err != nil || !found {
return "", fmt.Errorf("udta not found")
}
bodyStart = udta.offset + udta.headerSize
bodySize = udta.size - udta.headerSize
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
if err != nil || !found {
return "", fmt.Errorf("meta not found")
}
// meta atom has 4-byte version/flags after the header
bodyStart = meta.offset + meta.headerSize + 4
bodySize = meta.size - meta.headerSize - 4
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
if err != nil || !found {
return "", fmt.Errorf("ilst not found")
}
bodyStart = ilst.offset + ilst.headerSize
bodySize = ilst.size - ilst.headerSize
lyr, found, err := findAtomInRange(f, bodyStart, bodySize, "\xa9lyr", fileSize)
if err != nil || !found {
return "", fmt.Errorf("lyrics atom not found")
}
dataStart := lyr.offset + lyr.headerSize
dataSize := lyr.size - lyr.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return "", fmt.Errorf("data atom not found in lyrics")
}
// data atom: 8 bytes header + 4 bytes type indicator + 4 bytes locale = skip 8
textStart := dataAtom.offset + dataAtom.headerSize + 8
textLen := dataAtom.size - dataAtom.headerSize - 8
if textLen <= 0 {
return "", fmt.Errorf("empty lyrics")
}
buf := make([]byte, textLen)
if _, err := f.ReadAt(buf, textStart); err != nil {
return "", err
}
return string(buf), nil
}
func extractCoverFromM4A(filePath string) ([]byte, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
fileSize := fi.Size()
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("moov not found")
}
bodyStart := moov.offset + moov.headerSize
bodySize := moov.size - moov.headerSize
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("udta not found")
}
bodyStart = udta.offset + udta.headerSize
bodySize = udta.size - udta.headerSize
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("meta not found")
}
bodyStart = meta.offset + meta.headerSize + 4
bodySize = meta.size - meta.headerSize - 4
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("ilst not found")
}
bodyStart = ilst.offset + ilst.headerSize
bodySize = ilst.size - ilst.headerSize
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("cover atom not found")
}
dataStart := covr.offset + covr.headerSize
dataSize := covr.size - covr.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("data atom not found in cover")
}
// data atom: header + 4 bytes type indicator + 4 bytes locale
imgStart := dataAtom.offset + dataAtom.headerSize + 8
imgLen := dataAtom.size - dataAtom.headerSize - 8
if imgLen <= 0 {
return nil, fmt.Errorf("empty cover data")
}
buf := make([]byte, imgLen)
if _, err := f.ReadAt(buf, imgStart); err != nil {
return nil, err
}
return buf, nil
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
@@ -743,15 +898,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, err
}
buf := make([]byte, 24)
buf := make([]byte, 32)
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
}
sampleRate := int(buf[22])<<8 | int(buf[23])
bitDepth := 16
if atomType == "alac" {
bitDepth = 24
// AudioSampleEntry layout from the box type field:
// [0:4] type ("mp4a"/"alac")
// [4:10] SampleEntry.reserved
// [10:12] data_reference_index
// [12:20] reserved[8]
// [20:22] channelcount
// [22:24] samplesize (bit depth)
// [24:26] pre_defined
// [26:28] reserved
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23])
if bitDepth <= 0 {
bitDepth = 16
if atomType == "alac" {
bitDepth = 24
}
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
@@ -874,7 +1042,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
if bestIdx >= 0 {
absolute := readPos - int64(len(tail)) + int64(bestIdx)
if absolute+24 > fileSize {
if absolute+32 > fileSize {
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
return absolute, bestType, nil
+37 -14
View File
@@ -49,11 +49,13 @@ const (
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
qobuzDebugKeyXORMask = byte(0x5A)
)
@@ -1019,6 +1021,10 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
func (q *QobuzDownloader) GetAvailableAPIs() []string {
return []string{
qobuzDownloadAPIURL,
qobuzDabMusicAPIURL,
qobuzDeebAPIURL,
qobuzAfkarAPIURL,
qobuzSquidAPIURL,
}
}
@@ -1039,6 +1045,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
// "deeb" is mapped from the legacy reference fallback endpoint.
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
// "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
}
}
@@ -1624,19 +1632,23 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
}
func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) {
requestQuality := mapQobuzQualityCodeToAPI(quality)
payload := map[string]any{
"quality": requestQuality,
"upload_to_r2": false,
"url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID),
}
return json.Marshal(payload)
}
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
var lastErr error
retryDelay := qobuzRetryDelay
var payloadBytes []byte
if provider.Kind == qobuzAPIKindMusicDL {
requestQuality := mapQobuzQualityCodeToAPI(quality)
payload := map[string]any{
"quality": requestQuality,
"upload_to_r2": false,
"url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID),
}
var err error
payloadBytes, err = json.Marshal(payload)
payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality)
if err != nil {
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
}
@@ -1681,7 +1693,6 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
}
if provider.Kind == qobuzAPIKindMusicDL {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
}
resp, err := DoRequestWithUserAgent(client, req)
@@ -2156,6 +2167,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
if req.AlbumName != "" {
albumName = req.AlbumName
}
releaseDate := track.Album.ReleaseDate
if req.ReleaseDate != "" {
releaseDate = req.ReleaseDate
}
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
@@ -2167,7 +2182,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist,
Date: track.Album.ReleaseDate,
Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
@@ -2231,16 +2246,24 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
lyricsLRC = parallelResult.LyricsLRC
}
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
req,
track.Album.Title,
track.Album.ReleaseDate,
actualTrackNumber,
req.DiscNumber,
)
return QobuzDownloadResult{
FilePath: outputPath,
BitDepth: actualBitDepth,
SampleRate: actualSampleRate,
Title: track.Title,
Artist: track.Performer.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber,
Album: resultAlbum,
ReleaseDate: resultReleaseDate,
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
+30 -3
View File
@@ -1,6 +1,9 @@
package gobackend
import "testing"
import (
"encoding/json"
"testing"
)
func TestParseQobuzURL(t *testing.T) {
tests := []struct {
@@ -195,6 +198,28 @@ func TestGetQobuzDebugKey(t *testing.T) {
}
}
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
if err != nil {
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
t.Fatalf("payload is not valid JSON: %v", err)
}
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
}
if got := payload["quality"]; got != "hi-res" {
t.Fatalf("payload quality = %v, want hi-res", got)
}
if got := payload["upload_to_r2"]; got != false {
t.Fatalf("payload upload_to_r2 = %v, want false", got)
}
}
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
body := []byte(`
<button data-itemtype="album" data-itemId="0886446451985"></button>
@@ -213,14 +238,16 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
func TestQobuzAvailableProviders(t *testing.T) {
providers := NewQobuzDownloader().GetAvailableProviders()
if len(providers) != 3 {
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
if len(providers) != 5 {
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
}
want := map[string]string{
"musicdl": qobuzAPIKindMusicDL,
"dabmusic": qobuzAPIKindStandard,
"deeb": qobuzAPIKindStandard,
"qbz": qobuzAPIKindStandard,
"squid": qobuzAPIKindStandard,
}
for _, provider := range providers {
+144 -43
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -14,6 +15,10 @@ type SongLinkClient struct {
client *http.Client
}
type songLinkPlatformLink struct {
URL string `json:"url"`
}
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
@@ -43,6 +48,7 @@ var (
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
return s.CheckAvailabilityFromDeezer(deezerTrackID)
}
songLinkRetryConfig = DefaultRetryConfig
)
func NewSongLinkClient() *SongLinkClient {
@@ -130,7 +136,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
if pageErr == nil {
return availability, nil
}
if !songLinkRateLimiter.TryAcquire() {
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
}
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
@@ -140,10 +153,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
}
defer resp.Body.Close()
@@ -154,10 +167,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
return nil, fmt.Errorf("track not found on any streaming platform")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
}
body, err := ReadResponseBody(resp)
@@ -166,59 +179,102 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
req, err := http.NewRequest("GET", pageURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on song.link page")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read song.link page: %w", err)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
nextDataJSON, err := extractSongLinkNextDataJSON(body)
if err != nil {
return nil, err
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
var pageData struct {
Props struct {
PageProps struct {
PageData struct {
Sections []struct {
Links []struct {
Platform string `json:"platform"`
URL string `json:"url"`
Show bool `json:"show"`
} `json:"links"`
} `json:"sections"`
} `json:"pageData"`
} `json:"pageProps"`
} `json:"props"`
}
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
}
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
// Fallback to regular youtube if youtubeMusic not available
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
linksByPlatform := make(map[string]songLinkPlatformLink)
for _, section := range pageData.Props.PageProps.PageData.Sections {
for _, link := range section.Links {
if !link.Show || strings.TrimSpace(link.URL) == "" {
continue
}
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
}
}
return availability, nil
if len(linksByPlatform) == 0 {
return nil, fmt.Errorf("song.link page contained no usable platform links")
}
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
}
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
const endMarker = `</script>`
start := bytes.Index(body, []byte(startMarker))
if start < 0 {
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
}
start += len(startMarker)
end := bytes.Index(body[start:], []byte(endMarker))
if end < 0 {
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
}
return body[start : start+end], nil
}
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
@@ -459,7 +515,7 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check album availability: %w", err)
@@ -542,7 +598,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
@@ -647,7 +703,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
@@ -728,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return availability, nil
}
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
if availability.SpotifyID == "" {
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
}
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability
}
func extractSpotifyIDFromURL(spotifyURL string) string {
parts := strings.Split(spotifyURL, "/track/")
if len(parts) > 1 {
@@ -802,7 +903,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
+127
View File
@@ -0,0 +1,127 @@
package gobackend
import (
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
resp := &http.Response{
Header: make(http.Header),
}
if got := getRetryAfterDuration(resp); got != 0 {
t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
}
}
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.URL.Host == "api.song.link":
t.Fatalf("api.song.link should not be called when song.link page succeeds")
return nil, nil
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
default:
t.Fatalf("unexpected request: %s", req.URL.String())
return nil, nil
}
}),
},
}
availability, err := client.CheckTrackAvailability("testspotifyid", "")
if err != nil {
t.Fatalf("CheckTrackAvailability() error = %v", err)
}
if availability.SpotifyID != "testspotifyid" {
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
}
if !availability.Deezer || availability.DeezerID != "908604612" {
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
}
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
}
if availability.YouTubeID != "testvideoid1" {
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
}
}
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
origRetryConfig := songLinkRetryConfig
songLinkRetryConfig = func() RetryConfig {
return RetryConfig{
MaxRetries: 0,
InitialDelay: 0,
MaxDelay: 0,
BackoffFactor: 1,
}
}
defer func() {
songLinkRetryConfig = origRetryConfig
}()
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
return &http.Response{
StatusCode: 500,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("page failure")),
Request: req,
}, nil
case req.URL.Host == "api.song.link":
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
default:
t.Fatalf("unexpected request: %s", req.URL.String())
return nil, nil
}
}),
},
}
availability, err := client.CheckTrackAvailability("testspotifyid", "")
if err != nil {
t.Fatalf("CheckTrackAvailability() error = %v", err)
}
if availability.SpotifyID != "testspotifyid" {
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
}
if !availability.Deezer || availability.DeezerID != "908604612" {
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
}
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
}
if availability.YouTubeID != "testvideoid1" {
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"time"
)
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
// This is used as a fallback when direct Spotify API access is blocked/limited.
+170 -44
View File
@@ -26,8 +26,14 @@ type TidalDownloader struct {
}
var (
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) {
return t.getTrackSearchPage(query, limit)
}
tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) {
return t.getPublicTrack(resourceID)
}
)
const (
@@ -758,15 +764,101 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
}
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
if normalizedISRC == "" {
return nil, fmt.Errorf("empty tidal ISRC")
}
page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20)
if err != nil {
return nil, err
}
for i := range page.Items {
if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) {
return &page.Items[i], nil
}
}
return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC)
}
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
queryParts := make([]string, 0, 3)
if trimmed := strings.TrimSpace(trackName); trimmed != "" {
queryParts = append(queryParts, trimmed)
}
if trimmed := strings.TrimSpace(artistName); trimmed != "" {
queryParts = append(queryParts, trimmed)
}
if len(queryParts) == 0 {
return nil, fmt.Errorf("tidal metadata search requires track or artist name")
}
queries := []string{strings.Join(queryParts, " ")}
if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" {
queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " "))
}
req := DownloadRequest{
TrackName: strings.TrimSpace(trackName),
ArtistName: strings.TrimSpace(artistName),
AlbumName: strings.TrimSpace(albumName),
ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)),
DurationMS: expectedDuration * 1000,
}
seenQueries := make(map[string]struct{}, len(queries))
for _, query := range queries {
if _, seen := seenQueries[query]; seen {
continue
}
seenQueries[query] = struct{}{}
page, err := tidalGetTrackSearchPageFunc(t, query, 20)
if err != nil {
return nil, err
}
var candidates []*TidalTrack
for i := range page.Items {
track := &page.Items[i]
if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) {
continue
}
resolved := resolvedTrackInfo{
Title: strings.TrimSpace(track.Title),
ArtistName: tidalTrackArtistsDisplay(track),
Duration: track.Duration,
}
if trackMatchesRequest(req, resolved, "Tidal search") {
candidates = append(candidates, track)
}
}
if len(candidates) == 0 {
continue
}
if req.AlbumName != "" {
for _, candidate := range candidates {
if titlesMatch(req.AlbumName, candidate.Album.Title) {
return candidate, nil
}
}
}
return candidates[0], nil
}
if req.ISRC != "" {
return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC)
}
return nil, fmt.Errorf("no tidal metadata match found")
}
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0)
}
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
@@ -1847,6 +1939,36 @@ 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(
req.TrackName,
req.ArtistName,
req.AlbumName,
req.ISRC,
expectedDurationSec,
)
if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 {
trackID = searchTrack.ID
gotTidalID = true
GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID)
} else if searchErr != nil {
GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr)
}
}
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
@@ -1911,6 +2033,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
}
// Verify the resolved track matches the request.
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
if fetchErr != nil {
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
// Continue without verification — better than failing entirely.
} else {
providerArtist := actualTrack.Artist.Name
if providerArtist == "" && len(actualTrack.Artists) > 0 {
providerArtist = actualTrack.Artists[0].Name
}
resolved := resolvedTrackInfo{
Title: actualTrack.Title,
ArtistName: providerArtist,
Duration: actualTrack.Duration,
}
if !trackMatchesRequest(req, resolved, logPrefix) {
// Invalidate the cached ID so future requests don't reuse it.
if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, 0)
}
return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'",
trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
}
track := &TidalTrack{
ID: trackID,
Title: strings.TrimSpace(req.TrackName),
@@ -1961,11 +2109,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
outputExt := strings.TrimSpace(req.OutputExt)
if outputExt == "" {
if quality == "HIGH" {
outputExt = ".m4a"
} else {
outputExt = ".flac"
}
outputExt = ".flac"
} else if !strings.HasPrefix(outputExt, ".") {
outputExt = "." + outputExt
}
@@ -1979,7 +2123,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
m4aPath = outputPath
} else {
if outputExt == ".m4a" || quality == "HIGH" {
if outputExt == ".m4a" {
filename = sanitizeFilename(filename) + ".m4a"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = outputPath
@@ -1992,10 +2136,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
if quality != "HIGH" {
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
}
@@ -2151,27 +2293,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
if quality == "HIGH" {
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
}
}
}
} else {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
}
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
}
if !isSafOutput {
@@ -2181,24 +2303,28 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
lyricsLRC := ""
if quality == "HIGH" {
bitDepth = 0
sampleRate = 44100
}
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
req,
track.Album.Title,
track.Album.ReleaseDate,
actualTrackNumber,
actualDiscNumber,
)
return TidalDownloadResult{
FilePath: actualOutputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: track.Title,
Artist: track.Artist.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber,
Album: resultAlbum,
ReleaseDate: resultReleaseDate,
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
+42
View File
@@ -68,3 +68,45 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String()
}
// ==================== Shared Track Verification ====================
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct {
Title string
ArtistName string
Duration int // seconds
}
// trackMatchesRequest checks whether a resolved track from a provider matches
// the original download request. Returns true if the track is a plausible match.
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
if req.ArtistName != "" && resolved.ArtistName != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) {
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
if req.TrackName != "" && resolved.Title != "" &&
!titlesMatch(req.TrackName, resolved.Title) {
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
logPrefix, req.TrackName, resolved.Title)
return false
}
expectedDurationSec := req.DurationMS / 1000
if expectedDurationSec > 0 && resolved.Duration > 0 {
diff := expectedDurationSec - resolved.Duration
if diff < 0 {
diff = -diff
}
if diff > 10 {
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
logPrefix, expectedDurationSec, resolved.Duration)
return false
}
}
return true
}
+4 -1
View File
@@ -29,6 +29,7 @@ var (
type YouTubeQuality string
const (
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
@@ -37,7 +38,7 @@ const (
)
var (
youtubeOpusSupportedBitrates = []int{128, 256}
youtubeOpusSupportedBitrates = []int{128, 256, 320}
youtubeMp3SupportedBitrates = []int{128, 256, 320}
)
@@ -146,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
switch normalizedRaw {
case "opus_256", "opus256", "opus":
return "opus", 256, YouTubeQualityOpus256
case "opus_320", "opus320":
return "opus", 320, YouTubeQualityOpus320
case "opus_128", "opus128":
return "opus", 128, YouTubeQualityOpus128
case "mp3_320", "mp3320", "mp3", "":
+15 -2
View File
@@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T)
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
if opusBitrate != 256 {
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
if opusBitrate != 320 {
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
}
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
@@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
}
}
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
if format != "opus" {
t.Fatalf("expected opus format, got %s", format)
}
if bitrate != 320 {
t.Fatalf("expected 320 bitrate, got %d", bitrate)
}
if normalized != YouTubeQualityOpus320 {
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
}
}
+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 = '3.8.0';
static const String buildNumber = '106';
static const String version = '3.8.8';
static const String buildNumber = '114';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
+251 -68
View File
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
/// **'Import'**
String get dialogImport;
/// Confirm button in Download All dialog
///
/// In en, this message translates to:
/// **'Download'**
String get dialogDownload;
/// Dialog button - discard changes
///
/// In en, this message translates to:
@@ -3100,6 +3106,42 @@ abstract class AppLocalizations {
/// **'Show when searching for existing tracks'**
String get libraryShowDuplicateIndicatorSubtitle;
/// Setting for automatic library scanning
///
/// In en, this message translates to:
/// **'Auto Scan'**
String get libraryAutoScan;
/// Subtitle for auto scan setting
///
/// In en, this message translates to:
/// **'Automatically scan your library for new files'**
String get libraryAutoScanSubtitle;
/// Auto scan disabled
///
/// In en, this message translates to:
/// **'Off'**
String get libraryAutoScanOff;
/// Auto scan when app opens
///
/// In en, this message translates to:
/// **'Every app open'**
String get libraryAutoScanOnOpen;
/// Auto scan once per day
///
/// In en, this message translates to:
/// **'Daily'**
String get libraryAutoScanDaily;
/// Auto scan once per week
///
/// In en, this message translates to:
/// **'Weekly'**
String get libraryAutoScanWeekly;
/// Section header for library actions
///
/// In en, this message translates to:
@@ -3832,6 +3874,36 @@ abstract class AppLocalizations {
/// **'FFmpeg metadata embed failed'**
String get trackReEnrichFfmpegFailed;
/// Action/button label for queueing FLAC redownloads for local tracks
///
/// In en, this message translates to:
/// **'Queue FLAC'**
String get queueFlacAction;
/// Confirmation dialog body before queueing FLAC redownloads for local tracks
///
/// In en, this message translates to:
/// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'**
String queueFlacConfirmMessage(int count);
/// Snackbar while resolving remote matches for local FLAC redownloads
///
/// In en, this message translates to:
/// **'Finding FLAC matches... ({current}/{total})'**
String queueFlacFindingProgress(int current, int total);
/// Snackbar when no safe FLAC redownload matches were found
///
/// In en, this message translates to:
/// **'No reliable online matches found for the selection'**
String get queueFlacNoReliableMatches;
/// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped
///
/// In en, this message translates to:
/// **'Added {addedCount} tracks to queue, skipped {skippedCount}'**
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount);
/// Snackbar when save operation fails
///
/// In en, this message translates to:
@@ -3847,7 +3919,7 @@ abstract class AppLocalizations {
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3 or Opus'**
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
@@ -3884,6 +3956,21 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless-to-lossless conversion
///
/// In en, this message translates to:
/// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
);
/// Hint shown when converting between lossless formats
///
/// In en, this message translates to:
/// **'Lossless conversion — no quality loss'**
String get trackConvertLosslessHint;
/// Snackbar while converting
///
/// In en, this message translates to:
@@ -4254,6 +4341,12 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless batch conversion
///
/// In en, this message translates to:
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
String selectionBatchConvertConfirmMessageLossless(int count, String format);
/// Snackbar during batch conversion progress
///
/// In en, this message translates to:
@@ -4434,7 +4527,7 @@ abstract class AppLocalizations {
/// **'Added {count} tracks to Loved'**
String snackbarAddedTracksToLoved(int count);
/// Title of the Download All confirmation dialog
/// Dialog title for bulk download confirmation
///
/// In en, this message translates to:
/// **'Download All'**
@@ -4446,12 +4539,6 @@ abstract class AppLocalizations {
/// **'Download {count} tracks?'**
String dialogDownloadAllMessage(int count);
/// Confirm button in Download All dialog
///
/// In en, this message translates to:
/// **'Download'**
String get dialogDownload;
/// Checkbox label in import dialog to skip already-downloaded songs
///
/// In en, this message translates to:
@@ -4602,18 +4689,6 @@ abstract class AppLocalizations {
/// **'Select a built-in service to enable'**
String get downloadSelectServiceToEnable;
/// Quality option label for Tidal lossy 320kbps
///
/// In en, this message translates to:
/// **'Lossy 320kbps'**
String get downloadLossy320;
/// Setting title to pick output format for Tidal lossy downloads
///
/// In en, this message translates to:
/// **'Lossy Format'**
String get downloadLossyFormat;
/// Info hint when non-Tidal/Qobuz service is selected
///
/// In en, this message translates to:
@@ -4740,54 +4815,6 @@ abstract class AppLocalizations {
/// **'Auto'**
String get downloadMusixmatchAuto;
/// Title of the Tidal lossy format picker bottom sheet
///
/// In en, this message translates to:
/// **'Lossy 320kbps Format'**
String get downloadLossy320Format;
/// Description in the Tidal lossy format picker
///
/// In en, this message translates to:
/// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
String get downloadLossy320FormatDesc;
/// Tidal lossy format option - MP3 320kbps
///
/// In en, this message translates to:
/// **'MP3 320kbps'**
String get downloadLossyMp3;
/// Subtitle for MP3 320kbps option
///
/// In en, this message translates to:
/// **'Best compatibility, ~10MB per track'**
String get downloadLossyMp3Subtitle;
/// Tidal lossy format option - Opus 256kbps
///
/// In en, this message translates to:
/// **'Opus 256kbps'**
String get downloadLossyOpus256;
/// Subtitle for Opus 256kbps option
///
/// In en, this message translates to:
/// **'Best quality Opus, ~8MB per track'**
String get downloadLossyOpus256Subtitle;
/// Tidal lossy format option - Opus 128kbps
///
/// In en, this message translates to:
/// **'Opus 128kbps'**
String get downloadLossyOpus128;
/// Subtitle for Opus 128kbps option
///
/// In en, this message translates to:
/// **'Smallest size, ~4MB per track'**
String get downloadLossyOpus128Subtitle;
/// Subtitle for 'Any' network mode option
///
/// In en, this message translates to:
@@ -4817,6 +4844,162 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Refresh'**
String get cacheRefresh;
/// Dialog message for bulk playlist download confirmation
///
/// In en, this message translates to:
/// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'**
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount);
/// Button label for bulk downloading selected playlists
///
/// In en, this message translates to:
/// **'Download {count} {count, plural, =1{playlist} other{playlists}}'**
String bulkDownloadPlaylistsButton(int count);
/// Button label when no playlists are selected for download
///
/// In en, this message translates to:
/// **'Select playlists to download'**
String get bulkDownloadSelectPlaylists;
/// Snackbar when selected playlists contain no tracks
///
/// In en, this message translates to:
/// **'Selected playlists have no tracks'**
String get snackbarSelectedPlaylistsEmpty;
/// Playlist count display
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
String playlistsCount(int count);
/// Section title for selective online metadata auto-fill in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Auto-fill from online'**
String get editMetadataAutoFill;
/// Description for the auto-fill section
///
/// In en, this message translates to:
/// **'Select fields to fill automatically from online metadata'**
String get editMetadataAutoFillDesc;
/// Button label to fetch online metadata and fill selected fields
///
/// In en, this message translates to:
/// **'Fetch & Fill'**
String get editMetadataAutoFillFetch;
/// Snackbar shown while searching for online metadata
///
/// In en, this message translates to:
/// **'Searching online...'**
String get editMetadataAutoFillSearching;
/// Snackbar when online metadata search returns no results
///
/// In en, this message translates to:
/// **'No matching metadata found online'**
String get editMetadataAutoFillNoResults;
/// Snackbar confirming how many fields were auto-filled
///
/// In en, this message translates to:
/// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'**
String editMetadataAutoFillDone(int count);
/// Snackbar when user taps Fetch without selecting any fields
///
/// In en, this message translates to:
/// **'Select at least one field to auto-fill'**
String get editMetadataAutoFillNoneSelected;
/// Chip label for title field in auto-fill selector
///
/// In en, this message translates to:
/// **'Title'**
String get editMetadataFieldTitle;
/// Chip label for artist field in auto-fill selector
///
/// In en, this message translates to:
/// **'Artist'**
String get editMetadataFieldArtist;
/// Chip label for album field in auto-fill selector
///
/// In en, this message translates to:
/// **'Album'**
String get editMetadataFieldAlbum;
/// Chip label for album artist field in auto-fill selector
///
/// In en, this message translates to:
/// **'Album Artist'**
String get editMetadataFieldAlbumArtist;
/// Chip label for date field in auto-fill selector
///
/// In en, this message translates to:
/// **'Date'**
String get editMetadataFieldDate;
/// Chip label for track number field in auto-fill selector
///
/// In en, this message translates to:
/// **'Track #'**
String get editMetadataFieldTrackNum;
/// Chip label for disc number field in auto-fill selector
///
/// In en, this message translates to:
/// **'Disc #'**
String get editMetadataFieldDiscNum;
/// Chip label for genre field in auto-fill selector
///
/// In en, this message translates to:
/// **'Genre'**
String get editMetadataFieldGenre;
/// Chip label for ISRC field in auto-fill selector
///
/// In en, this message translates to:
/// **'ISRC'**
String get editMetadataFieldIsrc;
/// Chip label for label field in auto-fill selector
///
/// In en, this message translates to:
/// **'Label'**
String get editMetadataFieldLabel;
/// Chip label for copyright field in auto-fill selector
///
/// In en, this message translates to:
/// **'Copyright'**
String get editMetadataFieldCopyright;
/// Chip label for cover art field in auto-fill selector
///
/// In en, this message translates to:
/// **'Cover Art'**
String get editMetadataFieldCover;
/// Button to select all fields for auto-fill
///
/// In en, this message translates to:
/// **'All'**
String get editMetadataSelectAll;
/// Button to select only fields that are currently empty
///
/// In en, this message translates to:
/// **'Empty only'**
String get editMetadataSelectEmpty;
}
class _AppLocalizationsDelegate
+187 -35
View File
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dialogImport => 'Importieren';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Verwerfen';
@@ -1716,6 +1719,25 @@ class AppLocalizationsDe extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Bei der Suche nach vorhandenen Titeln anzeigen';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Aktionen';
@@ -2169,6 +2191,28 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackReEnrichFfmpegFailed =>
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Fehler: $error';
@@ -2201,6 +2245,18 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Konvertiere Audio...';
@@ -2455,6 +2511,17 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Konvertiere $current von $total...';
@@ -2580,9 +2647,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2666,12 +2730,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2748,32 +2806,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2790,4 +2822,124 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+189 -36
View File
@@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1692,6 +1695,25 @@ class AppLocalizationsEn extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2142,6 +2164,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2151,7 +2195,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2174,6 +2219,18 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2484,17 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2620,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2703,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2779,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,4 +2795,124 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+189 -36
View File
@@ -525,6 +525,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1692,6 +1695,25 @@ class AppLocalizationsEs extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2142,6 +2164,28 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2151,7 +2195,8 @@ class AppLocalizationsEs extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2174,6 +2219,18 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2484,17 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2620,6 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2703,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2779,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,6 +2795,126 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+187 -35
View File
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1694,6 +1697,25 @@ class AppLocalizationsFr extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2144,6 +2166,28 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2176,6 +2220,18 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2429,6 +2485,17 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2554,9 +2621,6 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2640,12 +2704,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2722,32 +2780,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2764,4 +2796,124 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+187 -35
View File
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1692,6 +1695,25 @@ class AppLocalizationsHi extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2142,6 +2164,28 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2174,6 +2218,18 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2483,17 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2619,6 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2702,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2778,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,4 +2794,124 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+189 -36
View File
@@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get dialogImport => 'Impor';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Buang';
@@ -1699,6 +1702,25 @@ class AppLocalizationsId extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2149,6 +2171,28 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Antrekan FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Mencari kecocokan FLAC... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2158,7 +2202,8 @@ class AppLocalizationsId extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Konversi ke MP3, Opus, ALAC, atau FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2181,6 +2226,18 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
}
@override
String get trackConvertLosslessHint =>
'Konversi lossless — tanpa kehilangan kualitas';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2434,6 +2491,17 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2559,9 +2627,6 @@ class AppLocalizationsId extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2645,12 +2710,6 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2727,32 +2786,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2769,4 +2802,124 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+187 -35
View File
@@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get dialogImport => 'インポート';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => '破棄';
@@ -1679,6 +1682,25 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'アクション';
@@ -2129,6 +2151,28 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return '失敗: $error';
@@ -2161,6 +2205,18 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'オーディオを変換中...';
@@ -2414,6 +2470,17 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2539,9 +2606,6 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2625,12 +2689,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2707,32 +2765,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2749,4 +2781,124 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+187 -35
View File
@@ -510,6 +510,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get dialogImport => '불러오기';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => '취소';
@@ -1672,6 +1675,25 @@ class AppLocalizationsKo extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2122,6 +2144,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2154,6 +2198,18 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2407,6 +2463,17 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2532,9 +2599,6 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2618,12 +2682,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2700,32 +2758,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2742,4 +2774,124 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+187 -35
View File
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1692,6 +1695,25 @@ class AppLocalizationsNl extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2142,6 +2164,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2174,6 +2218,18 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2483,17 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2619,6 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2702,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2778,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,4 +2794,124 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+189 -36
View File
@@ -525,6 +525,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1692,6 +1695,25 @@ class AppLocalizationsPt extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2142,6 +2164,28 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2151,7 +2195,8 @@ class AppLocalizationsPt extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2174,6 +2219,18 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2484,17 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2620,6 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2703,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2779,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,6 +2795,126 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+187 -35
View File
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get dialogImport => 'Импорт';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Отменить';
@@ -1728,6 +1731,25 @@ class AppLocalizationsRu extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Показать при поиске существующих треков';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Действия';
@@ -2195,6 +2217,28 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackReEnrichFfmpegFailed =>
'Ошибка встраивания метаданных FFmpeg';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Ошибка: $error';
@@ -2227,6 +2271,18 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Конвертация аудио...';
@@ -2486,6 +2542,17 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Конвертация $current из $total...';
@@ -2611,9 +2678,6 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2697,12 +2761,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2779,32 +2837,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2821,4 +2853,124 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+187 -35
View File
@@ -530,6 +530,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get dialogImport => 'İçe aktar';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Vazgeç';
@@ -1704,6 +1707,25 @@ class AppLocalizationsTr extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2154,6 +2176,28 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2186,6 +2230,18 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2439,6 +2495,17 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2564,9 +2631,6 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2650,12 +2714,6 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2732,32 +2790,6 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2774,4 +2806,124 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+189 -36
View File
@@ -525,6 +525,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -1692,6 +1695,25 @@ class AppLocalizationsZh extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2142,6 +2164,28 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2151,7 +2195,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2174,6 +2219,18 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2484,17 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2620,6 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2703,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2779,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,6 +2795,126 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
/// The translations for Chinese, as used in China (`zh_CN`).
+231 -41
View File
@@ -671,6 +671,10 @@
"@dialogImport": {
"description": "Dialog button - import data"
},
"dialogDownload": "Download",
"@dialogDownload": {
"description": "Dialog button - download action"
},
"dialogDiscard": "Discard",
"@dialogDiscard": {
"description": "Dialog button - discard changes"
@@ -2238,6 +2242,30 @@
"@libraryShowDuplicateIndicatorSubtitle": {
"description": "Subtitle for duplicate indicator toggle"
},
"libraryAutoScan": "Auto Scan",
"@libraryAutoScan": {
"description": "Setting for automatic library scanning"
},
"libraryAutoScanSubtitle": "Automatically scan your library for new files",
"@libraryAutoScanSubtitle": {
"description": "Subtitle for auto scan setting"
},
"libraryAutoScanOff": "Off",
"@libraryAutoScanOff": {
"description": "Auto scan disabled"
},
"libraryAutoScanOnOpen": "Every app open",
"@libraryAutoScanOnOpen": {
"description": "Auto scan when app opens"
},
"libraryAutoScanDaily": "Daily",
"@libraryAutoScanDaily": {
"description": "Auto scan once per day"
},
"libraryAutoScanWeekly": "Weekly",
"@libraryAutoScanWeekly": {
"description": "Auto scan once per week"
},
"libraryActions": "Actions",
"@libraryActions": {
"description": "Section header for library actions"
@@ -2815,6 +2843,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"queueFlacAction": "Queue FLAC",
"@queueFlacAction": {
"description": "Action/button label for queueing FLAC redownloads for local tracks"
},
"queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected",
"@queueFlacConfirmMessage": {
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})",
"@queueFlacFindingProgress": {
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"queueFlacNoReliableMatches": "No reliable online matches found for the selection",
"@queueFlacNoReliableMatches": {
"description": "Snackbar when no safe FLAC redownload matches were found"
},
"queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}",
"@queueFlacQueuedWithSkipped": {
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
"placeholders": {
"addedCount": {
"type": "int"
},
"skippedCount": {
"type": "int"
}
}
},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2828,7 +2897,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2863,6 +2932,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Lossless conversion — no quality loss",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3214,6 +3299,18 @@
}
}
},
"selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
@@ -3509,14 +3606,7 @@
"@downloadSelectServiceToEnable": {
"description": "Hint shown instead of Ask-quality subtitle when no built-in service selected"
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality",
"@downloadSelectTidalQobuz": {
"description": "Info hint when non-Tidal/Qobuz service is selected"
@@ -3602,38 +3692,7 @@
"@downloadMusixmatchAuto": {
"description": "Button to reset Musixmatch language to automatic"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps option"
},
"downloadNetworkAnySubtitle": "WiFi + Mobile Data",
"@downloadNetworkAnySubtitle": {
"description": "Subtitle for 'Any' network mode option"
@@ -3657,5 +3716,136 @@
"cacheRefresh": "Refresh",
"@cacheRefresh": {
"description": "Tooltip for refresh button on cache management page"
},
"dialogDownloadAllTitle": "Download All",
"@dialogDownloadAllTitle": {
"description": "Dialog title for bulk download confirmation"
},
"dialogDownloadPlaylistsMessage": "Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?",
"@dialogDownloadPlaylistsMessage": {
"description": "Dialog message for bulk playlist download confirmation",
"placeholders": {
"trackCount": {
"type": "int"
},
"playlistCount": {
"type": "int"
}
}
},
"bulkDownloadPlaylistsButton": "Download {count} {count, plural, =1{playlist} other{playlists}}",
"@bulkDownloadPlaylistsButton": {
"description": "Button label for bulk downloading selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"bulkDownloadSelectPlaylists": "Select playlists to download",
"@bulkDownloadSelectPlaylists": {
"description": "Button label when no playlists are selected for download"
},
"snackbarSelectedPlaylistsEmpty": "Selected playlists have no tracks",
"@snackbarSelectedPlaylistsEmpty": {
"description": "Snackbar when selected playlists contain no tracks"
},
"playlistsCount": "{count, plural, =1{1 playlist} other{{count} playlists}}",
"@playlistsCount": {
"description": "Playlist count display",
"placeholders": {
"count": {
"type": "int"
}
}
},
"editMetadataAutoFill": "Auto-fill from online",
"@editMetadataAutoFill": {
"description": "Section title for selective online metadata auto-fill in the edit metadata sheet"
},
"editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata",
"@editMetadataAutoFillDesc": {
"description": "Description for the auto-fill section"
},
"editMetadataAutoFillFetch": "Fetch & Fill",
"@editMetadataAutoFillFetch": {
"description": "Button label to fetch online metadata and fill selected fields"
},
"editMetadataAutoFillSearching": "Searching online...",
"@editMetadataAutoFillSearching": {
"description": "Snackbar shown while searching for online metadata"
},
"editMetadataAutoFillNoResults": "No matching metadata found online",
"@editMetadataAutoFillNoResults": {
"description": "Snackbar when online metadata search returns no results"
},
"editMetadataAutoFillDone": "Filled {count} {count, plural, =1{field} other{fields}} from online metadata",
"@editMetadataAutoFillDone": {
"description": "Snackbar confirming how many fields were auto-filled",
"placeholders": {
"count": {
"type": "int"
}
}
},
"editMetadataAutoFillNoneSelected": "Select at least one field to auto-fill",
"@editMetadataAutoFillNoneSelected": {
"description": "Snackbar when user taps Fetch without selecting any fields"
},
"editMetadataFieldTitle": "Title",
"@editMetadataFieldTitle": {
"description": "Chip label for title field in auto-fill selector"
},
"editMetadataFieldArtist": "Artist",
"@editMetadataFieldArtist": {
"description": "Chip label for artist field in auto-fill selector"
},
"editMetadataFieldAlbum": "Album",
"@editMetadataFieldAlbum": {
"description": "Chip label for album field in auto-fill selector"
},
"editMetadataFieldAlbumArtist": "Album Artist",
"@editMetadataFieldAlbumArtist": {
"description": "Chip label for album artist field in auto-fill selector"
},
"editMetadataFieldDate": "Date",
"@editMetadataFieldDate": {
"description": "Chip label for date field in auto-fill selector"
},
"editMetadataFieldTrackNum": "Track #",
"@editMetadataFieldTrackNum": {
"description": "Chip label for track number field in auto-fill selector"
},
"editMetadataFieldDiscNum": "Disc #",
"@editMetadataFieldDiscNum": {
"description": "Chip label for disc number field in auto-fill selector"
},
"editMetadataFieldGenre": "Genre",
"@editMetadataFieldGenre": {
"description": "Chip label for genre field in auto-fill selector"
},
"editMetadataFieldIsrc": "ISRC",
"@editMetadataFieldIsrc": {
"description": "Chip label for ISRC field in auto-fill selector"
},
"editMetadataFieldLabel": "Label",
"@editMetadataFieldLabel": {
"description": "Chip label for label field in auto-fill selector"
},
"editMetadataFieldCopyright": "Copyright",
"@editMetadataFieldCopyright": {
"description": "Chip label for copyright field in auto-fill selector"
},
"editMetadataFieldCover": "Cover Art",
"@editMetadataFieldCover": {
"description": "Chip label for cover art field in auto-fill selector"
},
"editMetadataSelectAll": "All",
"@editMetadataSelectAll": {
"description": "Button to select all fields for auto-fill"
},
"editMetadataSelectEmpty": "Empty only",
"@editMetadataSelectEmpty": {
"description": "Button to select only fields that are currently empty"
}
}
+59 -2
View File
@@ -2755,6 +2755,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"queueFlacAction": "Antrekan FLAC",
"@queueFlacAction": {
"description": "Action/button label for queueing FLAC redownloads for local tracks"
},
"queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
"@queueFlacConfirmMessage": {
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
"@queueFlacFindingProgress": {
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
"@queueFlacNoReliableMatches": {
"description": "Snackbar when no safe FLAC redownload matches were found"
},
"queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
"@queueFlacQueuedWithSkipped": {
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
"placeholders": {
"addedCount": {
"type": "int"
},
"skippedCount": {
"type": "int"
}
}
},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2768,7 +2809,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2803,6 +2844,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3114,4 +3171,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+71 -2
View File
@@ -4,6 +4,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/app.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -13,6 +14,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -90,16 +92,19 @@ class _EagerInitialization extends ConsumerStatefulWidget {
_EagerInitializationState();
}
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
class _EagerInitializationState extends ConsumerState<_EagerInitialization>
with WidgetsBindingObserver {
ProviderSubscription<bool>? _localLibraryEnabledSub;
Timer? _downloadHistoryWarmupTimer;
Timer? _libraryCollectionsWarmupTimer;
Timer? _localLibraryWarmupTimer;
bool _localLibraryWarmupScheduled = false;
bool _autoScanTriggeredOnLaunch = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_initializeAppServices();
@@ -110,6 +115,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_localLibraryEnabledSub?.close();
_downloadHistoryWarmupTimer?.cancel();
_libraryCollectionsWarmupTimer?.cancel();
@@ -117,6 +123,13 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_maybeAutoScanLocalLibrary();
}
}
void _initializeDeferredProviders() {
_downloadHistoryWarmupTimer = _scheduleProviderWarmup(
const Duration(milliseconds: 400),
@@ -155,7 +168,63 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
_localLibraryWarmupScheduled = true;
_localLibraryWarmupTimer = _scheduleProviderWarmup(
const Duration(milliseconds: 1600),
() => ref.read(localLibraryProvider),
() {
ref.read(localLibraryProvider);
// Trigger auto-scan after initial warmup on first app launch.
if (!_autoScanTriggeredOnLaunch) {
_autoScanTriggeredOnLaunch = true;
// Give the provider a moment to load existing data before scanning.
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) _maybeAutoScanLocalLibrary();
});
}
},
);
}
/// Checks whether an automatic incremental scan should be triggered based on
/// the user's auto-scan preference and the time since the last scan.
Future<void> _maybeAutoScanLocalLibrary() async {
if (!mounted) return;
final settings = ref.read(settingsProvider);
if (!settings.localLibraryEnabled) return;
if (settings.localLibraryPath.isEmpty) return;
if (settings.localLibraryAutoScan == 'off') return;
// Don't start a scan if one is already running.
final libraryState = ref.read(localLibraryProvider);
if (libraryState.isScanning) return;
// Determine cooldown based on auto-scan mode.
final now = DateTime.now();
final prefs = await SharedPreferences.getInstance();
final lastScanned = readLocalLibraryLastScannedAt(prefs);
if (lastScanned != null) {
final elapsed = now.difference(lastScanned);
switch (settings.localLibraryAutoScan) {
case 'on_open':
// Cooldown of 10 minutes to prevent rapid re-scans.
if (elapsed.inMinutes < 10) return;
break;
case 'daily':
if (elapsed.inHours < 24) return;
break;
case 'weekly':
if (elapsed.inDays < 7) return;
break;
default:
return;
}
}
// All checks passed -- start an incremental scan.
final iosBookmark = settings.localLibraryBookmark;
ref.read(localLibraryProvider.notifier).startScan(
settings.localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
}
+7 -6
View File
@@ -38,10 +38,8 @@ class AppSettings {
final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final int
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
final int
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
final bool
@@ -61,6 +59,8 @@ class AppSettings {
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
final String
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
@@ -114,7 +114,6 @@ class AppSettings {
this.showExtensionStore = true,
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.youtubeOpusBitrate = 256,
this.youtubeMp3Bitrate = 320,
this.useAllFilesAccess = false,
@@ -126,6 +125,7 @@ class AppSettings {
this.localLibraryPath = '',
this.localLibraryBookmark = '',
this.localLibraryShowDuplicates = true,
this.localLibraryAutoScan = 'off',
this.hasCompletedTutorial = false,
this.lyricsProviders = const [
'lrclib',
@@ -178,7 +178,6 @@ class AppSettings {
bool? showExtensionStore,
String? locale,
String? lyricsMode,
String? tidalHighFormat,
int? youtubeOpusBitrate,
int? youtubeMp3Bitrate,
bool? useAllFilesAccess,
@@ -190,6 +189,7 @@ class AppSettings {
String? localLibraryPath,
String? localLibraryBookmark,
bool? localLibraryShowDuplicates,
String? localLibraryAutoScan,
bool? hasCompletedTutorial,
List<String>? lyricsProviders,
bool? lyricsIncludeTranslationNetease,
@@ -241,7 +241,6 @@ class AppSettings {
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
@@ -256,6 +255,8 @@ class AppSettings {
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
localLibraryAutoScan:
localLibraryAutoScan ?? this.localLibraryAutoScan,
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
lyricsIncludeTranslationNetease:
+2 -2
View File
@@ -44,7 +44,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
@@ -58,6 +57,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off',
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
lyricsProviders:
(json['lyricsProviders'] as List<dynamic>?)
@@ -119,7 +119,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
'useAllFilesAccess': instance.useAllFilesAccess,
@@ -131,6 +130,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'localLibraryPath': instance.localLibraryPath,
'localLibraryBookmark': instance.localLibraryBookmark,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
'localLibraryAutoScan': instance.localLibraryAutoScan,
'hasCompletedTutorial': instance.hasCompletedTutorial,
'lyricsProviders': instance.lyricsProviders,
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
+354 -301
View File
@@ -770,6 +770,37 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
/// Remove history entries where the file no longer exists on disk
/// Returns the number of orphaned entries removed
/// Audio file extensions that the app commonly produces or converts between.
static const _audioExtensions = [
'.flac',
'.m4a',
'.mp3',
'.opus',
'.ogg',
'.wav',
'.aac',
];
/// When the original file is missing, check whether a sibling with a
/// different audio extension exists (e.g. the user converted .flac .opus).
/// Returns the path of the first match found, or `null` if none exist.
Future<String?> _findConvertedSibling(String originalPath) async {
// Strip the current extension to get the base path.
final dotIndex = originalPath.lastIndexOf('.');
if (dotIndex < 0) return null;
final basePath = originalPath.substring(0, dotIndex);
final originalExt = originalPath.substring(dotIndex).toLowerCase();
for (final ext in _audioExtensions) {
if (ext == originalExt) continue;
final candidatePath = '$basePath$ext';
try {
if (await fileExists(candidatePath)) return candidatePath;
} catch (_) {}
}
return null;
}
Future<int> cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...');
@@ -791,7 +822,21 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (filePath == null || filePath.isEmpty) return null;
pathById[id] = filePath;
try {
return MapEntry(id, await fileExists(filePath));
if (await fileExists(filePath)) return MapEntry(id, true);
// Original file missing -- check for a converted sibling.
final sibling = await _findConvertedSibling(filePath);
if (sibling != null) {
_historyLog.i(
'Found converted sibling for $id: $filePath$sibling',
);
// Update the stored path so future checks succeed immediately.
await _db.updateFilePath(id, sibling);
pathById[id] = sibling;
return MapEntry(id, true);
}
return MapEntry(id, false);
} catch (e) {
_historyLog.w('Error checking file existence for $id: $e');
return MapEntry(id, false);
@@ -960,6 +1005,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _lastNotifPercent = -1;
int _lastNotifQueueCount = -1;
final Set<String> _locallyCancelledItemIds = {};
final Set<String> _pausePendingItemIds = {};
double _normalizeProgressForUi(double value) {
final clamped = value.clamp(0.0, 1.0).toDouble();
@@ -1279,6 +1325,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (localItem == null) {
continue;
}
if (_isPausePending(itemId)) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.skipped) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
@@ -1840,7 +1890,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return '.opus';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a';
return '.flac'; // HIGH quality no longer available; fallback to FLAC
}
return '.flac';
}
@@ -2078,12 +2128,42 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return resolved?.status == DownloadStatus.skipped;
}
bool _isPausePending(String id) => _pausePendingItemIds.contains(id);
void _requeueItemForPause(String id) {
final updatedItems = state.items
.map((item) {
if (item.id != id) return item;
if (item.status == DownloadStatus.completed ||
item.status == DownloadStatus.failed ||
item.status == DownloadStatus.skipped) {
return item;
}
return item.copyWith(
status: DownloadStatus.queued,
progress: 0,
speedMBps: 0,
bytesReceived: 0,
);
})
.toList(growable: false);
final currentDownload = state.currentDownload?.id == id
? null
: state.currentDownload;
state = state.copyWith(
items: updatedItems,
currentDownload: currentDownload,
);
}
void _requestNativeCancel(String id) {
PlatformBridge.cancelDownload(id).catchError((_) {});
PlatformBridge.clearItemProgress(id).catchError((_) {});
}
void cancelItem(String id) {
_pausePendingItemIds.remove(id);
_locallyCancelledItemIds.add(id);
updateItemStatus(id, DownloadStatus.skipped);
_requestNativeCancel(id);
@@ -2116,6 +2196,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.toList(growable: false);
if (activeIds.isNotEmpty) {
_pausePendingItemIds.addAll(activeIds);
_locallyCancelledItemIds.addAll(activeIds);
for (final id in activeIds) {
_requestNativeCancel(id);
@@ -2128,11 +2209,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (!wasProcessing) {
_locallyCancelledItemIds.clear();
}
_pausePendingItemIds.clear();
}
void pauseQueue() {
if (state.isProcessing && !state.isPaused) {
state = state.copyWith(isPaused: true);
final activeIds = state.items
.where(
(item) =>
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing,
)
.map((item) => item.id)
.toSet();
if (activeIds.isNotEmpty) {
_pausePendingItemIds.addAll(activeIds);
for (final id in activeIds) {
_requestNativeCancel(id);
_requeueItemForPause(id);
}
}
state = state.copyWith(isPaused: true, currentDownload: null);
_notificationService.cancelDownloadNotification();
_log.i('Queue paused');
}
@@ -2334,7 +2433,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
/// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
String _upgradeToMaxQualityCover(String coverUrl) {
// Spotify CDN upgrade (hash-based size identifiers)
const spotifySize300 = 'ab67616d00001e02';
const spotifySize640 = 'ab67616d0000b273';
const spotifySizeMax = 'ab67616d000082c1';
@@ -2343,11 +2446,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (result.contains(spotifySize300)) {
result = result.replaceFirst(spotifySize300, spotifySize640);
}
if (result.contains(spotifySize640)) {
result = result.replaceFirst(spotifySize640, spotifySizeMax);
}
// Deezer CDN upgrade (1000x1000 1800x1800)
if (result.contains('cdn-images.dzcdn.net')) {
final upgraded = result.replaceFirst(
_deezerSizeRegex,
'/1800x1800-000000-80-0-0.jpg',
);
if (upgraded != result) {
_log.d('Cover URL upgraded (Deezer): 1800x1800');
result = upgraded;
}
}
// Tidal CDN upgrade (1280x1280 origin)
if (result.contains('resources.tidal.com') &&
result.contains('/1280x1280.jpg')) {
result = result.replaceFirst('/1280x1280.jpg', '/origin.jpg');
_log.d('Cover URL upgraded (Tidal): origin');
}
return result;
}
@@ -2383,7 +2504,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
backendResult['album_artist'] as String?,
);
final hasOverrides = backendTrackNum != null ||
final hasOverrides =
backendTrackNum != null ||
backendDiscNum != null ||
backendYear != null ||
backendAlbum != null ||
@@ -3126,6 +3248,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
await _processQueueParallel();
final stoppedWhilePaused = state.isPaused;
_stopProgressPolling();
@@ -3151,7 +3274,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i(
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
);
if (_totalQueuedAtStart > 0) {
if (!stoppedWhilePaused && _totalQueuedAtStart > 0) {
await _notificationService.showQueueComplete(
completedCount: _completedInSession,
failedCount: _failedInSession,
@@ -3166,13 +3289,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
_log.i('Queue processing finished');
if (stoppedWhilePaused) {
_log.i('Queue processing paused');
} else {
_log.i('Queue processing finished');
}
state = state.copyWith(isProcessing: false, currentDownload: null);
final hasQueuedItems = state.items.any(
(item) => item.status == DownloadStatus.queued,
);
if (hasQueuedItems) {
if (hasQueuedItems && !state.isPaused) {
_log.i(
'Found queued items after processing finished, restarting queue...',
);
@@ -3188,8 +3315,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
while (true) {
if (state.isPaused) {
if (activeDownloads.isEmpty) {
_log.d('Queue is paused and no active downloads remain');
break;
}
_log.d('Queue is paused, waiting for active downloads...');
await Future.delayed(_queueSchedulingInterval);
await Future.any([
Future.wait(activeDownloads.values),
Future.delayed(_queueSchedulingInterval),
]);
continue;
}
@@ -3200,7 +3334,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
final queuedItems = state.items
.where((item) => item.status == DownloadStatus.queued)
.where(
(item) =>
item.status == DownloadStatus.queued &&
!_pausePendingItemIds.contains(item.id),
)
.toList();
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
@@ -3245,11 +3383,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_stopProgressPolling();
final remainingIds = state.items.map((item) => item.id).toSet();
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
_pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id));
}
Future<void> _downloadSingleItem(DownloadItem item) async {
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}');
var pausedDuringThisRun = false;
final currentItem = _findItemById(item.id) ?? item;
if (_isLocallyCancelled(item.id, item: currentItem)) {
@@ -3257,11 +3397,33 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
if (_isPausePending(item.id)) {
pausedDuringThisRun = true;
_requeueItemForPause(item.id);
_log.i('Download is pause-pending before start, skipping');
return;
}
state = state.copyWith(currentDownload: item);
updateItemStatus(item.id, DownloadStatus.downloading);
try {
bool shouldAbortWork(String stage) {
final current = _findItemById(item.id);
if (_isLocallyCancelled(item.id, item: current)) {
_log.i('Download was cancelled $stage, skipping');
return true;
}
if (_isPausePending(item.id)) {
pausedDuringThisRun = true;
_requeueItemForPause(item.id);
_log.i('Download pause requested $stage, re-queueing');
return true;
}
return false;
}
final settings = ref.read(settingsProvider);
final metadataEmbeddingEnabled = settings.embedMetadata;
@@ -3342,6 +3504,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.w('Failed to enrich metadata: $e');
_log.w('Stack trace: $stack');
}
if (shouldAbortWork('during metadata enrichment')) {
return;
}
}
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
@@ -3428,6 +3594,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? genre;
String? label;
String? copyright;
final extensionState = ref.read(extensionProvider);
final selectedExtensionDownloadProvider =
settings.useExtensionProviders &&
extensionState.extensions.any(
(e) =>
e.enabled &&
e.hasDownloadProvider &&
e.id.toLowerCase() == item.service.toLowerCase(),
);
String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
@@ -3455,10 +3630,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (e) {
_log.w('Failed to search Deezer by ISRC: $e');
}
if (shouldAbortWork('during Deezer ISRC lookup')) {
return;
}
}
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
if (deezerTrackId == null &&
if (!selectedExtensionDownloadProvider &&
deezerTrackId == null &&
trackToDownload.id.isNotEmpty &&
!trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) {
@@ -3555,6 +3735,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (e) {
_log.w('Failed to convert Spotify to Deezer via SongLink: $e');
}
if (shouldAbortWork('during SongLink availability lookup')) {
return;
}
} else if (selectedExtensionDownloadProvider && deezerTrackId == null) {
_log.d(
'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}',
);
}
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
@@ -3574,11 +3762,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (e) {
_log.w('Failed to fetch extended metadata from Deezer: $e');
}
if (shouldAbortWork('during extended metadata lookup')) {
return;
}
}
Map<String, dynamic> result;
final extensionState = ref.read(extensionProvider);
final hasActiveExtensions = extensionState.extensions.any(
(e) => e.enabled,
);
@@ -3612,6 +3803,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
}
if (!useSaf) {
await _ensureDirExists(outputDir, label: 'Output folder');
}
_log.d('Output dir: $outputDir');
final normalizedTrackNumber =
@@ -3687,8 +3883,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
if (_isLocallyCancelled(item.id)) {
_log.i('Download was cancelled before native download start, skipping');
if (shouldAbortWork('before native download start')) {
return;
}
@@ -3730,10 +3925,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Result: $result');
final itemAfterResult = _findItemById(item.id);
final cancelledAfterResult =
itemAfterResult == null ||
_isLocallyCancelled(item.id, item: itemAfterResult);
if (cancelledAfterResult) {
if (itemAfterResult == null ||
_isLocallyCancelled(item.id, item: itemAfterResult)) {
_log.i('Download was cancelled, skipping result processing');
final filePath = result['file_path'] as String?;
if (filePath != null && result['success'] == true) {
@@ -3743,6 +3936,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
if (_isPausePending(item.id)) {
pausedDuringThisRun = true;
final filePath = result['file_path'] as String?;
if (filePath != null && result['success'] == true) {
await deleteFile(filePath);
_log.d('Deleted paused download file: $filePath');
}
_requeueItemForPause(item.id);
_log.i('Download pause requested after result, re-queueing');
return;
}
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
final reportedFileName = result['file_name'] as String?;
@@ -3903,7 +4108,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isContentUriPath &&
effectiveSafMode &&
actualService == 'tidal' &&
quality != 'HIGH' &&
filePath.endsWith('.flac') &&
(mimeType == null || mimeType.contains('flac'));
@@ -3918,73 +4122,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final currentFilePath = filePath;
if (isContentUriPath && effectiveSafMode) {
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...',
);
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
String? convertedPath;
try {
_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.downloading,
progress: 0.95,
);
final format = tidalHighFormat.startsWith('opus')
? 'opus'
: 'mp3';
convertedPath = await FFmpegService.convertM4aToLossy(
tempPath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: false,
);
if (convertedPath != null) {
_log.i(
'Successfully converted M4A to $format (temp): $convertedPath',
);
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
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?;
if (format == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
await _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
writeExternalLrc: false,
);
final newExt = format == 'opus' ? '.opus' : '.mp3';
final newFileName = '${safBaseName ?? 'track'}$newExt';
final newFileName = '${safBaseName ?? 'track'}.flac';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt(newExt),
srcPath: convertedPath,
mimeType: _mimeTypeForExt('.flac'),
srcPath: flacPath,
);
if (newUri != null) {
@@ -3993,58 +4174,60 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
filePath = newUri;
finalSafFileName = newFileName;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
} else {
_log.w(
'Failed to write converted $format to SAF, keeping M4A',
);
actualQuality = 'AAC 320kbps';
_log.w('Failed to write FLAC to SAF, keeping M4A');
}
} else {
_log.w(
'M4A to $format conversion failed, keeping M4A file',
);
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('SAF M4A conversion failed: $e');
actualQuality = 'AAC 320kbps';
} finally {
// Clean up temp files
try {
await File(tempPath).delete();
} catch (_) {}
if (convertedPath != null) {
try {
await File(convertedPath).delete();
} catch (_) {}
_log.w('FFmpeg conversion returned null, keeping M4A file');
}
}
}
} else {
_log.d('M4A file detected (SAF), converting to FLAC...');
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
String? flacPath;
} catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e');
} finally {
// Clean up temp files
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.downloading,
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...',
);
await File(tempPath).delete();
} catch (_) {}
if (flacPath != null) {
try {
await File(flacPath).delete();
} catch (_) {}
}
}
}
} 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.downloading,
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,
@@ -4055,201 +4238,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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 _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
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',
);
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
}
}
} catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e');
} finally {
// Clean up temp files
try {
await File(tempPath).delete();
} catch (_) {}
if (flacPath != null) {
try {
await File(flacPath).delete();
} catch (_) {}
}
}
}
}
} else {
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Tidal HIGH quality download, converting M4A to $tidalHighFormat...',
);
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final format = tidalHighFormat.startsWith('opus')
? 'opus'
: 'mp3';
final convertedPath = await FFmpegService.convertM4aToLossy(
currentFilePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
_log.i(
'Successfully converted M4A to $format: $convertedPath',
);
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (format == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('Metadata embedded successfully');
} else {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} 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.downloading,
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 _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
_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',
);
}
_log.w('FFmpeg conversion returned null, keeping M4A file');
}
}
} catch (e) {
_log.w(
'FFmpeg conversion process failed: $e, keeping M4A file',
);
}
} catch (e) {
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
}
}
} else if (metadataEmbeddingEnabled &&
@@ -4467,6 +4481,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
if (_isPausePending(item.id)) {
pausedDuringThisRun = true;
if (filePath != null) {
await deleteFile(filePath);
_log.d(
'Deleted paused download file during finalization: $filePath',
);
}
_requeueItemForPause(item.id);
_log.i('Download pause requested during finalization, re-queueing');
return;
}
// SAF downloads should end with content URI. If we still have a
// transient FD path, recover URI from SAF metadata to keep history
// dedup/exclusion stable.
@@ -4734,11 +4761,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
if (_isPausePending(item.id)) {
pausedDuringThisRun = true;
_requeueItemForPause(item.id);
_log.i('Download pause requested after backend failure, re-queueing');
return;
}
final errorMsg = result['error'] as String? ?? 'Download failed';
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
if (errorTypeStr == 'cancelled') {
_log.i('Download was cancelled by backend, skipping error handling');
updateItemStatus(item.id, DownloadStatus.skipped);
if (_isPausePending(item.id)) {
pausedDuringThisRun = true;
_requeueItemForPause(item.id);
_log.i('Download was paused by backend cancellation, re-queueing');
} else {
_log.i(
'Download was cancelled by backend, skipping error handling',
);
updateItemStatus(item.id, DownloadStatus.skipped);
}
return;
}
@@ -4797,6 +4839,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
if (_isPausePending(item.id)) {
pausedDuringThisRun = true;
_requeueItemForPause(item.id);
_log.i('Download pause requested after exception, re-queueing');
return;
}
_log.e('Exception: $e', e, stackTrace);
String errorMsg = e.toString();
@@ -4822,6 +4871,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (cleanupErr) {
_log.e('Post-exception connection cleanup failed: $cleanupErr');
}
} finally {
if (pausedDuringThisRun) {
_pausePendingItemIds.remove(item.id);
}
}
}
}
+25 -14
View File
@@ -9,11 +9,11 @@ import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
final _log = AppLogger('LocalLibrary');
const _lastScannedAtKey = 'local_library_last_scanned_at';
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
final _prefs = SharedPreferences.getInstance();
@@ -165,10 +165,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
var excludedDownloadedCount = 0;
try {
final prefs = await prefsFuture;
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
}
lastScannedAt = readLocalLibraryLastScannedAt(prefs);
excludedDownloadedCount =
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
} catch (e) {
@@ -332,11 +329,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (items.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
}
final persistedItems =
(await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await writeLocalLibraryLastScannedAt(prefs, now);
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
@@ -344,7 +346,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
state = state.copyWith(
items: items,
items: persistedItems,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
@@ -353,11 +355,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
_log.i(
'Full scan complete: ${items.length} tracks found, '
'Full scan complete: ${persistedItems.length} tracks found, '
'$skippedDownloads already in downloads',
);
await _showScanCompleteNotification(
totalTracks: items.length,
totalTracks: persistedItems.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
@@ -442,8 +444,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
);
// Build the incremental merge base from SQLite, not the current
// provider state. Startup auto-scan can fire before `state.items` has
// finished loading, which would otherwise drop unchanged rows from the
// in-memory library until a manual full rescan.
final existingJson = await _db.getAll();
final currentByPath = <String, LocalLibraryItem>{
for (final item in state.items) item.filePath: item,
for (final item in existingJson.map(LocalLibraryItem.fromJson))
item.filePath: item,
};
final existingDownloadedPaths = <String>[];
currentByPath.removeWhere((path, _) {
@@ -494,13 +502,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Deleted $deleteCount items from database');
}
final items = currentByPath.values.toList(growable: false)
..sort(_compareLibraryItems);
final items =
(await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await writeLocalLibraryLastScannedAt(prefs, now);
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
@@ -818,7 +829,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey);
await clearLocalLibraryLastScannedAt(prefs);
await prefs.remove(_excludedDownloadedCountKey);
} catch (e) {
_log.w('Failed to clear lastScannedAt: $e');
+29 -9
View File
@@ -1,20 +1,22 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 5;
const _currentMigrationVersion = 6;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
@@ -37,6 +39,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeYouTubeBitratesIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
@@ -114,6 +117,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
useCustomSpotifyCredentials: false,
);
}
// Migration 6: Tidal HIGH quality removed migrate to LOSSLESS
if (state.audioQuality == 'HIGH') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
@@ -189,6 +196,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
if (!Platform.isIOS) return;
final currentDir = state.downloadDirectory.trim();
if (currentDir.isEmpty) return;
final normalizedDir = await validateOrFixIosPath(currentDir);
if (normalizedDir == currentDir) return;
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
state = state.copyWith(downloadDirectory: normalizedDir);
await _saveSettings();
}
String _normalizeSongLinkRegion(String region) {
final normalized = region.trim().toUpperCase();
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
@@ -385,8 +406,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setMetadataSource(String source) {
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
state = state.copyWith(metadataSource: normalized);
state = state.copyWith(metadataSource: source);
_saveSettings();
}
@@ -430,11 +450,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setTidalHighFormat(String format) {
state = state.copyWith(tidalHighFormat: format);
_saveSettings();
}
void setYoutubeOpusBitrate(int bitrate) {
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
state = state.copyWith(youtubeOpusBitrate: normalized);
@@ -502,6 +517,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setLocalLibraryAutoScan(String mode) {
state = state.copyWith(localLibraryAutoScan: mode);
_saveSettings();
}
void setTutorialComplete() {
state = state.copyWith(hasCompletedTutorial: true);
_saveSettings();
+9 -5
View File
@@ -958,9 +958,16 @@ class TrackNotifier extends Notifier<TrackState> {
final durationMs = _extractDurationMs(data);
final itemType = data['item_type']?.toString();
final effectiveSource =
source ?? data['source']?.toString() ?? data['provider_id']?.toString();
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
final preferredId = effectiveSource != null && effectiveSource.isNotEmpty
? (nativeId.isNotEmpty ? nativeId : spotifyId)
: (spotifyId.isNotEmpty ? spotifyId : nativeId);
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
id: preferredId,
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
@@ -974,10 +981,7 @@ class TrackNotifier extends Notifier<TrackState> {
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source:
source ??
data['source']?.toString() ??
data['provider_id']?.toString(),
source: effectiveSource,
albumType: data['album_type']?.toString(),
itemType: itemType,
);
+58 -22
View File
@@ -81,7 +81,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Use extensionId if available, otherwise detect from albumId prefix
final providerId =
widget.extensionId ??
(() {
@@ -95,7 +94,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
.recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
artistName: widget.artistName ?? widget.tracks?.firstOrNull?.albumArtist ?? widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
@@ -134,9 +133,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
/// Spotify CDN only has 300, 640, ~2000 we stay at 640 (no intermediate).
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
/// Upgrade cover URL to a higher resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 640 only (no intermediate between 640 and 2000)
@@ -283,7 +280,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
) {
final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final artistName = widget.artistName ??
(tracks.isNotEmpty
? (tracks.first.albumArtist ?? tracks.first.artistName)
: null);
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverAppBar(
@@ -516,7 +516,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -571,37 +570,74 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
void _downloadAll(BuildContext context) {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in tracks) {
final isInHistory = historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
final isInLocal = localLibState?.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
) ??
false;
if (isInHistory || isInLocal) {
skippedCount++;
} else {
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.discographySkippedDownloaded(0, skippedCount),
),
),
);
return;
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
trackName: '${tracksToQueue.length} tracks',
artistName: widget.albumName,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
.addMultipleToQueue(tracksToQueue, service, qualityOverride: quality);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
},
);
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
.addMultipleToQueue(tracksToQueue, settings.defaultService);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}
}
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
Widget _buildLoveAllButton() {
final collectionsState = ref.watch(libraryCollectionsProvider);
final tracks = _tracks;
+139 -51
View File
@@ -910,8 +910,44 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
BuildContext context,
List<DownloadHistoryItem> allTracks,
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate =
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
context: context,
@@ -923,7 +959,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -960,51 +995,75 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -1057,12 +1116,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null && ext != targetFormat) selected.add(item);
if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1074,16 +1140,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1103,8 +1175,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
int successCount = 0;
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -1207,13 +1281,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
+1
View File
@@ -3909,6 +3909,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? widget.albumName).toString(),
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
+72 -6
View File
@@ -39,6 +39,7 @@ class _LibraryTracksFolderScreenState
bool _isSelectionMode = false;
final Set<String> _selectedKeys = {};
UserPlaylistCollection? playlist;
@override
void initState() {
@@ -243,7 +244,6 @@ class _LibraryTracksFolderScreenState
final colorScheme = Theme.of(context).colorScheme;
ref.watch(localLibraryProvider.select((s) => s.items));
final localState = ref.read(localLibraryProvider);
final UserPlaylistCollection? playlist;
final List<CollectionTrackEntry> entries;
switch (widget.mode) {
@@ -872,11 +872,54 @@ class _LibraryTracksFolderScreenState
void _downloadAll(List<Track> tracks) {
if (tracks.isEmpty) return;
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final playlistName = widget.mode == LibraryTracksFolderMode.playlist
? playlist?.name ?? context.l10n.collectionPlaylist
: null;
final tracksToQueue = <Track>[];
var skippedCount = 0;
for (final track in tracks) {
final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
null;
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
) ??
false;
if (isInHistory || isInLocal) {
skippedCount++;
} else {
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.discographySkippedDownloaded(0, skippedCount),
),
),
);
return;
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
trackName: '${tracksToQueue.length} tracks',
artistName: switch (widget.mode) {
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
@@ -885,12 +928,24 @@ class _LibraryTracksFolderScreenState
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
.addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
playlistName: playlistName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
skippedCount > 0
? context.l10n.discographySkippedDownloaded(
tracksToQueue.length,
skippedCount,
)
: context.l10n.snackbarAddedTracksToQueue(
tracksToQueue.length,
),
),
),
);
@@ -899,10 +954,21 @@ class _LibraryTracksFolderScreenState
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
.addMultipleToQueue(
tracksToQueue,
settings.defaultService,
playlistName: playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
content: Text(
skippedCount > 0
? context.l10n.discographySkippedDownloaded(
tracksToQueue.length,
skippedCount,
)
: context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length),
),
),
);
}
+297 -59
View File
@@ -4,11 +4,15 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -41,11 +45,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(cueVirtualTrackRequiresSplitMessage),
),
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@@ -623,11 +626,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final track = discTracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
}, childCount: discTracks.length),
),
);
}
@@ -897,6 +902,128 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return false;
}
List<LocalLibraryItem> _selectedFlacEligibleItems(
List<LocalLibraryItem> allTracks,
) {
final tracksById = {for (final t in allTracks) t.id: t};
return _selectedIds
.map((id) => tracksById[id])
.whereType<LocalLibraryItem>()
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
.toList(growable: false);
}
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
final selected = _selectedFlacEligibleItems(allTracks);
if (selected.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.queueFlacAction),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final includeExtensions =
settings.useExtensionProviders &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.hasMetadataProvider,
);
final targetService = LocalTrackRedownloadService.preferredFlacService(
settings,
);
final targetQuality =
LocalTrackRedownloadService.preferredFlacQualityForService(
targetService,
);
final matchedTracks = <Track>[];
var skippedCount = 0;
final total = selected.length;
for (var i = 0; i < total; i++) {
if (!mounted) break;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
duration: const Duration(seconds: 30),
),
);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selected[i],
includeExtensions: includeExtensions,
);
if (resolution.canQueue && resolution.match != null) {
matchedTracks.add(resolution.match!);
} else {
skippedCount++;
}
} catch (_) {
skippedCount++;
}
}
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).clearSnackBars();
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
matchedTracks,
targetService,
qualityOverride: targetQuality,
);
final summary = skippedCount == 0
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
: context.l10n.queueFlacQueuedWithSkipped(
matchedTracks.length,
skippedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
setState(() {
_selectedIds.clear();
_isSelectionMode = false;
});
}
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@@ -1005,8 +1132,57 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
BuildContext context,
List<LocalLibraryItem> allTracks,
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
String? ext;
if (item.format != null && item.format!.isNotEmpty) {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
ext = 'FLAC';
} else if (fmt == 'm4a') {
ext = 'M4A';
} else if (fmt == 'mp3') {
ext = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
ext = 'Opus';
}
}
if (ext == null) {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
ext = 'FLAC';
} else if (lower.endsWith('.m4a')) {
ext = 'M4A';
} else if (lower.endsWith('.mp3')) {
ext = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
ext = 'Opus';
}
}
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate = isLosslessTarget
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
context: context,
@@ -1018,7 +1194,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -1055,51 +1230,73 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -1152,6 +1349,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
currentFormat = 'FLAC';
} else if (fmt == 'm4a') {
currentFormat = 'M4A';
} else if (fmt == 'mp3') {
currentFormat = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
@@ -1163,15 +1362,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
currentFormat = 'FLAC';
} else if (lower.endsWith('.m4a')) {
currentFormat = 'M4A';
} else if (lower.endsWith('.mp3')) {
currentFormat = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
currentFormat = 'Opus';
}
}
if (currentFormat != null && currentFormat != targetFormat) {
selected.add(item);
}
if (currentFormat == null || currentFormat == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource =
currentFormat == 'FLAC' || currentFormat == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1183,16 +1388,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1357,13 +1568,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
@@ -1434,6 +1659,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
double bottomPadding,
) {
final selectedCount = _selectedIds.length;
final flacEligibleCount = _selectedFlacEligibleItems(tracks).length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container(
@@ -1525,6 +1751,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Row(
children: [
if (flacEligibleCount > 0) ...[
Expanded(
child: _LocalAlbumSelectionActionButton(
icon: Icons.download_for_offline_outlined,
label:
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
onPressed: () => _queueSelectedAsFlac(tracks),
colorScheme: colorScheme,
),
),
const SizedBox(width: 8),
],
Expanded(
child: _LocalAlbumSelectionActionButton(
icon: Icons.auto_fix_high_outlined,
-1
View File
@@ -83,7 +83,6 @@ class _MainShellState extends ConsumerState<MainShell> {
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...');
// Wait up to 5 seconds for extensions to initialize
for (int i = 0; i < 50; i++) {
await Future.delayed(const Duration(milliseconds: 100));
if (!mounted) return;
+53 -17
View File
@@ -350,7 +350,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -533,7 +532,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
tooltip: context.l10n.tooltipAddToPlaylist,
onPressed: _tracks.isEmpty
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
: () => showAddTracksToPlaylistSheet(context, ref, _tracks, playlistNamePrefill: widget.playlistName),
);
}
@@ -608,45 +607,82 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _downloadTracks(BuildContext context, List<Track> tracks) {
if (tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in tracks) {
final isInHistory = historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
final isInLocal = localLibState?.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
) ??
false;
if (isInHistory || isInLocal) {
skippedCount++;
} else {
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.discographySkippedDownloaded(0, skippedCount),
),
),
);
return;
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
trackName: '${tracksToQueue.length} tracks',
artistName: _playlistName,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracks,
tracksToQueue,
service,
qualityOverride: quality,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
},
);
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracks,
tracksToQueue,
settings.defaultService,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}
}
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
+407 -57
View File
@@ -17,11 +17,13 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -31,6 +33,7 @@ import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
enum LibraryItemSource { downloaded, local }
@@ -1314,6 +1317,94 @@ class _QueueTabState extends ConsumerState<QueueTab> {
});
}
Future<void> _downloadAllSelectedPlaylists(BuildContext context) async {
final collectionsState = ref.read(libraryCollectionsProvider);
final selectedPlaylists = collectionsState.playlists
.where((p) => _selectedPlaylistIds.contains(p.id))
.toList();
final totalTracks = selectedPlaylists.fold<int>(
0,
(sum, p) => sum + p.tracks.length,
);
if (totalTracks == 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarSelectedPlaylistsEmpty)),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.dialogDownloadAllTitle),
content: Text(
ctx.l10n.dialogDownloadPlaylistsMessage(
totalTracks,
selectedPlaylists.length,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.dialogDownload),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final settings = ref.read(settingsProvider);
final queueNotifier = ref.read(downloadQueueProvider.notifier);
void enqueueAll({String? qualityOverride, String? service}) {
final svc = service ?? settings.defaultService;
for (final playlist in selectedPlaylists) {
final tracks = playlist.tracks.map((e) => e.track).toList();
queueNotifier.addMultipleToQueue(
tracks,
svc,
qualityOverride: qualityOverride,
playlistName: playlist.name,
);
}
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: context.l10n.tracksCount(totalTracks),
artistName: context.l10n.playlistsCount(selectedPlaylists.length),
onSelect: (quality, service) {
enqueueAll(qualityOverride: quality, service: service);
if (!mounted) return;
_exitPlaylistSelectionMode();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(totalTracks),
),
),
);
},
);
} else {
enqueueAll();
_exitPlaylistSelectionMode();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(totalTracks)),
),
);
}
}
Future<void> _deleteSelectedPlaylists(BuildContext context) async {
final count = _selectedPlaylistIds.length;
final confirmed = await showDialog<bool>(
@@ -1452,6 +1543,37 @@ class _QueueTabState extends ConsumerState<QueueTab> {
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0
? () => _downloadAllSelectedPlaylists(context)
: null,
icon: const Icon(Icons.download_rounded),
label: Text(
selectedCount > 0
? context.l10n.bulkDownloadPlaylistsButton(
selectedCount,
)
: context.l10n.bulkDownloadSelectPlaylists,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0
? colorScheme.primary
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
@@ -4362,6 +4484,132 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return false;
}
List<LocalLibraryItem> _selectedFlacEligibleLocalItems(
List<UnifiedLibraryItem> allItems,
) {
final selectedItems = _selectedItemsFromAll(allItems);
return selectedItems
.map((item) => item.localItem)
.whereType<LocalLibraryItem>()
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
.toList(growable: false);
}
Future<void> _queueSelectedLocalAsFlac(
List<UnifiedLibraryItem> allItems,
) async {
final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems);
if (selectedLocalItems.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(
context.l10n.queueFlacConfirmMessage(selectedLocalItems.length),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.queueFlacAction),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final includeExtensions =
settings.useExtensionProviders &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.hasMetadataProvider,
);
final targetService = LocalTrackRedownloadService.preferredFlacService(
settings,
);
final targetQuality =
LocalTrackRedownloadService.preferredFlacQualityForService(
targetService,
);
final matchedTracks = <Track>[];
var skippedCount = 0;
final total = selectedLocalItems.length;
for (var i = 0; i < total; i++) {
if (!mounted) break;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
duration: const Duration(seconds: 30),
),
);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selectedLocalItems[i],
includeExtensions: includeExtensions,
);
if (resolution.canQueue && resolution.match != null) {
matchedTracks.add(resolution.match!);
} else {
skippedCount++;
}
} catch (_) {
skippedCount++;
}
}
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).clearSnackBars();
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
matchedTracks,
targetService,
qualityOverride: targetQuality,
);
final summary = skippedCount == 0
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
: context.l10n.queueFlacQueuedWithSkipped(
matchedTracks.length,
skippedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
setState(() {
_selectedIds.clear();
_isSelectionMode = false;
});
}
Future<void> _reEnrichSelectedLocalFromQueue(
List<UnifiedLibraryItem> allItems,
) async {
@@ -4512,8 +4760,51 @@ class _QueueTabState extends ConsumerState<QueueTab> {
BuildContext context,
List<UnifiedLibraryItem> allItems,
) async {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final itemsById = {for (final item in allItems) item.id: item};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = itemsById[id];
if (item == null) continue;
String nameToCheck;
if (item.historyItem?.safFileName != null &&
item.historyItem!.safFileName!.isNotEmpty) {
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
} else if (item.localItem?.format != null &&
item.localItem!.format!.isNotEmpty) {
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
} else {
nameToCheck = item.filePath.toLowerCase();
}
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate = isLosslessTarget
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
var didStartConversion = false;
_hideSelectionOverlay();
@@ -4529,7 +4820,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -4566,51 +4856,73 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -4686,14 +4998,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null && ext != targetFormat) {
selectedItems.add(item);
}
if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selectedItems.add(item);
}
if (selectedItems.isEmpty) {
@@ -4706,16 +5023,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
// Confirm
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selectedItems.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selectedItems.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selectedItems.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -4736,7 +5059,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final total = selectedItems.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
(targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -4756,7 +5082,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
try {
// Read metadata from file
final metadata = <String, String>{
'TITLE': item.trackName,
'ARTIST': item.artistName,
@@ -4785,7 +5110,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
1000,
);
// Extract cover art
String? coverPath;
try {
final tempDir = await getTemporaryDirectory();
@@ -4800,7 +5124,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
} catch (_) {}
// Handle SAF vs regular file
String workingPath = item.filePath;
final isSaf = isContentUri(item.filePath);
String? safTempPath;
@@ -4813,7 +5136,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
workingPath = safTempPath;
}
// Convert
final newPath = await FFmpegService.convertAudioFormat(
inputPath: workingPath,
targetFormat: targetFormat.toLowerCase(),
@@ -4823,7 +5145,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
deleteOriginal: !isSaf,
);
// Cleanup cover temp
if (coverPath != null) {
try {
await File(coverPath).delete();
@@ -4850,13 +5171,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
@@ -5040,6 +5375,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final allSelected =
selectedCount == unifiedItems.length && unifiedItems.isNotEmpty;
final localOnlySelection = _isLocalOnlySelection(unifiedItems);
final flacEligibleCount = _selectedFlacEligibleLocalItems(
unifiedItems,
).length;
return Container(
decoration: BoxDecoration(
@@ -5129,6 +5467,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
// Action buttons row: Share/Re-enrich, Convert, Delete
Row(
children: [
if (localOnlySelection && flacEligibleCount > 0) ...[
Expanded(
child: _SelectionActionButton(
icon: Icons.download_for_offline_outlined,
label:
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
onPressed: () => _queueSelectedLocalAsFlac(unifiedItems),
colorScheme: colorScheme,
),
),
const SizedBox(width: 8),
],
Expanded(
child: _SelectionActionButton(
icon: localOnlySelection
+3 -3
View File
@@ -164,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>['a fan'];
const donorNames = <String>['McNuggets Jimmy', 'zcc09', 'micahRichie', 'a fan', 'CJBGR'];
// Match SettingsGroup color logic
final cardColor = isDark
@@ -479,8 +479,8 @@ int _cr(String v) {
return r;
}
// Highlighted supporters (hashes of names): none for now.
const _cv = <int>{};
// Highlighted supporters (hashes of names).
const _cv = <int>{1211573191, 1003219236, 560908930};
class _SupporterChip extends StatelessWidget {
final String name;
+23 -130
View File
@@ -300,7 +300,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
return PopScope(
canPop: true,
@@ -408,35 +407,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'),
showDivider: isTidalService,
showDivider: false,
),
// Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
if (isTidalService)
_QualityOption(
title: context.l10n.downloadLossy320,
subtitle: _getTidalHighFormatLabel(
settings.tidalHighFormat,
),
isSelected: settings.audioQuality == 'HIGH',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HIGH'),
showDivider: false,
),
if (isTidalService && settings.audioQuality == 'HIGH')
SettingsItem(
icon: Icons.tune,
title: context.l10n.downloadLossyFormat,
subtitle: _getTidalHighFormatLabel(
settings.tidalHighFormat,
),
onTap: () => _showTidalHighFormatPicker(
context,
ref,
settings.tidalHighFormat,
),
showDivider: false,
),
],
if (!isBuiltInService) ...[
Padding(
@@ -464,12 +436,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
],
SettingsItem(
title: context.l10n.youtubeOpusBitrateTitle,
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)',
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256/320)',
onTap: () => _showYoutubeBitratePicker(
context: context,
title: context.l10n.youtubeOpusBitrateTitle,
currentValue: settings.youtubeOpusBitrate,
options: const [128, 256],
options: const [128, 256, 320],
onSave: (value) => ref
.read(settingsProvider.notifier)
.setYoutubeOpusBitrate(value),
@@ -1338,8 +1310,27 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
if (Platform.isIOS) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
String? result;
try {
result = await FilePicker.platform.getDirectoryPath();
} catch (e) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text('Failed to open folder picker: $e'),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
if (result != null) {
// iOS: Validate the selected path is writable (not iCloud or container root)
if (Platform.isIOS) {
@@ -1691,104 +1682,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
String _getTidalHighFormatLabel(String format) {
switch (format) {
case 'mp3_320':
return 'MP3 320kbps';
case 'opus_256':
return 'Opus 256kbps';
case 'opus_128':
return 'Opus 128kbps';
default:
return 'MP3 320kbps';
}
}
void _showTidalHighFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadLossy320Format,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.downloadLossy320FormatDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: Text(context.l10n.downloadLossyMp3),
subtitle: Text(context.l10n.downloadLossyMp3Subtitle),
trailing: current == 'mp3_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus256),
subtitle: Text(context.l10n.downloadLossyOpus256Subtitle),
trailing: current == 'opus_256'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_256');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus128),
subtitle: Text(context.l10n.downloadLossyOpus128Subtitle),
trailing: current == 'opus_128'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_128');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showNetworkModePicker(
BuildContext context,
WidgetRef ref,
+133 -1
View File
@@ -241,6 +241,99 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
}
}
String _getAutoScanLabel(BuildContext context, String mode) {
switch (mode) {
case 'on_open':
return context.l10n.libraryAutoScanOnOpen;
case 'daily':
return context.l10n.libraryAutoScanDaily;
case 'weekly':
return context.l10n.libraryAutoScanWeekly;
default:
return context.l10n.libraryAutoScanOff;
}
}
void _showAutoScanPicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.libraryAutoScan,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.libraryAutoScanSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
_AutoScanOption(
icon: Icons.block,
title: context.l10n.libraryAutoScanOff,
selected: current == 'off',
colorScheme: colorScheme,
onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('off');
Navigator.pop(context);
},
),
_AutoScanOption(
icon: Icons.open_in_new,
title: context.l10n.libraryAutoScanOnOpen,
selected: current == 'on_open',
colorScheme: colorScheme,
onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('on_open');
Navigator.pop(context);
},
),
_AutoScanOption(
icon: Icons.today,
title: context.l10n.libraryAutoScanDaily,
selected: current == 'daily',
colorScheme: colorScheme,
onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('daily');
Navigator.pop(context);
},
),
_AutoScanOption(
icon: Icons.date_range,
title: context.l10n.libraryAutoScanWeekly,
selected: current == 'weekly',
colorScheme: colorScheme,
onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('weekly');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
@@ -344,7 +437,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setLocalLibraryShowDuplicates(value),
showDivider: false,
),
Opacity(
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.autorenew_rounded,
title: context.l10n.libraryAutoScan,
subtitle: _getAutoScanLabel(context, settings.localLibraryAutoScan),
onTap: settings.localLibraryEnabled
? () => _showAutoScanPicker(context, settings.localLibraryAutoScan)
: null,
showDivider: false,
),
),
],
),
@@ -825,3 +929,31 @@ class _ScanProgressTile extends StatelessWidget {
);
}
}
class _AutoScanOption extends StatelessWidget {
final IconData icon;
final String title;
final bool selected;
final ColorScheme colorScheme;
final VoidCallback onTap;
const _AutoScanOption({
required this.icon,
required this.title,
required this.selected,
required this.colorScheme,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon),
title: Text(title),
trailing: selected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: onTap,
);
}
}
+20 -1
View File
@@ -321,7 +321,26 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
title: Text(context.l10n.setupChooseFromFiles),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.getDirectoryPath();
if (Platform.isIOS) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
String? result;
try {
result = await FilePicker.platform.getDirectoryPath();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to open folder picker: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
if (result != null) {
// iOS: Validate the selected path is writable
if (Platform.isIOS) {
+13 -16
View File
@@ -269,22 +269,19 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SettingsGroup(
children: filteredExtensions.asMap().entries.map((entry) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
showDivider: index < filteredExtensions.length - 1,
isDownloading: downloadingId == ext.id,
onInstall: () => _installExtension(ext),
onUpdate: () => _updateExtension(ext),
onTap: () => _showExtensionDetails(ext),
);
}).toList(),
),
child: SettingsGroup(
children: filteredExtensions.asMap().entries.map((entry) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
showDivider: index < filteredExtensions.length - 1,
isDownloading: downloadingId == ext.id,
onInstall: () => _installExtension(ext),
onUpdate: () => _updateExtension(ext),
onTap: () => _showExtensionDetails(ext),
);
}).toList(),
),
),
File diff suppressed because it is too large Load Diff
-2
View File
@@ -37,7 +37,6 @@ class CoverCacheManager {
final appDir = await getApplicationSupportDirectory();
_cachePath = p.join(appDir.path, 'cover_cache');
// Ensure cache directory exists
await Directory(_cachePath!).create(recursive: true);
debugPrint('CoverCacheManager: Initializing at $_cachePath');
@@ -48,7 +47,6 @@ class CoverCacheManager {
debugPrint('CoverCacheManager: Initialized successfully');
} catch (e) {
debugPrint('CoverCacheManager: Failed to initialize: $e');
// Will fallback to DefaultCacheManager
}
}
+273 -2
View File
@@ -1209,7 +1209,8 @@ class FFmpegService {
}
/// Unified audio format conversion with full metadata + cover preservation.
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
/// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC.
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
/// Returns the new file path on success, null on failure.
static Future<String?> convertAudioFormat({
required String inputPath,
@@ -1220,11 +1221,30 @@ class FFmpegService {
bool deleteOriginal = true,
}) async {
final format = targetFormat.toLowerCase();
if (format != 'mp3' && format != 'opus') {
if (!const {'mp3', 'opus', 'alac', 'flac'}.contains(format)) {
_log.e('Unsupported target format: $targetFormat');
return null;
}
// Lossless targets: dedicated single-pass methods
if (format == 'alac') {
return _convertToAlac(
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: deleteOriginal,
);
}
if (format == 'flac') {
return _convertToFlac(
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: deleteOriginal,
);
}
// Lossy targets: MP3 / Opus
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
@@ -1296,6 +1316,257 @@ class FFmpegService {
return outputPath;
}
/// Convert any audio format to ALAC (Apple Lossless) in an M4A container.
/// Metadata and cover art are embedded in a single FFmpeg pass.
static Future<String?> _convertToAlac({
required String inputPath,
required Map<String, String> metadata,
String? coverPath,
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.m4a');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" ');
// Cover art as second input for M4A attached picture
final hasCover = coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
}
cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 ');
// Embed M4A metadata tags
final m4aTags = _convertToM4aTags(metadata);
for (final entry in m4aTags.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
cmdBuffer.write('"$outputPath" -y');
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC',
);
final result = await _execute(cmdBuffer.toString());
if (!result.success) {
_log.e('ALAC conversion failed: ${result.output}');
return null;
}
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
/// Convert any audio format to FLAC with metadata and cover art preservation.
static Future<String?> _convertToFlac({
required String inputPath,
required Map<String, String> metadata,
String? coverPath,
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.flac');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" ');
final hasCover = coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a flac -compression_level 8 ');
cmdBuffer.write('-map_metadata 0 ');
final vorbisComments = _normalizeToVorbisComments(metadata);
for (final entry in vorbisComments.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
cmdBuffer.write('"$outputPath" -y');
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC',
);
final result = await _execute(cmdBuffer.toString());
if (!result.success) {
_log.e('FLAC conversion failed: ${result.output}');
return null;
}
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
/// Normalize metadata keys to standard Vorbis comment names, filtering out
/// technical fields (bit_depth, sample_rate, duration, etc.).
static Map<String, String> _normalizeToVorbisComments(
Map<String, String> metadata,
) {
final vorbis = <String, String>{};
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (key) {
case 'TITLE':
vorbis['TITLE'] = value;
break;
case 'ARTIST':
vorbis['ARTIST'] = value;
break;
case 'ALBUM':
vorbis['ALBUM'] = value;
break;
case 'ALBUMARTIST':
vorbis['ALBUMARTIST'] = value;
break;
case 'TRACKNUMBER':
case 'TRACKNBR':
case 'TRACK':
case 'TRCK':
if (value != '0') vorbis['TRACKNUMBER'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
if (value != '0') vorbis['DISCNUMBER'] = value;
break;
case 'DATE':
case 'YEAR':
vorbis['DATE'] = value;
break;
case 'GENRE':
vorbis['GENRE'] = value;
break;
case 'ISRC':
vorbis['ISRC'] = value;
break;
case 'LABEL':
case 'ORGANIZATION':
vorbis['ORGANIZATION'] = value;
break;
case 'COPYRIGHT':
vorbis['COPYRIGHT'] = value;
break;
case 'COMPOSER':
vorbis['COMPOSER'] = value;
break;
case 'COMMENT':
vorbis['COMMENT'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
vorbis['LYRICS'] = value;
vorbis['UNSYNCEDLYRICS'] = value;
break;
}
}
return vorbis;
}
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
static Map<String, String> _convertToM4aTags(
Map<String, String> metadata,
) {
final m4aMap = <String, String>{};
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (key) {
case 'TITLE':
m4aMap['title'] = value;
break;
case 'ARTIST':
m4aMap['artist'] = value;
break;
case 'ALBUM':
m4aMap['album'] = value;
break;
case 'ALBUMARTIST':
m4aMap['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
m4aMap['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
m4aMap['disc'] = value;
break;
case 'DATE':
case 'YEAR':
m4aMap['date'] = value;
break;
case 'GENRE':
m4aMap['genre'] = value;
break;
case 'COMPOSER':
m4aMap['composer'] = value;
break;
case 'COMMENT':
m4aMap['comment'] = value;
break;
case 'COPYRIGHT':
m4aMap['copyright'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
m4aMap['lyrics'] = value;
break;
}
}
return m4aMap;
}
static Map<String, String> _convertToId3Tags(
Map<String, String> vorbisMetadata,
) {
-11
View File
@@ -12,7 +12,6 @@ final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
/// Cached current iOS container path for path normalization
String? _currentContainerPath;
/// SQLite database service for download history
/// Provides O(1) lookups by spotify_id and isrc with proper indexing
class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init();
@@ -78,7 +77,6 @@ class HistoryDatabase {
)
''');
// Indexes for fast lookups
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
await db.execute(
@@ -171,7 +169,6 @@ class HistoryDatabase {
try {
final db = await database;
// Get all items with iOS paths
final rows = await db.query('history', columns: ['id', 'file_path']);
int updatedCount = 0;
final batch = db.batch();
@@ -198,7 +195,6 @@ class HistoryDatabase {
await batch.commit(noResult: true);
}
// Save current container path
await prefs.setString('ios_last_container_path', _currentContainerPath!);
_log.i('iOS path migration complete: $updatedCount paths updated');
@@ -323,7 +319,6 @@ class HistoryDatabase {
};
}
/// Insert or update a history item
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
@@ -345,7 +340,6 @@ class HistoryDatabase {
return rows.map(_dbRowToJson).toList();
}
/// Get item by ID
Future<Map<String, dynamic>?> getById(String id) async {
final db = await database;
final rows = await db.query(
@@ -403,26 +397,22 @@ class HistoryDatabase {
return rows.map((r) => r['spotify_id'] as String).toSet();
}
/// Delete by ID
Future<void> deleteById(String id) async {
final db = await database;
await db.delete('history', where: 'id = ?', whereArgs: [id]);
}
/// Delete by Spotify ID
Future<void> deleteBySpotifyId(String spotifyId) async {
final db = await database;
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
}
/// Clear all history
Future<void> clearAll() async {
final db = await database;
await db.delete('history');
_log.i('Cleared all history');
}
/// Get total count
Future<int> getCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
@@ -459,7 +449,6 @@ class HistoryDatabase {
return null;
}
/// Close database
Future<void> close() async {
final db = await database;
await db.close();
+1 -4
View File
@@ -123,7 +123,7 @@ class LibraryDatabase {
return await openDatabase(
path,
version: 4, // Bumped version for bitrate column
version: 4,
onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL');
@@ -331,13 +331,11 @@ class LibraryDatabase {
String? trackName,
String? artistName,
}) async {
// First try ISRC if available
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = await getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
// Then try name matching
if (trackName != null && artistName != null) {
final matches = await findByTrackAndArtist(trackName, artistName);
if (matches.isNotEmpty) return matches.first;
@@ -523,7 +521,6 @@ class LibraryDatabase {
return rows.map((r) => r['file_path'] as String).toSet();
}
/// Delete multiple items by their file paths
Future<int> deleteByPaths(List<String> filePaths) async {
if (filePaths.isEmpty) return 0;
final db = await database;
@@ -0,0 +1,347 @@
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class LocalTrackRedownloadResolution {
final LocalLibraryItem localItem;
final Track? match;
final int score;
final String reason;
const LocalTrackRedownloadResolution({
required this.localItem,
required this.match,
required this.score,
required this.reason,
});
bool get canQueue => match != null;
}
class LocalTrackRedownloadService {
static const int _minimumConfidenceScore = 85;
static const int _ambiguousScoreGap = 8;
static bool isFlacUpgradeEligible(LocalLibraryItem item) {
final format = item.format?.trim().toLowerCase();
if (format == 'flac') {
return false;
}
return !item.filePath.toLowerCase().endsWith('.flac');
}
static Future<LocalTrackRedownloadResolution> resolveBestMatch(
LocalLibraryItem item, {
required bool includeExtensions,
}) async {
final query = _buildSearchQuery(item);
final rawResults = await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 10,
includeExtensions: includeExtensions,
);
if (rawResults.isEmpty) {
return LocalTrackRedownloadResolution(
localItem: item,
match: null,
score: 0,
reason: 'No candidates found',
);
}
final scored =
rawResults
.map(
(raw) => (
track: _parseSearchTrack(raw),
score: _scoreMatch(item, raw),
),
)
.where((entry) => entry.track.name.trim().isNotEmpty)
.toList(growable: false)
..sort((a, b) => b.score.compareTo(a.score));
if (scored.isEmpty) {
return LocalTrackRedownloadResolution(
localItem: item,
match: null,
score: 0,
reason: 'No usable candidates found',
);
}
final best = scored.first;
final runnerUp = scored.length > 1 ? scored[1] : null;
final exactIsrc =
_normalizedIsrc(item.isrc) != null &&
_normalizedIsrc(item.isrc) == _normalizedIsrc(best.track.isrc);
final isAmbiguous =
!exactIsrc &&
runnerUp != null &&
best.score < (_minimumConfidenceScore + 10) &&
(best.score - runnerUp.score) <= _ambiguousScoreGap;
if (!exactIsrc && (best.score < _minimumConfidenceScore || isAmbiguous)) {
return LocalTrackRedownloadResolution(
localItem: item,
match: null,
score: best.score,
reason: isAmbiguous ? 'Ambiguous match' : 'Low-confidence match',
);
}
return LocalTrackRedownloadResolution(
localItem: item,
match: best.track,
score: best.score,
reason: exactIsrc ? 'Exact ISRC match' : 'High-confidence metadata match',
);
}
static String preferredFlacService(AppSettings settings) {
switch (settings.defaultService.toLowerCase()) {
case 'tidal':
case 'qobuz':
case 'deezer':
return settings.defaultService.toLowerCase();
default:
return 'tidal';
}
}
static String preferredFlacQualityForService(String service) {
return service.toLowerCase() == 'deezer' ? 'FLAC' : 'LOSSLESS';
}
static String _buildSearchQuery(LocalLibraryItem item) {
final artist = _primaryArtist(item.artistName);
final album = item.albumName.trim();
if (album.isNotEmpty && album.toLowerCase() != 'unknown album') {
return '${item.trackName} $artist $album'.trim();
}
return '${item.trackName} $artist'.trim();
}
static Track _parseSearchTrack(Map<String, dynamic> data) {
final durationMs = _extractDurationMs(data);
final itemType = data['item_type']?.toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source: data['source']?.toString() ?? data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
);
}
static int _extractDurationMs(Map<String, dynamic> data) {
final durationMsRaw = data['duration_ms'];
if (durationMsRaw is num && durationMsRaw > 0) {
return durationMsRaw.toInt();
}
if (durationMsRaw is String) {
final parsed = num.tryParse(durationMsRaw.trim());
if (parsed != null && parsed > 0) {
return parsed.toInt();
}
}
final durationSecRaw = data['duration'];
if (durationSecRaw is num && durationSecRaw > 0) {
return (durationSecRaw * 1000).toInt();
}
if (durationSecRaw is String) {
final parsed = num.tryParse(durationSecRaw.trim());
if (parsed != null && parsed > 0) {
return (parsed * 1000).toInt();
}
}
return 0;
}
static int _scoreMatch(LocalLibraryItem item, Map<String, dynamic> raw) {
final track = _parseSearchTrack(raw);
var score = 0;
final localIsrc = _normalizedIsrc(item.isrc);
final candidateIsrc = _normalizedIsrc(track.isrc);
if (localIsrc != null && candidateIsrc != null) {
score += localIsrc == candidateIsrc ? 140 : -120;
}
final localTitle = _normalizedTitle(item.trackName);
final candidateTitle = _normalizedTitle(track.name);
if (localTitle == candidateTitle) {
score += 45;
} else if (_tokenOverlap(localTitle, candidateTitle) >= 0.75) {
score += 24;
} else {
score -= 25;
}
final localArtist = _normalizedArtistGroup(item.artistName);
final candidateArtist = _normalizedArtistGroup(track.artistName);
final artistOverlap = _tokenOverlap(localArtist, candidateArtist);
if (localArtist == candidateArtist) {
score += 30;
} else if (artistOverlap >= 0.6) {
score += 16;
} else {
score -= 20;
}
final localAlbum = _normalizedText(item.albumName);
final candidateAlbum = _normalizedText(track.albumName);
if (localAlbum.isNotEmpty && candidateAlbum.isNotEmpty) {
if (localAlbum == candidateAlbum) {
score += 12;
} else if (_tokenOverlap(localAlbum, candidateAlbum) >= 0.7) {
score += 6;
}
}
final localDuration = item.duration ?? 0;
final candidateDuration = track.duration;
if (localDuration > 0 && candidateDuration > 0) {
final diff = (localDuration - candidateDuration).abs();
if (diff <= 2) {
score += 20;
} else if (diff <= 5) {
score += 12;
} else if (diff <= 10) {
score += 5;
} else if (diff > 20) {
score -= 30;
}
}
if (item.trackNumber != null &&
track.trackNumber != null &&
item.trackNumber == track.trackNumber) {
score += 6;
}
if (item.discNumber != null &&
track.discNumber != null &&
item.discNumber == track.discNumber) {
score += 4;
}
final localYear = _extractYear(item.releaseDate);
final candidateYear = _extractYear(track.releaseDate);
if (localYear != null &&
candidateYear != null &&
localYear == candidateYear) {
score += 4;
}
score += _versionPenalty(item.trackName, track.name);
return score;
}
static String? _normalizedIsrc(String? value) {
final normalized = value?.trim().toUpperCase();
if (normalized == null || normalized.isEmpty) {
return null;
}
return normalized;
}
static String _normalizedTitle(String value) {
final cleaned = _normalizedText(value)
.replaceAll(RegExp(r'\b(feat|ft|featuring)\b.*$'), ' ')
.replaceAll(RegExp(r'\b(remaster(?:ed)?|deluxe|bonus)\b'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return cleaned;
}
static String _normalizedArtistGroup(String value) {
return _normalizedText(
value
.replaceAll(RegExp(r'\b(feat|ft|featuring|with|x)\b'), ',')
.replaceAll('&', ','),
);
}
static String _primaryArtist(String value) {
final parts = _normalizedArtistGroup(
value,
).split(',').map((part) => part.trim()).where((part) => part.isNotEmpty);
return parts.isEmpty ? value.trim() : parts.first;
}
static String _normalizedText(String value) {
return value
.toLowerCase()
.replaceAll(RegExp(r'[\(\)\[\]\{\}]'), ' ')
.replaceAll(RegExp(r'[^a-z0-9, ]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
static double _tokenOverlap(String left, String right) {
final leftTokens = left
.split(RegExp(r'[\s,]+'))
.where((token) => token.isNotEmpty)
.toSet();
final rightTokens = right
.split(RegExp(r'[\s,]+'))
.where((token) => token.isNotEmpty)
.toSet();
if (leftTokens.isEmpty || rightTokens.isEmpty) {
return 0;
}
final intersection = leftTokens.intersection(rightTokens).length;
final denominator = leftTokens.length > rightTokens.length
? leftTokens.length
: rightTokens.length;
return intersection / denominator;
}
static int _versionPenalty(String localTitle, String candidateTitle) {
const riskyMarkers = [
'live',
'karaoke',
'instrumental',
'acoustic',
'radio edit',
'sped up',
'slowed',
];
final local = _normalizedText(localTitle);
final candidate = _normalizedText(candidateTitle);
var penalty = 0;
for (final marker in riskyMarkers) {
final localHas = local.contains(marker);
final candidateHas = candidate.contains(marker);
if (!localHas && candidateHas) {
penalty -= 18;
}
}
return penalty;
}
static int? _extractYear(String? date) {
if (date == null || date.length < 4) {
return null;
}
return int.tryParse(date.substring(0, 4));
}
}
+12 -20
View File
@@ -35,7 +35,6 @@ class AppTheme {
);
}
/// Create dark theme
static ThemeData dark({
ColorScheme? dynamicScheme,
Color? seedColor,
@@ -88,12 +87,11 @@ class AppTheme {
),
);
/// Card theme
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 12 -> 16
),
color: scheme.surfaceContainerLow,
surfaceTintColor: scheme.surfaceTint,
);
@@ -104,18 +102,17 @@ class AppTheme {
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
);
/// Filled button theme
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
);
@@ -125,18 +122,17 @@ class AppTheme {
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
);
/// Text button theme
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
TextButtonThemeData(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
);
@@ -149,40 +145,39 @@ class AppTheme {
foregroundColor: scheme.onPrimaryContainer,
);
/// Input decoration theme
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHighest.withValues(
alpha: 0.3,
), // Added transparency
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), // 12 -> 16
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), // 12 -> 16
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), // 12 -> 16
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: scheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), // 12 -> 16
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: scheme.error, width: 1),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
), // consistent padding
),
);
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 12 -> 16
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
);
@@ -193,7 +188,6 @@ class AppTheme {
surfaceTintColor: scheme.surfaceTint,
);
/// Navigation bar theme
static NavigationBarThemeData _navigationBarTheme(
ColorScheme scheme, {
bool isAmoled = false,
@@ -213,7 +207,6 @@ class AppTheme {
contentTextStyle: TextStyle(color: scheme.onInverseSurface),
);
/// Progress indicator theme
static ProgressIndicatorThemeData _progressIndicatorTheme(
ColorScheme scheme,
) => ProgressIndicatorThemeData(
@@ -243,7 +236,6 @@ class AppTheme {
}),
);
/// Chip theme
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: scheme.surfaceContainerLow,
-3
View File
@@ -4,7 +4,6 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
/// Wrapper widget that provides dynamic color support from device wallpaper
class DynamicColorWrapper extends ConsumerWidget {
final Widget Function(ThemeData light, ThemeData dark, ThemeMode mode) builder;
@@ -23,7 +22,6 @@ class DynamicColorWrapper extends ConsumerWidget {
ColorScheme darkScheme;
if (themeSettings.useDynamicColor && lightDynamic != null && darkDynamic != null) {
// Use dynamic colors from wallpaper (Android 12+)
lightScheme = lightDynamic;
darkScheme = darkDynamic;
} else {
@@ -38,7 +36,6 @@ class DynamicColorWrapper extends ConsumerWidget {
);
}
// Apply AMOLED mode if enabled (pure black background)
if (themeSettings.useAmoled) {
darkScheme = _applyAmoledColors(darkScheme);
}
+41 -9
View File
@@ -20,6 +20,22 @@ final _iosLegacyRelativeDocumentsPattern = RegExp(
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
caseSensitive: false,
);
final _iosNestedLegacyDocumentsPattern = RegExp(
r'/Documents/Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
caseSensitive: false,
);
String _normalizeRecoveredIosSuffix(String suffix) {
final trimmed = suffix.trim();
if (trimmed.isEmpty) return '';
return trimmed.startsWith('/') ? trimmed.substring(1) : trimmed;
}
String _joinRecoveredIosPath(String documentsPath, String suffix) {
final normalizedSuffix = _normalizeRecoveredIosSuffix(suffix);
if (normalizedSuffix.isEmpty) return documentsPath;
return '$documentsPath/$normalizedSuffix';
}
/// Checks if a path is a valid writable directory on iOS.
/// Returns false if:
@@ -43,6 +59,12 @@ bool isValidIosWritablePath(String path) {
return false;
}
// Reject stale paths where an old sandbox container path has been embedded
// inside the current Documents directory.
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
return false;
}
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
// This handles cases where FilePicker returns container root
final containerPattern = RegExp(
@@ -70,11 +92,19 @@ Future<String> validateOrFixIosPath(
if (!Platform.isIOS) return path;
final trimmed = path.trim();
final docDir = await getApplicationDocumentsDirectory();
final nestedLegacyMatch = _iosNestedLegacyDocumentsPattern.firstMatch(
trimmed,
);
if (nestedLegacyMatch != null) {
return _joinRecoveredIosPath(docDir.path, nestedLegacyMatch.group(1) ?? '');
}
if (isValidIosWritablePath(trimmed)) {
return trimmed;
}
final docDir = await getApplicationDocumentsDirectory();
final candidates = <String>[];
if (trimmed.isNotEmpty) {
@@ -92,14 +122,8 @@ Future<String> validateOrFixIosPath(
trimmed,
);
if (legacyRelativeMatch != null) {
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
final normalizedSuffix = suffix.startsWith('/')
? suffix.substring(1)
: suffix;
candidates.add(
normalizedSuffix.isEmpty
? docDir.path
: '${docDir.path}/$normalizedSuffix',
_joinRecoveredIosPath(docDir.path, legacyRelativeMatch.group(1) ?? ''),
);
}
@@ -109,7 +133,7 @@ Future<String> validateOrFixIosPath(
final index = trimmed.indexOf(documentsMarker);
if (index >= 0) {
final suffix = trimmed.substring(index + documentsMarker.length).trim();
candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix');
candidates.add(_joinRecoveredIosPath(docDir.path, suffix));
}
}
@@ -181,6 +205,14 @@ IosPathValidationResult validateIosPath(String path) {
);
}
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
errorReason:
'Invalid iOS app folder path. Please choose App Documents or another local folder.',
);
}
// Check for container root without subdirectory
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
+29
View File
@@ -0,0 +1,29 @@
import 'package:shared_preferences/shared_preferences.dart';
const localLibraryLastScannedAtKey = 'local_library_last_scanned_at';
DateTime? readLocalLibraryLastScannedAt(SharedPreferences prefs) {
final lastScannedAtStr = prefs.getString(localLibraryLastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
return DateTime.tryParse(lastScannedAtStr);
}
// Backward compatibility for older builds that may have stored epoch millis.
final lastScannedAtMs = prefs.getInt(localLibraryLastScannedAtKey);
if (lastScannedAtMs != null) {
return DateTime.fromMillisecondsSinceEpoch(lastScannedAtMs);
}
return null;
}
Future<void> writeLocalLibraryLastScannedAt(
SharedPreferences prefs,
DateTime value,
) {
return prefs.setString(localLibraryLastScannedAtKey, value.toIso8601String());
}
Future<void> clearLocalLibraryLastScannedAt(SharedPreferences prefs) {
return prefs.remove(localLibraryLastScannedAtKey);
}
+39
View File
@@ -8,6 +8,33 @@ const _androidStoragePathAliases = <String>[
'/mnt/sdcard',
];
/// Audio file extensions that the app commonly produces or converts between.
/// Used to generate extension-stripped match keys so that a file converted from
/// one format to another (e.g. .flac .opus) is still recognised as the same
/// track.
const _audioExtensions = <String>[
'.flac',
'.m4a',
'.mp3',
'.opus',
'.ogg',
'.wav',
'.aac',
];
/// Strips a trailing audio extension from [path] if present.
/// Returns the path without extension, or `null` if no known audio extension
/// was found.
String? _stripAudioExtension(String path) {
final lower = path.toLowerCase();
for (final ext in _audioExtensions) {
if (lower.endsWith(ext)) {
return path.substring(0, path.length - ext.length);
}
}
return null;
}
Set<String> buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
@@ -79,6 +106,18 @@ Set<String> buildPathMatchKeys(String? filePath) {
}
addNormalized(cleaned);
// Add extension-stripped variants so that a file converted from one audio
// format to another (e.g. Song.flac Song.opus) still matches.
final extensionStrippedKeys = <String>{};
for (final key in keys) {
final stripped = _stripAudioExtension(key);
if (stripped != null && stripped.isNotEmpty) {
extensionStrippedKeys.add(stripped);
}
}
keys.addAll(extensionStrippedKeys);
return keys;
}
+5 -7
View File
@@ -22,8 +22,7 @@ class BuiltInService {
});
}
/// Default quality options for built-in services
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
/// Default quality options for each built-in service
const _builtInServices = [
BuiltInService(
id: 'tidal',
@@ -83,9 +82,9 @@ const _builtInServices = [
label: 'YouTube',
qualityOptions: [
QualityOption(
id: 'opus_256',
label: 'Opus 256kbps',
description: 'Best quality lossy (~8MB per track)',
id: 'opus_320',
label: 'Opus 320kbps',
description: 'Best quality lossy (~10MB per track)',
),
QualityOption(
id: 'mp3_320',
@@ -98,7 +97,6 @@ const _builtInServices = [
),
];
/// A reusable widget for selecting download service (built-in + extensions)
class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName;
final String? artistName;
@@ -146,7 +144,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
}
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
late String _selectedService;
+7 -5
View File
@@ -20,6 +20,7 @@ Future<void> showAddTracksToPlaylistSheet(
BuildContext context,
WidgetRef ref,
List<Track> tracks,
{String? playlistNamePrefill}
) async {
if (tracks.isEmpty) return;
@@ -31,15 +32,16 @@ Future<void> showAddTracksToPlaylistSheet(
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return _PlaylistPickerSheetContent(tracks: tracks);
return _PlaylistPickerSheetContent(tracks: tracks, playlistNamePrefill: playlistNamePrefill);
},
);
}
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
final List<Track> tracks;
final String? playlistNamePrefill;
const _PlaylistPickerSheetContent({required this.tracks});
const _PlaylistPickerSheetContent({required this.tracks, this.playlistNamePrefill});
@override
ConsumerState<_PlaylistPickerSheetContent> createState() =>
@@ -130,7 +132,7 @@ class _PlaylistPickerSheetContentState
leading: const Icon(Icons.add_circle_outline),
title: Text(context.l10n.collectionCreatePlaylist),
onTap: () async {
final name = await _promptPlaylistName(context);
final name = await _promptPlaylistName(context, widget.playlistNamePrefill);
if (name == null || name.trim().isEmpty || !context.mounted) {
return;
}
@@ -221,8 +223,8 @@ class _PlaylistPickerSheetContentState
}
}
Future<String?> _promptPlaylistName(BuildContext context) async {
final controller = TextEditingController();
Future<String?> _promptPlaylistName(BuildContext context, String? playlistNamePrefill) async {
final controller = TextEditingController(text: playlistNamePrefill);
final formKey = GlobalKey<FormState>();
final result = await showDialog<String>(
+12 -20
View File
@@ -133,10 +133,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -557,14 +557,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
@@ -633,18 +625,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
@@ -1166,26 +1158,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
version: "1.26.3"
version: "1.30.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
version: "0.6.12"
version: "0.6.16"
timezone:
dependency: transitive
description:
+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: 3.8.0+106
version: 3.8.8+114
environment:
sdk: ^3.10.0