Compare commits

...

121 Commits

Author SHA1 Message Date
zarzet 0dc89cf569 fix(deps): allow material_color_utilities sdk-pinned range 2026-02-12 02:33:54 +07:00
zarzet 3c1e9d03a0 fix(ios): recover notification permission and path handling 2026-02-12 02:23:54 +07:00
zarzet 28a082f47a fix(l10n): fix arb locale mismatch - rename es-ES/pt-PT to underscore format 2026-02-12 01:28:56 +07:00
zarzet 38994d5900 chore: bump pubspec.yaml version to 3.6.6+80 2026-02-12 01:16:49 +07:00
zarzet 472896328a Merge remote-tracking branch 'origin/dev' into dev 2026-02-12 01:15:50 +07:00
zarzet 92f408035a feat: enable library scan notifications on iOS (remove Android-only guard) 2026-02-12 01:14:10 +07:00
zarzet 979186243c docs: update changelog v3.6.6 with new features and translation update 2026-02-12 01:11:12 +07:00
zarzet ee66247bea Merge remote-tracking branch 'origin/l10n_dev' into dev
# Conflicts:
#	lib/l10n/arb/app_es-ES.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_pt-PT.arb
2026-02-12 01:10:04 +07:00
zarzet 66a9daf733 chore: minor formatting and whitespace cleanup 2026-02-12 01:06:27 +07:00
zarzet 69a9e0cb40 feat: add library scan notifications - progress, complete, failed, cancelled notifications for local library scan - new notification channel for library scan - Android only 2026-02-12 01:06:17 +07:00
zarzet cd6beaa7d4 feat: add filterContributingArtistsInAlbumArtist setting - new option to strip contributing artists from Album Artist metadata - applies to folder organization and metadata embedding - collapsible Artist Name Filters section in download settings UI 2026-02-12 01:06:08 +07:00
zarzet 5f4ff17630 refactor(ios): remove legacy download handlers, use downloadByStrategy only 2026-02-12 01:02:48 +07:00
zarzet 3c3bbe516e v3.6.6: fix iOS downloads, metadata fallback, lossy quality display, audio duration accuracy 2026-02-12 00:32:40 +07:00
zarzet a1d1ab1f0f fix: preserve extended metadata during fallback, accurate lossy quality display, SAF improvements
- Add Genre/Label/Copyright fields to DownloadResult struct
- buildDownloadSuccessResponse now prefers service result metadata over request
- enrichRequestExtendedMetadata fetches Deezer metadata by ISRC before download
- Flutter sends copyright in download request payload
- History merge preserves existing genre/label/copyright on re-download
- Accurate MP3 duration via Xing/VBRI VBR headers, MPEG2/2.5 bitrate tables
- Accurate Opus/Vorbis duration via last Ogg page granule position
- Bitrate field added to LibraryScanResult, LocalLibraryItem, DB v4 migration
- Lossy formats display format+bitrate instead of fake 16-bit quality
- Local library file date uses fileModTime instead of scannedAt
- SAF URI recovery for transient FD paths after download
- Improved SAF repair and download history path matching in library scan
- Extract quality probe logic into reusable enrichResultQualityFromFile
2026-02-12 00:19:02 +07:00
Zarz Eleutherius ab9456fff8 New translations app_en.arb (Turkish) 2026-02-11 16:12:15 +07:00
Zarz Eleutherius 2f673469aa New translations app_en.arb (Hindi) 2026-02-11 16:12:14 +07:00
Zarz Eleutherius 05fde22075 New translations app_en.arb (Indonesian) 2026-02-11 16:12:13 +07:00
Zarz Eleutherius deab7b7dd6 New translations app_en.arb (Chinese Traditional) 2026-02-11 16:12:11 +07:00
Zarz Eleutherius ae5da3b6e0 New translations app_en.arb (Chinese Simplified) 2026-02-11 16:12:10 +07:00
Zarz Eleutherius 4d0c8f49aa New translations app_en.arb (Russian) 2026-02-11 16:12:08 +07:00
Zarz Eleutherius 3068f4e367 New translations app_en.arb (Portuguese) 2026-02-11 16:12:07 +07:00
Zarz Eleutherius 3844704490 New translations app_en.arb (Dutch) 2026-02-11 16:12:06 +07:00
Zarz Eleutherius 12144b8220 New translations app_en.arb (Korean) 2026-02-11 16:12:04 +07:00
Zarz Eleutherius b639080494 New translations app_en.arb (Japanese) 2026-02-11 16:12:03 +07:00
Zarz Eleutherius e67d7d68cb New translations app_en.arb (German) 2026-02-11 16:12:01 +07:00
Zarz Eleutherius b8f18c1cf5 New translations app_en.arb (Spanish) 2026-02-11 16:12:00 +07:00
Zarz Eleutherius 529958c4af New translations app_en.arb (French) 2026-02-11 16:11:59 +07:00
Zarz Eleutherius 40077a577c Update source file app_en.arb 2026-02-11 16:11:57 +07:00
Zarz Eleutherius e0fbd706ce Merge pull request #145 from zarzet/renovate/go-dependencies
fix(deps): update go dependencies
2026-02-11 16:10:59 +07:00
Zarz Eleutherius b76879f204 Merge pull request #148 from zarzet/renovate/go-1.x
chore(deps): update dependency go to 1.26
2026-02-11 16:10:47 +07:00
renovate[bot] eefbb63299 fix(deps): update go dependencies 2026-02-11 05:31:18 +00:00
renovate[bot] fdbb474763 chore(deps): update dependency go to 1.26 2026-02-11 05:30:57 +00:00
zarzet 6a7eef6956 chore: add Elias el Autentico to supporters list 2026-02-11 12:28:50 +07:00
Zarz Eleutherius 803e0dc5a3 New translations app_en.arb (Turkish) 2026-02-10 19:46:50 +07:00
Zarz Eleutherius 474c37ec8e New translations app_en.arb (Hindi) 2026-02-10 19:46:46 +07:00
Zarz Eleutherius eb7726263a New translations app_en.arb (Indonesian) 2026-02-10 19:46:45 +07:00
Zarz Eleutherius f87ccc51c5 New translations app_en.arb (Chinese Traditional) 2026-02-10 19:46:43 +07:00
Zarz Eleutherius b0b4e7803c New translations app_en.arb (Chinese Simplified) 2026-02-10 19:46:42 +07:00
Zarz Eleutherius 450f19c656 New translations app_en.arb (Russian) 2026-02-10 19:46:41 +07:00
Zarz Eleutherius 55b9c08f99 New translations app_en.arb (Portuguese) 2026-02-10 19:46:39 +07:00
Zarz Eleutherius a5f3aab775 New translations app_en.arb (Dutch) 2026-02-10 19:46:38 +07:00
Zarz Eleutherius 7442c9b106 New translations app_en.arb (Korean) 2026-02-10 19:46:37 +07:00
Zarz Eleutherius ae66cb478b New translations app_en.arb (Japanese) 2026-02-10 19:46:35 +07:00
Zarz Eleutherius 2516c3e618 New translations app_en.arb (German) 2026-02-10 19:46:34 +07:00
Zarz Eleutherius 02a5893279 New translations app_en.arb (Spanish) 2026-02-10 19:46:33 +07:00
Zarz Eleutherius bd0d653210 New translations app_en.arb (French) 2026-02-10 19:46:31 +07:00
Zarz Eleutherius 62626ddc08 Update source file app_en.arb 2026-02-10 19:46:29 +07:00
Zarz Eleutherius 9cd2b1d8c5 New translations app_en.arb (Russian) 2026-02-09 19:41:00 +07:00
Zarz Eleutherius 49f1fb43fa New translations app_en.arb (Spanish) 2026-02-09 19:40:58 +07:00
Zarz Eleutherius 65b521ff8b New translations app_en.arb (Turkish) 2026-02-08 19:17:59 +07:00
Zarz Eleutherius 6d578694e2 New translations app_en.arb (Hindi) 2026-02-08 19:17:58 +07:00
Zarz Eleutherius f7ec649b24 New translations app_en.arb (Indonesian) 2026-02-08 19:17:57 +07:00
Zarz Eleutherius 71a9e1baef New translations app_en.arb (Chinese Traditional) 2026-02-08 19:17:56 +07:00
Zarz Eleutherius 4a4adcb72e New translations app_en.arb (Chinese Simplified) 2026-02-08 19:17:55 +07:00
Zarz Eleutherius 3458f03158 New translations app_en.arb (Russian) 2026-02-08 19:17:54 +07:00
Zarz Eleutherius 4fe4a01840 New translations app_en.arb (Portuguese) 2026-02-08 19:17:53 +07:00
Zarz Eleutherius e5d6fddeda New translations app_en.arb (Dutch) 2026-02-08 19:17:52 +07:00
Zarz Eleutherius 370f5e3b8b New translations app_en.arb (Korean) 2026-02-08 19:17:51 +07:00
Zarz Eleutherius f5bb0820d5 New translations app_en.arb (Japanese) 2026-02-08 19:17:50 +07:00
Zarz Eleutherius feb6da3ecb New translations app_en.arb (German) 2026-02-08 19:17:48 +07:00
Zarz Eleutherius 39f28a12aa New translations app_en.arb (Spanish) 2026-02-08 19:17:47 +07:00
Zarz Eleutherius 416fc79637 New translations app_en.arb (French) 2026-02-08 19:17:46 +07:00
Zarz Eleutherius 1f43780bec Update source file app_en.arb 2026-02-08 19:17:44 +07:00
Zarz Eleutherius 481b4b03dc New translations app_en.arb (Turkish) 2026-02-07 19:20:11 +07:00
Zarz Eleutherius b7fd2f7902 New translations app_en.arb (Hindi) 2026-02-07 19:20:10 +07:00
Zarz Eleutherius f2e1e59d6a New translations app_en.arb (Indonesian) 2026-02-07 19:20:09 +07:00
Zarz Eleutherius 3af2ecf1f4 New translations app_en.arb (Chinese Traditional) 2026-02-07 19:20:08 +07:00
Zarz Eleutherius 1b2f2c891c New translations app_en.arb (Chinese Simplified) 2026-02-07 19:20:07 +07:00
Zarz Eleutherius 155f3259f2 New translations app_en.arb (Russian) 2026-02-07 19:20:06 +07:00
Zarz Eleutherius f52d8d68b8 New translations app_en.arb (Portuguese) 2026-02-07 19:20:05 +07:00
Zarz Eleutherius 216d6e152c New translations app_en.arb (Dutch) 2026-02-07 19:20:04 +07:00
Zarz Eleutherius b6f90e727c New translations app_en.arb (Korean) 2026-02-07 19:20:03 +07:00
Zarz Eleutherius 790bbc544f New translations app_en.arb (Japanese) 2026-02-07 19:20:02 +07:00
Zarz Eleutherius bd511f7dc6 New translations app_en.arb (German) 2026-02-07 19:20:01 +07:00
Zarz Eleutherius e91c8c28a8 New translations app_en.arb (Spanish) 2026-02-07 19:20:00 +07:00
Zarz Eleutherius 3c6d1afa97 New translations app_en.arb (French) 2026-02-07 19:19:59 +07:00
Zarz Eleutherius 3947e109b4 Update source file app_en.arb 2026-02-07 19:19:57 +07:00
Zarz Eleutherius bf87662f99 New translations app_en.arb (Russian) 2026-02-06 18:33:40 +07:00
Zarz Eleutherius 4273edd836 New translations app_en.arb (Russian) 2026-02-05 13:19:12 +07:00
Zarz Eleutherius 7ce41fc1c1 Update source file app_en.arb 2026-02-05 13:19:10 +07:00
Zarz Eleutherius fb7a576e00 New translations app_en.arb (Turkish) 2026-02-04 12:53:12 +07:00
Zarz Eleutherius 30a559b279 New translations app_en.arb (Hindi) 2026-02-04 12:53:11 +07:00
Zarz Eleutherius f77d5fdf14 New translations app_en.arb (Indonesian) 2026-02-04 12:53:10 +07:00
Zarz Eleutherius 0a0667889c New translations app_en.arb (Chinese Traditional) 2026-02-04 12:53:09 +07:00
Zarz Eleutherius 14d8cd54d7 New translations app_en.arb (Chinese Simplified) 2026-02-04 12:53:08 +07:00
Zarz Eleutherius 5fa3d405e6 New translations app_en.arb (Russian) 2026-02-04 12:53:07 +07:00
Zarz Eleutherius 34eb335fd0 New translations app_en.arb (Portuguese) 2026-02-04 12:53:06 +07:00
Zarz Eleutherius c910530927 New translations app_en.arb (Dutch) 2026-02-04 12:53:05 +07:00
Zarz Eleutherius 69e1a6cf6b New translations app_en.arb (Korean) 2026-02-04 12:53:04 +07:00
Zarz Eleutherius bd84613624 New translations app_en.arb (Japanese) 2026-02-04 12:53:02 +07:00
Zarz Eleutherius 0b4777fc6b New translations app_en.arb (German) 2026-02-04 12:53:01 +07:00
Zarz Eleutherius e22813caec New translations app_en.arb (Spanish) 2026-02-04 12:53:00 +07:00
Zarz Eleutherius 8f6e8432de New translations app_en.arb (French) 2026-02-04 12:52:59 +07:00
Zarz Eleutherius b3c98cecc3 New translations app_en.arb (Turkish) 2026-02-02 08:25:49 +07:00
Zarz Eleutherius 49a18a977b New translations app_en.arb (Hindi) 2026-02-02 08:25:48 +07:00
Zarz Eleutherius a5d0feeedf New translations app_en.arb (Indonesian) 2026-02-02 08:25:47 +07:00
Zarz Eleutherius a574e73b44 New translations app_en.arb (Chinese Traditional) 2026-02-02 08:25:46 +07:00
Zarz Eleutherius a66f6a739f New translations app_en.arb (Chinese Simplified) 2026-02-02 08:25:45 +07:00
Zarz Eleutherius cc7e1b54b6 New translations app_en.arb (Russian) 2026-02-02 08:25:44 +07:00
Zarz Eleutherius 28cb7fcd3d New translations app_en.arb (Portuguese) 2026-02-02 08:25:43 +07:00
Zarz Eleutherius aeb370beca New translations app_en.arb (Dutch) 2026-02-02 08:25:42 +07:00
Zarz Eleutherius 239707e2da New translations app_en.arb (Korean) 2026-02-02 08:25:41 +07:00
Zarz Eleutherius c1e2778735 New translations app_en.arb (Japanese) 2026-02-02 08:25:40 +07:00
Zarz Eleutherius fb608a554d New translations app_en.arb (German) 2026-02-02 08:25:39 +07:00
Zarz Eleutherius 7561065802 New translations app_en.arb (Spanish) 2026-02-02 08:25:38 +07:00
Zarz Eleutherius 56c8d89999 New translations app_en.arb (French) 2026-02-02 08:25:37 +07:00
Zarz Eleutherius 9192760f3c Update source file app_en.arb 2026-02-02 08:25:35 +07:00
Zarz Eleutherius 40ec24db69 New translations app_en.arb (Turkish) 2026-02-01 08:04:39 +07:00
Zarz Eleutherius ba8d0a3438 New translations app_en.arb (Hindi) 2026-02-01 08:04:38 +07:00
Zarz Eleutherius 82decf99a6 New translations app_en.arb (Indonesian) 2026-02-01 08:04:37 +07:00
Zarz Eleutherius 6ba9fc1fec New translations app_en.arb (Chinese Traditional) 2026-02-01 08:04:36 +07:00
Zarz Eleutherius 715d94c2ed New translations app_en.arb (Chinese Simplified) 2026-02-01 08:04:35 +07:00
Zarz Eleutherius e1a722f479 New translations app_en.arb (Russian) 2026-02-01 08:04:34 +07:00
Zarz Eleutherius edbe12c512 New translations app_en.arb (Portuguese) 2026-02-01 08:04:33 +07:00
Zarz Eleutherius 9fc6542792 New translations app_en.arb (Dutch) 2026-02-01 08:04:32 +07:00
Zarz Eleutherius 4c01ee26c2 New translations app_en.arb (Korean) 2026-02-01 08:04:31 +07:00
Zarz Eleutherius 813b9fcf61 New translations app_en.arb (Japanese) 2026-02-01 08:04:30 +07:00
Zarz Eleutherius fe070e0177 New translations app_en.arb (German) 2026-02-01 08:04:29 +07:00
Zarz Eleutherius 423bb87ed8 New translations app_en.arb (Spanish) 2026-02-01 08:04:28 +07:00
Zarz Eleutherius 1641f51b0c New translations app_en.arb (French) 2026-02-01 08:04:27 +07:00
Zarz Eleutherius 3f78a1f3d1 Update source file app_en.arb 2026-02-01 08:04:25 +07:00
38 changed files with 15422 additions and 1030 deletions
+2 -2
View File
@@ -71,7 +71,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: "1.25" go-version: "1.26"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds # Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: "1.25" go-version: "1.26"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache CocoaPods # Cache CocoaPods
+25
View File
@@ -1,5 +1,30 @@
# Changelog # Changelog
## [3.6.6] - 2026-02-12
### Added
- "Filter Contributing Artists in Album Artist" setting - strips featured/contributing artists from Album Artist metadata tag
- Library scan notifications (Android and iOS) - shows progress, completion, failure, and cancellation status
- Collapsible "Artist Name Filters" section in download settings UI
### Fixed
- Fixed downloads not working on iOS - missing `downloadByStrategy` and `downloadFromYouTube` method channel handlers in AppDelegate.swift
- Fixed extended metadata (genre, label, copyright) lost during service fallback (e.g. Tidal unavailable, falls back to Qobuz) - Go backend now enriches metadata from Deezer by ISRC before download and preserves it through the fallback chain
- Fixed local library showing incorrect "16-bit" quality label for lossy formats (MP3, Opus) - now displays actual bitrate (e.g. "MP3 320kbps")
- Fixed inaccurate Opus/Vorbis duration calculation (e.g. 4:11 showing as 8:44) - now reads granule position from last Ogg page for precise duration
- Fixed MP3 duration/bitrate inaccuracy for VBR files - added Xing/Info and VBRI header parsing with MPEG2/2.5 bitrate table support
- Fixed Track Metadata screen showing scan date instead of file date for local library items
- Fixed SAF content URI paths displayed as raw `content://` strings in Track Metadata - now shows human-readable paths
### Changed
- Removed legacy iOS download handlers (`downloadTrack`, `downloadWithFallback`, `downloadFromYouTube`) - iOS now uses `downloadByStrategy` only
- Updated translations from Crowdin (all 14 languages)
---
## [3.6.5] - 2026-02-10 ## [3.6.5] - 2026-02-10
### Highlights ### Highlights
+190 -44
View File
@@ -43,6 +43,7 @@ type OggQuality struct {
SampleRate int SampleRate int
BitDepth int BitDepth int
Duration int Duration int
Bitrate int // estimated bitrate in bps
} }
// ============================================================================= // =============================================================================
@@ -664,50 +665,144 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
file.Seek(audioStart, io.SeekStart) file.Seek(audioStart, io.SeekStart)
// Find first valid MP3 frame sync
frameHeader := make([]byte, 4) frameHeader := make([]byte, 4)
for i := 0; i < 10000; i++ { // Search first 10KB var frameStart int64 = -1
for i := 0; i < 10000; i++ {
if _, err := io.ReadFull(file, frameHeader); err != nil { if _, err := io.ReadFull(file, frameHeader); err != nil {
break break
} }
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 { if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
version := (frameHeader[1] >> 3) & 0x03 pos, _ := file.Seek(0, io.SeekCurrent)
layer := (frameHeader[1] >> 1) & 0x03 frameStart = pos - 4
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
quality.BitDepth = 16
if quality.Bitrate > 0 {
audioSize := fileSize - audioStart - 128
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
break break
} }
file.Seek(-3, io.SeekCurrent) file.Seek(-3, io.SeekCurrent)
} }
if frameStart < 0 {
return quality, nil
}
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
channelMode := (frameHeader[3] >> 6) & 0x03
// Sample rate tables: [version][index]
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
// Bitrate tables for all MPEG versions and layers
// MPEG1 Layer III
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// MPEG2/2.5 Layer III
if (version == 0 || version == 2) && layer == 1 {
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// Determine samples per frame for duration calculation
samplesPerFrame := 1152 // MPEG1 Layer III
if version == 0 || version == 2 {
samplesPerFrame = 576 // MPEG2/2.5 Layer III
}
// Try to read Xing/VBRI header from the first frame for VBR info
// Xing header offset depends on MPEG version and channel mode
var xingOffset int
if version == 3 { // MPEG1
if channelMode == 3 { // Mono
xingOffset = 17
} else {
xingOffset = 32
}
} else { // MPEG2/2.5
if channelMode == 3 {
xingOffset = 9
} else {
xingOffset = 17
}
}
// Read enough of the first frame to find Xing/VBRI header
xingBuf := make([]byte, 200)
file.Seek(frameStart+4, io.SeekStart)
n, _ := io.ReadFull(file, xingBuf)
xingBuf = xingBuf[:n]
vbrFrames := 0
vbrBytes := int64(0)
isVBR := false
// Check for Xing/Info header
if xingOffset+8 <= n {
tag := string(xingBuf[xingOffset : xingOffset+4])
if tag == "Xing" || tag == "Info" {
flags := binary.BigEndian.Uint32(xingBuf[xingOffset+4 : xingOffset+8])
off := xingOffset + 8
if flags&0x01 != 0 && off+4 <= n { // Frames flag
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[off : off+4]))
off += 4
}
if flags&0x02 != 0 && off+4 <= n { // Bytes flag
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[off : off+4]))
}
if vbrFrames > 0 {
isVBR = true
}
}
}
// Check for VBRI header (always at offset 32 from frame start + 4)
if !isVBR && 36+26 <= n {
if string(xingBuf[32:36]) == "VBRI" {
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[36+10 : 36+14]))
if vbrFrames > 0 {
isVBR = true
}
}
}
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
// Accurate duration from total frames
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
quality.Duration = int(totalSamples / int64(quality.SampleRate))
// Accurate average bitrate
if vbrBytes > 0 && quality.Duration > 0 {
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
} else if quality.Duration > 0 {
audioSize := fileSize - audioStart
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
}
} else if quality.Bitrate > 0 {
// CBR fallback: estimate duration from file size and frame bitrate
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
return quality, nil return quality, nil
} }
@@ -981,7 +1076,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
defer file.Close() defer file.Close()
quality := &OggQuality{} quality := &OggQuality{}
isOpus := false
packets, err := collectOggPackets(file, 5, 10) packets, err := collectOggPackets(file, 5, 10)
if err != nil && len(packets) == 0 { if err != nil && len(packets) == 0 {
@@ -997,15 +1091,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
} }
} }
if streamType == oggStreamOpus { isOpus := streamType == oggStreamOpus
isOpus = true var preSkip int
if isOpus {
for _, pkt := range packets { for _, pkt := range packets {
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" { if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
if quality.SampleRate == 0 { if quality.SampleRate == 0 {
quality.SampleRate = 48000 quality.SampleRate = 48000
} }
quality.BitDepth = 16 preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
break break
} }
} }
@@ -1013,26 +1109,76 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
for _, pkt := range packets { for _, pkt := range packets {
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" { if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
quality.BitDepth = 16
break break
} }
} }
} }
// Read granule position from the last Ogg page for accurate duration
stat, err := file.Stat() stat, err := file.Stat()
if err == nil { if err != nil {
// Very rough duration estimate based on file size return quality, nil
// Assume ~128kbps average for Opus, ~160kbps for Vorbis }
avgBitrate := 128000 fileSize := stat.Size()
if !isOpus {
avgBitrate = 160000 granule := readLastOggGranulePosition(file, fileSize)
if granule > 0 {
if isOpus {
// Opus always uses 48kHz granule position internally
totalSamples := granule - int64(preSkip)
if totalSamples > 0 {
quality.Duration = int(totalSamples / 48000)
}
} else if quality.SampleRate > 0 {
quality.Duration = int(granule / int64(quality.SampleRate))
} }
quality.Duration = int(stat.Size() * 8 / int64(avgBitrate)) }
// Calculate average bitrate from file size and actual duration
if quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
} }
return quality, nil return quality, nil
} }
// readLastOggGranulePosition seeks to the end of the file and scans backwards
// to find the last Ogg page, then reads its granule position (bytes 6-13).
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
// Read the last chunk of the file to find the last OggS sync
searchSize := int64(65536)
if searchSize > fileSize {
searchSize = fileSize
}
buf := make([]byte, searchSize)
offset := fileSize - searchSize
if offset < 0 {
offset = 0
}
n, err := file.ReadAt(buf, offset)
if err != nil && n == 0 {
return 0
}
buf = buf[:n]
// Scan backwards for "OggS" magic
lastPageOffset := -1
for i := n - 4; i >= 0; i-- {
if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' {
lastPageOffset = i
break
}
}
if lastPageOffset < 0 || lastPageOffset+14 > n {
return 0
}
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64)
return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14]))
}
// ============================================================================= // =============================================================================
// ID3v1 Genre List // ID3v1 Genre List
// ============================================================================= // =============================================================================
+102 -29
View File
@@ -213,6 +213,9 @@ type DownloadResult struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
Genre string
Label string
Copyright string
LyricsLRC string LyricsLRC string
DecryptionKey string DecryptionKey string
} }
@@ -260,6 +263,21 @@ func buildDownloadSuccessResponse(
isrc = req.ISRC isrc = req.ISRC
} }
genre := result.Genre
if genre == "" {
genre = req.Genre
}
label := result.Label
if label == "" {
label = req.Label
}
copyright := result.Copyright
if copyright == "" {
copyright = req.Copyright
}
return DownloadResponse{ return DownloadResponse{
Success: true, Success: true,
Message: message, Message: message,
@@ -277,14 +295,85 @@ func buildDownloadSuccessResponse(
DiscNumber: discNumber, DiscNumber: discNumber,
ISRC: isrc, ISRC: isrc,
CoverURL: req.CoverURL, CoverURL: req.CoverURL,
Genre: req.Genre, Genre: genre,
Label: req.Label, Label: label,
Copyright: req.Copyright, Copyright: copyright,
LyricsLRC: result.LyricsLRC, LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey, DecryptionKey: result.DecryptionKey,
} }
} }
func shouldSkipQualityProbe(filePath string) bool {
path := strings.TrimSpace(filePath)
if path == "" {
return true
}
if strings.HasPrefix(path, "/proc/self/fd/") {
return true
}
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
if strings.Contains(path, "://") {
return true
}
return false
}
func enrichResultQualityFromFile(result *DownloadResult) {
if result == nil {
return
}
path := strings.TrimSpace(result.FilePath)
if shouldSkipQualityProbe(path) {
if strings.HasPrefix(path, "/proc/self/fd/") {
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
}
return
}
quality, qErr := GetAudioQuality(path)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
return
}
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
}
func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil {
return
}
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
if err != nil || extMeta == nil {
if err != nil {
GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
return
}
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
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)
}
}
func DownloadTrack(requestJSON string) (string, error) { func DownloadTrack(requestJSON string) (string, error) {
var req DownloadRequest var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -303,6 +392,8 @@ func DownloadTrack(requestJSON string) (string, error) {
AddAllowedDownloadDir(req.OutputDir) AddAllowedDownloadDir(req.OutputDir)
} }
enrichRequestExtendedMetadata(&req)
var result DownloadResult var result DownloadResult
var err error var err error
@@ -390,11 +481,8 @@ func DownloadTrack(requestJSON string) (string, error) {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:] actualPath := result.FilePath[7:]
quality, qErr := GetAudioQuality(actualPath) result.FilePath = actualPath
if qErr == nil { enrichResultQualityFromFile(&result)
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := buildDownloadSuccessResponse( resp := buildDownloadSuccessResponse(
req, req,
result, result,
@@ -407,14 +495,7 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
quality, qErr := GetAudioQuality(result.FilePath) enrichResultQualityFromFile(&result)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Could not read quality from file: %v\n", qErr)
}
resp := buildDownloadSuccessResponse( resp := buildDownloadSuccessResponse(
req, req,
@@ -488,6 +569,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
AddAllowedDownloadDir(req.OutputDir) AddAllowedDownloadDir(req.OutputDir)
} }
enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz", "amazon"} allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service preferredService := req.Service
if preferredService == "" { if preferredService == "" {
@@ -585,11 +668,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err == nil { if err == nil {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:] actualPath := result.FilePath[7:]
quality, qErr := GetAudioQuality(actualPath) result.FilePath = actualPath
if qErr == nil { enrichResultQualityFromFile(&result)
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := buildDownloadSuccessResponse( resp := buildDownloadSuccessResponse(
req, req,
result, result,
@@ -602,14 +682,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
quality, qErr := GetAudioQuality(result.FilePath) enrichResultQualityFromFile(&result)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Could not read quality from file: %v\n", qErr)
}
resp := buildDownloadSuccessResponse( resp := buildDownloadSuccessResponse(
req, req,
+8 -8
View File
@@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0 go 1.25.0
toolchain go1.25.7 toolchain go1.26.0
require ( require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
@@ -10,8 +10,8 @@ require (
github.com/go-flac/flacvorbis/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4 github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2 github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
golang.org/x/net v0.49.0 golang.org/x/net v0.50.0
) )
require ( require (
@@ -20,10 +20,10 @@ require (
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/compress v1.17.4 // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.42.0 // indirect
) )
+14
View File
@@ -30,20 +30,34 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4= golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+9 -2
View File
@@ -28,6 +28,7 @@ type LibraryScanResult struct {
ReleaseDate string `json:"releaseDate,omitempty"` ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"` BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"` SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"` Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"` Format string `json:"format,omitempty"`
} }
@@ -289,8 +290,11 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetMP3Quality(filePath) quality, err := GetMP3Quality(filePath)
if err == nil { if err == nil {
result.SampleRate = quality.SampleRate result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
} }
if result.TrackName == "" { if result.TrackName == "" {
@@ -326,8 +330,11 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetOggQuality(filePath) quality, err := GetOggQuality(filePath)
if err == nil { if err == nil {
result.SampleRate = quality.SampleRate result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
} }
if result.TrackName == "" { if result.TrackName == "" {
+5
View File
@@ -46,6 +46,11 @@ post_install do |installer|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config| target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
definitions = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
unless definitions.include?('PERMISSION_NOTIFICATIONS=1')
definitions << 'PERMISSION_NOTIFICATIONS=1'
end
end end
end end
end end
+43 -14
View File
@@ -83,15 +83,9 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "downloadTrack": case "downloadByStrategy":
let requestJson = call.arguments as! String let requestJson = call.arguments as! String
let response = GobackendDownloadTrack(requestJson, &error) let response = GobackendDownloadByStrategy(requestJson, &error)
if let error = error { throw error }
return response
case "downloadWithFallback":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithFallback(requestJson, &error)
if let error = error { throw error } if let error = error { throw error }
return response return response
@@ -210,6 +204,41 @@ import Gobackend // Import Go framework
GobackendCleanupConnections() GobackendCleanupConnections()
return nil return nil
case "downloadCoverToFile":
let args = call.arguments as! [String: Any]
let coverURL = args["cover_url"] as! String
let outputPath = args["output_path"] as! String
let maxQuality = args["max_quality"] as? Bool ?? true
GobackendDownloadCoverToFile(coverURL, outputPath, maxQuality, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "extractCoverToFile":
let args = call.arguments as! [String: Any]
let audioPath = args["audio_path"] as! String
let outputPath = args["output_path"] as! String
GobackendExtractCoverToFile(audioPath, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "fetchAndSaveLyrics":
let args = call.arguments as! [String: Any]
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let spotifyId = args["spotify_id"] as! String
let durationMs = args["duration_ms"] as? Int64 ?? 0
let outputPath = args["output_path"] as! String
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "reEnrichFile":
let args = call.arguments as! [String: Any]
let requestJson = args["request_json"] as? String ?? "{}"
let response = GobackendReEnrichFile(requestJson, &error)
if let error = error { throw error }
return response
case "readFileMetadata": case "readFileMetadata":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String let filePath = args["file_path"] as! String
@@ -479,12 +508,6 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "enrichTrackWithExtension": case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String let extensionId = args["extension_id"] as! String
@@ -493,6 +516,12 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "removeExtension": case "removeExtension":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String let extensionId = args["extension_id"] as! String
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.6.5'; static const String version = '3.6.6';
static const String buildNumber = '79'; static const String buildNumber = '80';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+1246 -234
View File
File diff suppressed because it is too large Load Diff
+1276 -11
View File
File diff suppressed because it is too large Load Diff
+1107 -95
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+838 -211
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1041 -29
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1316 -51
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+32 -14
View File
@@ -21,6 +21,7 @@ class AppSettings {
final String folderOrganization; final String folderOrganization;
final bool useAlbumArtistForFolders; final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final bool filterContributingArtistsInAlbumArtist;
final String historyViewMode; final String historyViewMode;
final String historyFilterMode; final String historyFilterMode;
final bool askQualityBeforeDownload; final bool askQualityBeforeDownload;
@@ -36,18 +37,24 @@ class AppSettings {
final bool showExtensionStore; final bool showExtensionStore;
final String locale; final String locale;
final String lyricsMode; final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' final String
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file final bool
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
// Local Library Settings // Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files final String localLibraryPath; // Path to scan for audio files
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
// Tutorial/Onboarding // Tutorial/Onboarding
final bool hasCompletedTutorial; // Track if user has completed the app tutorial final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
const AppSettings({ const AppSettings({
this.defaultService = 'tidal', this.defaultService = 'tidal',
@@ -67,6 +74,7 @@ class AppSettings {
this.folderOrganization = 'none', this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true, this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false, this.usePrimaryArtistOnly = false,
this.filterContributingArtistsInAlbumArtist = false,
this.historyViewMode = 'grid', this.historyViewMode = 'grid',
this.historyFilterMode = 'all', this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true, this.askQualityBeforeDownload = true,
@@ -112,6 +120,7 @@ class AppSettings {
String? folderOrganization, String? folderOrganization,
bool? useAlbumArtistForFolders, bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly, bool? usePrimaryArtistOnly,
bool? filterContributingArtistsInAlbumArtist,
String? historyViewMode, String? historyViewMode,
String? historyFilterMode, String? historyFilterMode,
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
@@ -157,18 +166,25 @@ class AppSettings {
folderOrganization: folderOrganization ?? this.folderOrganization, folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders: useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders, useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly: usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
usePrimaryArtistOnly ?? this.usePrimaryArtistOnly, filterContributingArtistsInAlbumArtist:
filterContributingArtistsInAlbumArtist ??
this.filterContributingArtistsInAlbumArtist,
historyViewMode: historyViewMode ?? this.historyViewMode, historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode, historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, askQualityBeforeDownload:
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId, spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret, spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, useCustomSpotifyCredentials:
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource, metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging, enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, useExtensionProviders:
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider), useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
separateSingles: separateSingles ?? this.separateSingles, separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore, showExtensionStore: showExtensionStore ?? this.showExtensionStore,
@@ -176,12 +192,14 @@ class AppSettings {
lyricsMode: lyricsMode ?? this.lyricsMode, lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode, downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
// Local Library // Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath, localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
// Tutorial // Tutorial
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
); );
+4
View File
@@ -24,6 +24,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
folderOrganization: json['folderOrganization'] as String? ?? 'none', folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true, useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false, usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
filterContributingArtistsInAlbumArtist:
json['filterContributingArtistsInAlbumArtist'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all', historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -72,6 +74,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'folderOrganization': instance.folderOrganization, 'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders, 'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly, 'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
'filterContributingArtistsInAlbumArtist':
instance.filterContributingArtistsInAlbumArtist,
'historyViewMode': instance.historyViewMode, 'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode, 'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
+223 -86
View File
@@ -323,7 +323,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) { if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) {
continue; continue;
} }
if (item.filePath.isEmpty || !isContentUri(item.filePath)) { final hasFilePath = item.filePath.trim().isNotEmpty;
final hasSafFileName =
item.safFileName != null && item.safFileName!.trim().isNotEmpty;
if (!hasFilePath && !hasSafFileName) {
continue; continue;
} }
candidateIndexes.add(i); candidateIndexes.add(i);
@@ -344,52 +347,59 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
for (var c = 0; c < candidateIndexes.length; c++) { for (var c = 0; c < candidateIndexes.length; c++) {
final i = candidateIndexes[c]; final i = candidateIndexes[c];
final item = items[i]; final item = items[i];
final rawPath = item.filePath.trim();
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
final exists = await fileExists(item.filePath); if (isDirectSafUri) {
if (exists) { final exists = await fileExists(rawPath);
final verified = item.copyWith( if (exists) {
safRepaired: true, final verified = item.copyWith(
safFileName: item.safFileName ?? _fileNameFromUri(item.filePath), safRepaired: true,
); safFileName: item.safFileName ?? _fileNameFromUri(rawPath),
updatedItems[i] = verified; );
changed = true; updatedItems[i] = verified;
verifiedCount++; changed = true;
await _db.upsert(verified.toJson()); verifiedCount++;
} else { await _db.upsert(verified.toJson());
final fallbackName =
item.safFileName ?? _fileNameFromUri(item.filePath);
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
continue; continue;
} }
}
try { var fallbackName = (item.safFileName ?? '').trim();
final resolved = await PlatformBridge.resolveSafFile( if (fallbackName.isEmpty && isDirectSafUri) {
treeUri: item.downloadTreeUri!, fallbackName = _fileNameFromUri(rawPath);
relativeDir: item.safRelativeDir ?? '', }
fileName: fallbackName, if (fallbackName.isEmpty) {
); _historyLog.w('Missing SAF filename for history item: ${item.id}');
final newUri = resolved['uri'] as String? ?? ''; continue;
if (newUri.isEmpty) continue; }
final newRelativeDir = resolved['relative_dir'] as String?; try {
final updated = item.copyWith( final resolved = await PlatformBridge.resolveSafFile(
filePath: newUri, treeUri: item.downloadTreeUri!,
safRelativeDir: relativeDir: item.safRelativeDir ?? '',
(newRelativeDir != null && newRelativeDir.isNotEmpty) fileName: fallbackName,
? newRelativeDir );
: item.safRelativeDir, final newUri = (resolved['uri'] as String? ?? '').trim();
safFileName: fallbackName, if (newUri.isEmpty) continue;
safRepaired: true,
);
updatedItems[i] = updated; final newRelativeDir = resolved['relative_dir'] as String?;
changed = true; final updated = item.copyWith(
repairedCount++; filePath: newUri,
await _db.upsert(updated.toJson()); safRelativeDir:
} catch (e) { (newRelativeDir != null && newRelativeDir.isNotEmpty)
_historyLog.w('Failed to repair SAF URI: $e'); ? newRelativeDir
} : item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
updatedItems[i] = updated;
changed = true;
repairedCount++;
await _db.upsert(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
} }
if ((c + 1) % _safRepairBatchSize == 0) { if ((c + 1) % _safRepairBatchSize == 0) {
@@ -421,19 +431,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
existing = state.getByIsrc(item.isrc!); existing = state.getByIsrc(item.isrc!);
} }
final mergedItem = existing == null
? item
: item.copyWith(
genre:
_normalizeOptionalString(item.genre) ??
_normalizeOptionalString(existing.genre),
label:
_normalizeOptionalString(item.label) ??
_normalizeOptionalString(existing.label),
copyright:
_normalizeOptionalString(item.copyright) ??
_normalizeOptionalString(existing.copyright),
);
if (existing != null) { if (existing != null) {
final updatedItems = state.items final updatedItems = state.items
.where((i) => i.id != existing!.id) .where((i) => i.id != existing!.id)
.toList(); .toList();
updatedItems.insert(0, item); updatedItems.insert(0, mergedItem);
state = state.copyWith(items: updatedItems); state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}'); _historyLog.d('Updated existing history entry: ${mergedItem.trackName}');
} else { } else {
state = state.copyWith(items: [item, ...state.items]); state = state.copyWith(items: [mergedItem, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}'); _historyLog.d('Added new history entry: ${mergedItem.trackName}');
} }
_db.upsert(item.toJson()).catchError((e) { _db.upsert(mergedItem.toJson()).catchError((e) {
_historyLog.e('Failed to save to database: $e'); _historyLog.e('Failed to save to database: $e');
}); });
} }
@@ -1173,11 +1197,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true, bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false, bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
}) async { }) async {
String baseDir = state.outputDir; String baseDir = state.outputDir;
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName ? normalizedAlbumArtist ?? track.artistName
: track.artistName; : track.artistName;
if (useAlbumArtistForFolders &&
filterContributingArtistsInAlbumArtist &&
normalizedAlbumArtist != null) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (usePrimaryArtistOnly) { if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist); folderArtist = _extractPrimaryArtist(folderArtist);
} }
@@ -1285,6 +1316,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return artist; return artist;
} }
String _resolveAlbumArtistForMetadata(Track track, AppSettings settings) {
var albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
if (settings.filterContributingArtistsInAlbumArtist) {
albumArtist = _extractPrimaryArtist(albumArtist);
}
return albumArtist;
}
bool _isSafMode(AppSettings settings) { bool _isSafMode(AppSettings settings) {
return Platform.isAndroid && return Platform.isAndroid &&
settings.storageMode == 'saf' && settings.storageMode == 'saf' &&
@@ -1309,10 +1349,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true, bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false, bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
}) async { }) async {
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName ? normalizedAlbumArtist ?? track.artistName
: track.artistName; : track.artistName;
if (useAlbumArtistForFolders &&
filterContributingArtistsInAlbumArtist &&
normalizedAlbumArtist != null) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (usePrimaryArtistOnly) { if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist); folderArtist = _extractPrimaryArtist(folderArtist);
} }
@@ -1728,6 +1775,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider); final extensionState = ref.read(extensionProvider);
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
track,
settings,
);
if (!settings.useExtensionProviders) return; if (!settings.useExtensionProviders) return;
@@ -1742,8 +1793,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'title': track.name, 'title': track.name,
'artist': track.artistName, 'artist': track.artistName,
'album': track.albumName, 'album': track.albumName,
'album_artist': 'album_artist': resolvedAlbumArtist,
_normalizeOptionalString(track.albumArtist) ?? track.artistName,
'track_number': track.trackNumber ?? 1, 'track_number': track.trackNumber ?? 1,
'disc_number': track.discNumber ?? 1, 'disc_number': track.discNumber ?? 1,
'isrc': track.isrc ?? '', 'isrc': track.isrc ?? '',
@@ -1803,7 +1853,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Track _buildTrackForMetadataEmbedding( Track _buildTrackForMetadataEmbedding(
Track baseTrack, Track baseTrack,
Map<String, dynamic> backendResult, Map<String, dynamic> backendResult,
String? normalizedAlbumArtist, String resolvedAlbumArtist,
) { ) {
final backendTrackNum = _parsePositiveInt(backendResult['track_number']); final backendTrackNum = _parsePositiveInt(backendResult['track_number']);
final backendDiscNum = _parsePositiveInt(backendResult['disc_number']); final backendDiscNum = _parsePositiveInt(backendResult['disc_number']);
@@ -1826,7 +1876,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
name: baseTrack.name, name: baseTrack.name,
artistName: baseTrack.artistName, artistName: baseTrack.artistName,
albumName: backendAlbum ?? baseTrack.albumName, albumName: backendAlbum ?? baseTrack.albumName,
albumArtist: normalizedAlbumArtist, albumArtist: resolvedAlbumArtist,
coverUrl: baseTrack.coverUrl, coverUrl: baseTrack.coverUrl,
duration: baseTrack.duration, duration: baseTrack.duration,
isrc: baseTrack.isrc, isrc: baseTrack.isrc,
@@ -1890,16 +1940,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName, 'ALBUM': track.albumName,
}; };
final albumArtist = final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
metadata['ALBUMARTIST'] = albumArtist; metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) { if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString(); metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString(); metadata['TRACK'] = track.trackNumber.toString();
} }
if (track.discNumber != null) { if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString(); metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString(); metadata['DISC'] = track.discNumber.toString();
} }
@@ -2033,16 +2082,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName, 'ALBUM': track.albumName,
}; };
final albumArtist = final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
metadata['ALBUMARTIST'] = albumArtist; metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) { if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString(); metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString(); metadata['TRACK'] = track.trackNumber.toString();
} }
if (track.discNumber != null) { if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString(); metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString(); metadata['DISC'] = track.discNumber.toString();
} }
@@ -2198,15 +2246,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName, 'ALBUM': track.albumName,
}; };
final albumArtist = final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
metadata['ALBUMARTIST'] = albumArtist; metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) { if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString(); metadata['TRACKNUMBER'] = track.trackNumber.toString();
} }
if (track.discNumber != null) { if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString(); metadata['DISCNUMBER'] = track.discNumber.toString();
} }
@@ -2442,6 +2489,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await musicDir.create(recursive: true); await musicDir.create(recursive: true);
} }
state = state.copyWith(outputDir: musicDir.path); state = state.copyWith(outputDir: musicDir.path);
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
} else if (!isValidIosWritablePath(state.outputDir)) { } else if (!isValidIosWritablePath(state.outputDir)) {
// Check for other invalid paths (like container root without Documents/) // Check for other invalid paths (like container root without Documents/)
_log.w( _log.w(
@@ -2451,6 +2499,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final correctedPath = await validateOrFixIosPath(state.outputDir); final correctedPath = await validateOrFixIosPath(state.outputDir);
_log.i('Corrected path: $correctedPath'); _log.i('Corrected path: $correctedPath');
state = state.copyWith(outputDir: correctedPath); state = state.copyWith(outputDir: correctedPath);
ref.read(settingsProvider.notifier).setDownloadDirectory(correctedPath);
} }
} }
@@ -2717,8 +2766,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}'); _log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
final normalizedAlbumArtist = _normalizeOptionalString( final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
trackToDownload.albumArtist, trackToDownload,
settings,
); );
final quality = item.qualityOverride ?? state.audioQuality; final quality = item.qualityOverride ?? state.audioQuality;
@@ -2731,6 +2781,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders, useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly, usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
) )
: ''; : '';
String? appOutputDir; String? appOutputDir;
@@ -2743,6 +2795,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders, useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly, usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
); );
var effectiveOutputDir = initialOutputDir; var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode; var effectiveSafMode = isSafMode;
@@ -2768,6 +2822,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? genre; String? genre;
String? label; String? label;
String? copyright;
String? deezerTrackId = trackToDownload.deezerId; String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
@@ -2845,9 +2900,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(trackToDownload.isrc == null && deezerIsrc != null) || (trackToDownload.isrc == null && deezerIsrc != null) ||
(!_isValidISRC(trackToDownload.isrc ?? '') && (!_isValidISRC(trackToDownload.isrc ?? '') &&
deezerIsrc != null) || deezerIsrc != null) ||
(trackToDownload.trackNumber == null && ((trackToDownload.trackNumber == null ||
deezerTrackNum != null) || trackToDownload.trackNumber! <= 0) &&
(trackToDownload.discNumber == null && deezerDiscNum != null); deezerTrackNum != null &&
deezerTrackNum > 0) ||
((trackToDownload.discNumber == null ||
trackToDownload.discNumber! <= 0) &&
deezerDiscNum != null &&
deezerDiscNum > 0);
if (needsEnrich) { if (needsEnrich) {
trackToDownload = Track( trackToDownload = Track(
@@ -2861,8 +2921,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc ? deezerIsrc
: trackToDownload.isrc, : trackToDownload.isrc,
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum, trackNumber:
discNumber: trackToDownload.discNumber ?? deezerDiscNum, (trackToDownload.trackNumber != null &&
trackToDownload.trackNumber! > 0)
? trackToDownload.trackNumber
: deezerTrackNum,
discNumber:
(trackToDownload.discNumber != null &&
trackToDownload.discNumber! > 0)
? trackToDownload.discNumber
: deezerDiscNum,
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate, releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
deezerId: deezerTrackId, deezerId: deezerTrackId,
availability: trackToDownload.availability, availability: trackToDownload.availability,
@@ -2889,8 +2957,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (extendedMetadata != null) { if (extendedMetadata != null) {
genre = extendedMetadata['genre']; genre = extendedMetadata['genre'];
label = extendedMetadata['label']; label = extendedMetadata['label'];
copyright = extendedMetadata['copyright'];
if (genre != null && genre.isNotEmpty) { if (genre != null && genre.isNotEmpty) {
_log.d('Extended metadata - Genre: $genre, Label: $label'); _log.d(
'Extended metadata - Genre: $genre, Label: $label, Copyright: $copyright',
);
} }
} }
} catch (e) { } catch (e) {
@@ -2937,6 +3008,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
_log.d('Output dir: $outputDir'); _log.d('Output dir: $outputDir');
final normalizedTrackNumber =
(trackToDownload.trackNumber != null &&
trackToDownload.trackNumber! > 0)
? trackToDownload.trackNumber!
: 1;
final normalizedDiscNumber =
(trackToDownload.discNumber != null &&
trackToDownload.discNumber! > 0)
? trackToDownload.discNumber!
: 1;
final payload = DownloadRequestPayload( final payload = DownloadRequestPayload(
isrc: trackToDownload.isrc ?? '', isrc: trackToDownload.isrc ?? '',
service: item.service, service: item.service,
@@ -2944,7 +3026,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name, trackName: trackToDownload.name,
artistName: trackToDownload.artistName, artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName, albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist ?? trackToDownload.artistName, albumArtist: resolvedAlbumArtist,
coverUrl: trackToDownload.coverUrl ?? '', coverUrl: trackToDownload.coverUrl ?? '',
outputDir: outputDir, outputDir: outputDir,
filenameFormat: state.filenameFormat, filenameFormat: state.filenameFormat,
@@ -2952,14 +3034,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Keep prior behavior: non-YouTube paths were implicitly true. // Keep prior behavior: non-YouTube paths were implicitly true.
embedLyrics: isYouTube ? settings.embedLyrics : true, embedLyrics: isYouTube ? settings.embedLyrics : true,
embedMaxQualityCover: settings.maxQualityCover, embedMaxQualityCover: settings.maxQualityCover,
trackNumber: trackToDownload.trackNumber ?? 1, trackNumber: normalizedTrackNumber,
discNumber: trackToDownload.discNumber ?? 1, discNumber: normalizedDiscNumber,
releaseDate: trackToDownload.releaseDate ?? '', releaseDate: trackToDownload.releaseDate ?? '',
itemId: item.id, itemId: item.id,
durationMs: trackToDownload.duration, durationMs: trackToDownload.duration,
source: trackToDownload.source ?? '', source: trackToDownload.source ?? '',
genre: genre ?? '', genre: genre ?? '',
label: label ?? '', label: label ?? '',
copyright: copyright ?? '',
deezerId: deezerTrackId ?? '', deezerId: deezerTrackId ?? '',
lyricsMode: settings.lyricsMode, lyricsMode: settings.lyricsMode,
storageMode: storageMode, storageMode: storageMode,
@@ -2992,6 +3075,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders, useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly, usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
); );
final fallbackResult = await runDownload( final fallbackResult = await runDownload(
useSaf: false, useSaf: false,
@@ -3329,7 +3414,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding( final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload, trackToDownload,
result, result,
normalizedAlbumArtist, resolvedAlbumArtist,
); );
final backendGenre = result['genre'] as String?; final backendGenre = result['genre'] as String?;
@@ -3493,7 +3578,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding( final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload, trackToDownload,
result, result,
normalizedAlbumArtist, resolvedAlbumArtist,
); );
final backendGenre = result['genre'] as String?; final backendGenre = result['genre'] as String?;
@@ -3553,7 +3638,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding( final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload, trackToDownload,
result, result,
normalizedAlbumArtist, resolvedAlbumArtist,
); );
final backendGenre = result['genre'] as String?; final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?; final backendLabel = result['label'] as String?;
@@ -3613,7 +3698,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding( final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload, trackToDownload,
result, result,
normalizedAlbumArtist, resolvedAlbumArtist,
); );
final backendGenre = result['genre'] as String?; final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?; final backendLabel = result['label'] as String?;
@@ -3650,7 +3735,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding( final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload, trackToDownload,
result, result,
normalizedAlbumArtist, resolvedAlbumArtist,
); );
final backendGenre = result['genre'] as String?; final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?; final backendLabel = result['label'] as String?;
@@ -3748,6 +3833,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return; 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.
if (effectiveSafMode &&
filePath != null &&
filePath.isNotEmpty &&
!isContentUri(filePath) &&
settings.downloadTreeUri.isNotEmpty) {
final fallbackName = (finalSafFileName ?? safFileName ?? '').trim();
if (fallbackName.isNotEmpty) {
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: fallbackName,
);
final resolvedUri = (resolved['uri'] as String? ?? '').trim();
final resolvedRelativeDir =
(resolved['relative_dir'] as String? ?? '').trim();
if (resolvedUri.isNotEmpty && isContentUri(resolvedUri)) {
_log.w('Recovered SAF URI from transient path: $filePath');
filePath = resolvedUri;
finalSafFileName = fallbackName;
if (resolvedRelativeDir.isNotEmpty) {
effectiveOutputDir = resolvedRelativeDir;
}
} else {
_log.w(
'Failed to recover SAF URI (fileName=$fallbackName, dir=$effectiveOutputDir)',
);
}
} catch (e) {
_log.w('SAF URI recovery failed: $e');
}
} else {
_log.w(
'SAF download returned non-URI path without filename metadata: $filePath',
);
}
}
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.completed, DownloadStatus.completed,
@@ -3840,13 +3966,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendGenre = result['genre'] as String?; final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?; final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?; final backendCopyright = result['copyright'] as String?;
final effectiveGenre =
_normalizeOptionalString(backendGenre) ??
_normalizeOptionalString(genre) ??
_normalizeOptionalString(existingInHistory?.genre);
final effectiveLabel =
_normalizeOptionalString(backendLabel) ??
_normalizeOptionalString(label) ??
_normalizeOptionalString(existingInHistory?.label);
final effectiveCopyright =
_normalizeOptionalString(backendCopyright) ??
_normalizeOptionalString(copyright) ??
_normalizeOptionalString(existingInHistory?.copyright);
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final historyAlbumArtist = final historyAlbumArtist =
(normalizedAlbumArtist != null && resolvedAlbumArtist != trackToDownload.artistName
normalizedAlbumArtist != trackToDownload.artistName) ? resolvedAlbumArtist
? normalizedAlbumArtist
: null; : null;
final isMp3 = filePath.endsWith('.mp3'); final isMp3 = filePath.endsWith('.mp3');
@@ -3899,9 +4036,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
quality: actualQuality, quality: actualQuality,
bitDepth: historyBitDepth, bitDepth: historyBitDepth,
sampleRate: historySampleRate, sampleRate: historySampleRate,
genre: backendGenre, genre: effectiveGenre,
label: backendLabel, label: effectiveLabel,
copyright: backendCopyright, copyright: effectiveCopyright,
), ),
); );
+169 -4
View File
@@ -3,8 +3,10 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart'; 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/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
@@ -116,6 +118,7 @@ class LocalLibraryState {
class LocalLibraryNotifier extends Notifier<LocalLibraryState> { class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance; final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance;
final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 800); static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer; Timer? _progressTimer;
bool _isLoaded = false; bool _isLoaded = false;
@@ -180,6 +183,58 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase(); await _loadFromDatabase();
} }
Set<String> _buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
final keys = <String>{cleaned};
void addNormalized(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
keys.add(trimmed);
keys.add(trimmed.toLowerCase());
if (trimmed.contains('\\')) {
final slash = trimmed.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
if (trimmed.contains('%')) {
try {
final decoded = Uri.decodeFull(trimmed);
keys.add(decoded);
keys.add(decoded.toLowerCase());
} catch (_) {}
}
}
addNormalized(cleaned);
if (cleaned.startsWith('content://')) {
try {
final uri = Uri.parse(cleaned);
addNormalized(uri.toString());
addNormalized(uri.replace(query: null, fragment: null).toString());
} catch (_) {}
}
return keys;
}
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false;
}
final candidateKeys = _buildPathMatchKeys(filePath);
for (final key in candidateKeys) {
if (downloadedPathKeys.contains(key)) {
return true;
}
}
return false;
}
Future<void> startScan( Future<void> startScan(
String folderPath, { String folderPath, {
bool forceFullScan = false, bool forceFullScan = false,
@@ -202,6 +257,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanErrorCount: 0, scanErrorCount: 0,
scanWasCancelled: false, scanWasCancelled: false,
); );
await _showScanProgressNotification(
progress: 0,
scannedFiles: 0,
totalFiles: 0,
currentFile: null,
);
try { try {
final appSupportDir = await getApplicationSupportDirectory(); final appSupportDir = await getApplicationSupportDirectory();
@@ -217,10 +278,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final isSaf = folderPath.startsWith('content://'); final isSaf = folderPath.startsWith('content://');
// Get all file paths from download history to exclude them // Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
// been flushed to SQLite yet.
final downloadedPaths = await _historyDb.getAllFilePaths(); final downloadedPaths = await _historyDb.getAllFilePaths();
final inMemoryHistoryPaths = ref
.read(downloadHistoryProvider)
.items
.map((item) => item.filePath)
.where((path) => path.isNotEmpty);
final allHistoryPaths = <String>{
...downloadedPaths,
...inMemoryHistoryPaths,
};
final downloadedPathKeys = <String>{};
for (final path in allHistoryPaths) {
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
}
_log.i( _log.i(
'Excluding ${downloadedPaths.length} downloaded files from library scan', 'Excluding ${allHistoryPaths.length} downloaded files from library scan '
'(${downloadedPathKeys.length} path keys)',
); );
if (forceFullScan) { if (forceFullScan) {
@@ -230,6 +307,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
: await PlatformBridge.scanLibraryFolder(folderPath); : await PlatformBridge.scanLibraryFolder(folderPath);
if (_scanCancelRequested) { if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return; return;
} }
@@ -238,7 +316,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in results) { for (final json in results) {
final filePath = json['filePath'] as String?; final filePath = json['filePath'] as String?;
// Skip files that are already in download history // Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) { if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++; skippedDownloads++;
continue; continue;
} }
@@ -275,6 +353,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'Full scan complete: ${items.length} tracks found, ' 'Full scan complete: ${items.length} tracks found, '
'$skippedDownloads already in downloads', '$skippedDownloads already in downloads',
); );
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
} else { } else {
// Incremental scan path - only scans new/modified files // Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes(); final existingFiles = await _db.getFileModTimes();
@@ -308,6 +391,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_scanCancelRequested) { if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return; return;
} }
@@ -344,7 +428,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in scannedList) { for (final json in scannedList) {
final map = json as Map<String, dynamic>; final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?; final filePath = map['filePath'] as String?;
if (filePath != null && downloadedPaths.contains(filePath)) { if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++; skippedDownloads++;
continue; continue;
} }
@@ -399,10 +483,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'(${scannedList.length} new/updated, $skippedCount unchanged, ' '(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)', '${deletedPaths.length} removed, $skippedDownloads already in downloads)',
); );
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
} }
} catch (e, stack) { } catch (e, stack) {
_log.e('Library scan failed: $e', e, stack); _log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false, scanWasCancelled: false); state = state.copyWith(isScanning: false, scanWasCancelled: false);
await _showScanFailedNotification(e.toString());
} finally { } finally {
_stopProgressPolling(); _stopProgressPolling();
} }
@@ -441,6 +531,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scannedFiles: scannedFiles, scannedFiles: scannedFiles,
scanErrorCount: errorCount, scanErrorCount: errorCount,
); );
await _showScanProgressNotification(
progress: normalizedProgress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: currentFile,
);
} }
if (progress['is_complete'] == true) { if (progress['is_complete'] == true) {
@@ -473,6 +569,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await PlatformBridge.cancelLibraryScan(); await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(isScanning: false, scanWasCancelled: true);
_stopProgressPolling(); _stopProgressPolling();
await _showScanCancelledNotification();
}
Future<void> _showScanProgressNotification({
required double progress,
required int scannedFiles,
required int totalFiles,
required String? currentFile,
}) async {
try {
await _notificationService.showLibraryScanProgress(
progress: progress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: _shortenFileForNotification(currentFile),
);
} catch (e) {
_log.w('Failed to show scan progress notification: $e');
}
}
Future<void> _showScanCompleteNotification({
required int totalTracks,
required int excludedDownloadedCount,
required int errorCount,
}) async {
try {
await _notificationService.showLibraryScanComplete(
totalTracks: totalTracks,
excludedDownloadedCount: excludedDownloadedCount,
errorCount: errorCount,
);
} catch (e) {
_log.w('Failed to show scan complete notification: $e');
}
}
Future<void> _showScanFailedNotification(String message) async {
try {
await _notificationService.showLibraryScanFailed(message);
} catch (e) {
_log.w('Failed to show scan failure notification: $e');
}
}
Future<void> _showScanCancelledNotification() async {
try {
await _notificationService.showLibraryScanCancelled();
} catch (e) {
_log.w('Failed to show scan cancelled notification: $e');
}
}
String? _shortenFileForNotification(String? path) {
final raw = path?.trim() ?? '';
if (raw.isEmpty) return null;
var decoded = raw;
try {
decoded = Uri.decodeFull(raw);
} catch (_) {}
final slashIdx = decoded.lastIndexOf('/');
final backslashIdx = decoded.lastIndexOf('\\');
final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx;
if (cut >= 0 && cut < decoded.length - 1) {
return decoded.substring(cut + 1);
}
return decoded;
} }
Future<int> cleanupMissingFiles() async { Future<int> cleanupMissingFiles() async {
+5
View File
@@ -236,6 +236,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setFilterContributingArtistsInAlbumArtist(bool enabled) {
state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) { void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode); state = state.copyWith(historyViewMode: mode);
_saveSettings(); _saveSettings();
+15 -1
View File
@@ -560,7 +560,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
String? _computeCommonQuality(List<LocalLibraryItem> tracks) { String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null; if (tracks.isEmpty) return null;
final first = tracks.first; final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
// For lossy formats, use bitrate
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
for (final track in tracks) {
if (track.bitrate != firstBitrate) {
return null;
}
}
return '$fmt ${firstBitrate}kbps'.trim();
}
// For lossless formats, use bit depth / sample rate
if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null;
final firstQuality = final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz'; '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
+10 -2
View File
@@ -70,7 +70,12 @@ class UnifiedLibraryItem {
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) { factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
String? quality; String? quality;
if (item.bitDepth != null && item.sampleRate != null) { if (item.bitrate != null && item.bitrate! > 0) {
// Lossy format with bitrate
final fmt = item.format?.toUpperCase() ?? '';
quality = '$fmt ${item.bitrate}kbps'.trim();
} else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) {
// Lossless format with actual bit depth
quality = quality =
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
} }
@@ -897,7 +902,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
String? _localQualityLabel(LocalLibraryItem item) { String? _localQualityLabel(LocalLibraryItem item) {
if (item.bitDepth == null || item.sampleRate == null) { if (item.bitrate != null && item.bitrate! > 0) {
return '${item.bitrate}kbps';
}
if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) {
return null; return null;
} }
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
@@ -25,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon']; static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
int _androidSdkVersion = 0; int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false; bool _hasAllFilesAccess = false;
bool _artistFolderFiltersExpanded = false;
@override @override
void initState() { void initState() {
@@ -363,19 +364,53 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onChanged: (value) => ref onChanged: (value) => ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value), .setUseAlbumArtistForFolders(value),
showDivider: false, ),
SettingsItem(
icon: Icons.filter_alt_outlined,
title: 'Artist Name Filters',
subtitle: _getArtistFolderFilterSubtitle(
context,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterAlbumArtistContributors:
settings.filterContributingArtistsInAlbumArtist,
), ),
SettingsSwitchItem( trailing: Icon(
icon: Icons.person_outline, _artistFolderFiltersExpanded
title: context.l10n.downloadUsePrimaryArtistOnly, ? Icons.expand_less
subtitle: settings.usePrimaryArtistOnly : Icons.expand_more,
? context.l10n.downloadUsePrimaryArtistOnlyEnabled ),
: context.l10n.downloadUsePrimaryArtistOnlyDisabled, onTap: () {
value: settings.usePrimaryArtistOnly, setState(() {
onChanged: (value) => ref _artistFolderFiltersExpanded =
.read(settingsProvider.notifier) !_artistFolderFiltersExpanded;
.setUsePrimaryArtistOnly(value), });
showDivider: false, },
showDivider: !_artistFolderFiltersExpanded,
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.person_outline,
title: context.l10n.downloadUsePrimaryArtistOnly,
subtitle: settings.usePrimaryArtistOnly
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
value: settings.usePrimaryArtistOnly,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsePrimaryArtistOnly(value),
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.group_remove_outlined,
title: 'Filter contributing artists in Album Artist',
subtitle: settings.filterContributingArtistsInAlbumArtist
? 'Album Artist metadata uses primary artist only'
: 'Keep full Album Artist metadata value',
value: settings.filterContributingArtistsInAlbumArtist,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setFilterContributingArtistsInAlbumArtist(value),
showDivider: false,
), ),
], ],
), ),
@@ -937,7 +972,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) { if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar( ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar( SnackBar(
content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported), content: Text(
validation.errorReason ??
context.l10n.setupIcloudNotSupported,
),
backgroundColor: Theme.of(ctx).colorScheme.error, backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
@@ -1000,6 +1038,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
} }
} }
String _getArtistFolderFilterSubtitle(
BuildContext context, {
required bool usePrimaryArtistOnly,
required bool filterAlbumArtistContributors,
}) {
final statuses = <String>[
usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off',
filterAlbumArtistContributors
? 'Album Artist metadata: Primary only'
: 'Album Artist metadata: Full',
];
return statuses.join(' | ');
}
String _getLyricsModeLabel(BuildContext context, String mode) { String _getLyricsModeLabel(BuildContext context, String mode) {
switch (mode) { switch (mode) {
case 'external': case 'external':
@@ -1456,9 +1508,7 @@ class _ServiceChip extends StatelessWidget {
return Expanded( return Expanded(
child: Material( child: Material(
color: isSelected color: isSelected ? colorScheme.primaryContainer : unselectedColor,
? colorScheme.primaryContainer
: unselectedColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
+11 -2
View File
@@ -68,10 +68,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _checkInitialPermissions() async { Future<void> _checkInitialPermissions() async {
if (Platform.isIOS) { if (Platform.isIOS) {
final notificationStatus = await Permission.notification.status;
if (mounted) { if (mounted) {
setState(() { setState(() {
_storagePermissionGranted = true; _storagePermissionGranted = true;
_notificationPermissionGranted = true; _notificationPermissionGranted =
notificationStatus.isGranted || notificationStatus.isProvisional;
}); });
} }
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
@@ -181,7 +183,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _requestNotificationPermission() async { Future<void> _requestNotificationPermission() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
if (_androidSdkVersion >= 33) { if (Platform.isIOS) {
final status = await Permission.notification.request();
if (status.isGranted || status.isProvisional) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog('Notification');
}
} else if (_androidSdkVersion >= 33) {
final status = await Permission.notification.request(); final status = await Permission.notification.request();
if (status.isGranted) { if (status.isGranted) {
setState(() => _notificationPermissionGranted = true); setState(() => _notificationPermissionGranted = true);
+94 -8
View File
@@ -416,6 +416,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth; _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
int? get sampleRate => int? get sampleRate =>
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate; _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
String get _filePath => String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
@@ -424,8 +425,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.coverPath : null; _isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
String get _service => _isLocalItem ? 'local' : _downloadItem!.service; String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt => DateTime get _addedAt {
_isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt; if (_isLocalItem) {
// Use file modification time if available, otherwise fall back to scannedAt
final modTime = _localLibraryItem!.fileModTime;
if (modTime != null && modTime > 0) {
return DateTime.fromMillisecondsSinceEpoch(modTime);
}
return _localLibraryItem!.scannedAt;
}
return _downloadItem!.downloadedAt;
}
String? get _quality => _isLocalItem ? null : _downloadItem!.quality; String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
String get cleanFilePath { String get cleanFilePath {
@@ -433,6 +444,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return path.startsWith('EXISTS:') ? path.substring(7) : path; return path.startsWith('EXISTS:') ? path.substring(7) : path;
} }
String _formatPathForDisplay(String pathOrUri) {
if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) {
return pathOrUri;
}
try {
final uri = Uri.parse(pathOrUri);
final segments = uri.pathSegments;
String? documentId;
final documentIndex = segments.indexOf('document');
if (documentIndex != -1 && documentIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[documentIndex + 1]);
}
if (documentId == null || documentId.isEmpty) {
final treeIndex = segments.indexOf('tree');
if (treeIndex != -1 && treeIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[treeIndex + 1]);
}
}
if (documentId == null || documentId.isEmpty) return pathOrUri;
final separatorIndex = documentId.indexOf(':');
if (separatorIndex <= 0) return documentId;
final volumeId = documentId.substring(0, separatorIndex);
final relativePath = documentId
.substring(separatorIndex + 1)
.replaceAll('\\', '/');
if (volumeId.toLowerCase() == 'primary') {
if (relativePath.isEmpty) return '/storage/emulated/0';
return '/storage/emulated/0/$relativePath';
}
if (relativePath.isEmpty) return volumeId;
return 'SD Card/$relativePath';
} catch (_) {
return pathOrUri;
}
}
void _markMetadataChanged() { void _markMetadataChanged() {
_hasMetadataChanges = true; _hasMetadataChanges = true;
} }
@@ -913,7 +968,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string - prefer stored quality from download // Determine audio quality string - prefer stored quality from download
String? audioQualityStr; String? audioQualityStr;
final fileName = _filePath.split('/').last; final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExt = fileName.contains('.') final fileExt = fileName.contains('.')
? fileName.split('.').last.toUpperCase() ? fileName.split('.').last.toUpperCase()
: ''; : '';
@@ -921,8 +976,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Use stored quality from download history if available // Use stored quality from download history if available
if (_quality != null && _quality!.isNotEmpty) { if (_quality != null && _quality!.isNotEmpty) {
audioQualityStr = _quality; audioQualityStr = _quality;
} else if (bitDepth != null && sampleRate != null) { } else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) {
// Fallback for FLAC files without stored quality // Lossy local file with bitrate info
final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt;
audioQualityStr = '$fmt ${_localBitrate}kbps';
} else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) {
// Lossless file with actual bit depth (FLAC, ALAC)
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1); final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz'; audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
} else { } else {
@@ -1031,7 +1090,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool fileExists, bool fileExists,
int? fileSize, int? fileSize,
) { ) {
final fileName = cleanFilePath.split(Platform.pathSeparator).last; final displayFilePath = _formatPathForDisplay(cleanFilePath);
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExtension = fileName.contains('.') final fileExtension = fileName.contains('.')
? fileName.split('.').last.toUpperCase() ? fileName.split('.').last.toUpperCase()
: 'Unknown'; : 'Unknown';
@@ -1128,7 +1188,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
) )
else if (bitDepth != null && sampleRate != null) else if (_isLocalItem &&
_localBitrate != null &&
_localBitrate! > 0 &&
(fileExtension == 'MP3' ||
fileExtension == 'OPUS' ||
fileExtension == 'OGG'))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_localBitrate}kbps',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
)
else if (bitDepth != null &&
bitDepth! > 0 &&
sampleRate != null)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
@@ -1194,7 +1280,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
cleanFilePath, displayFilePath,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace', fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
+14 -1
View File
@@ -23,6 +23,7 @@ class LocalLibraryItem {
final String? releaseDate; final String? releaseDate;
final int? bitDepth; final int? bitDepth;
final int? sampleRate; final int? sampleRate;
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
final String? genre; final String? genre;
final String? format; // flac, mp3, opus, m4a final String? format; // flac, mp3, opus, m4a
@@ -43,6 +44,7 @@ class LocalLibraryItem {
this.releaseDate, this.releaseDate,
this.bitDepth, this.bitDepth,
this.sampleRate, this.sampleRate,
this.bitrate,
this.genre, this.genre,
this.format, this.format,
}); });
@@ -64,6 +66,7 @@ class LocalLibraryItem {
'releaseDate': releaseDate, 'releaseDate': releaseDate,
'bitDepth': bitDepth, 'bitDepth': bitDepth,
'sampleRate': sampleRate, 'sampleRate': sampleRate,
'bitrate': bitrate,
'genre': genre, 'genre': genre,
'format': format, 'format': format,
}; };
@@ -86,6 +89,7 @@ class LocalLibraryItem {
releaseDate: json['releaseDate'] as String?, releaseDate: json['releaseDate'] as String?,
bitDepth: json['bitDepth'] as int?, bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?, sampleRate: json['sampleRate'] as int?,
bitrate: (json['bitrate'] as num?)?.toInt(),
genre: json['genre'] as String?, genre: json['genre'] as String?,
format: json['format'] as String?, format: json['format'] as String?,
); );
@@ -115,7 +119,7 @@ class LibraryDatabase {
return await openDatabase( return await openDatabase(
path, path,
version: 3, // Bumped version for file_mod_time migration version: 4, // Bumped version for bitrate column
onCreate: _createDB, onCreate: _createDB,
onUpgrade: _upgradeDB, onUpgrade: _upgradeDB,
); );
@@ -142,6 +146,7 @@ class LibraryDatabase {
release_date TEXT, release_date TEXT,
bit_depth INTEGER, bit_depth INTEGER,
sample_rate INTEGER, sample_rate INTEGER,
bitrate INTEGER,
genre TEXT, genre TEXT,
format TEXT format TEXT
) )
@@ -169,6 +174,12 @@ class LibraryDatabase {
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER'); await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
_log.i('Added file_mod_time column for incremental scanning'); _log.i('Added file_mod_time column for incremental scanning');
} }
if (oldVersion < 4) {
// Add bitrate column for lossy format quality info
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
_log.i('Added bitrate column for lossy format quality');
}
} }
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) { Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
@@ -189,6 +200,7 @@ class LibraryDatabase {
'release_date': json['releaseDate'], 'release_date': json['releaseDate'],
'bit_depth': json['bitDepth'], 'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'], 'sample_rate': json['sampleRate'],
'bitrate': json['bitrate'],
'genre': json['genre'], 'genre': json['genre'],
'format': json['format'], 'format': json['format'],
}; };
@@ -212,6 +224,7 @@ class LibraryDatabase {
'releaseDate': row['release_date'], 'releaseDate': row['release_date'],
'bitDepth': row['bit_depth'], 'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'], 'sampleRate': row['sample_rate'],
'bitrate': row['bitrate'],
'genre': row['genre'], 'genre': row['genre'],
'format': row['format'], 'format': row['format'],
}; };
+274 -31
View File
@@ -1,27 +1,39 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationService { class NotificationService {
static final NotificationService _instance = NotificationService._internal(); static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance; factory NotificationService() => _instance;
NotificationService._internal(); NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false; bool _isInitialized = false;
bool _notificationPermissionRequested = false;
static const int downloadProgressId = 1; static const int downloadProgressId = 1;
static const int updateDownloadId = 2; static const int updateDownloadId = 2;
static const int libraryScanId = 3;
static const String channelId = 'download_progress'; static const String channelId = 'download_progress';
static const String channelName = 'Download Progress'; static const String channelName = 'Download Progress';
static const String channelDescription = 'Shows download progress for tracks'; static const String channelDescription = 'Shows download progress for tracks';
static const String libraryChannelId = 'library_scan';
static const String libraryChannelName = 'Library Scan';
static const String libraryChannelDescription =
'Shows local library scan progress';
Future<void> initialize() async { Future<void> initialize() async {
if (_isInitialized) return; if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings( const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true, requestAlertPermission: false,
requestBadgePermission: true, requestBadgePermission: false,
requestSoundPermission: false, requestSoundPermission: false,
); );
@@ -33,24 +45,86 @@ class NotificationService {
await _notifications.initialize(settings: initSettings); await _notifications.initialize(settings: initSettings);
if (Platform.isAndroid) { if (Platform.isAndroid) {
await _notifications final androidImpl = _notifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>() .resolvePlatformSpecificImplementation<
?.createNotificationChannel( AndroidFlutterLocalNotificationsPlugin
const AndroidNotificationChannel( >();
channelId, await androidImpl?.createNotificationChannel(
channelName, const AndroidNotificationChannel(
description: channelDescription, channelId,
importance: Importance.low, channelName,
showBadge: false, description: channelDescription,
playSound: false, importance: Importance.low,
enableVibration: false, showBadge: false,
), playSound: false,
); enableVibration: false,
),
);
await androidImpl?.createNotificationChannel(
const AndroidNotificationChannel(
libraryChannelId,
libraryChannelName,
description: libraryChannelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
} }
_isInitialized = true; _isInitialized = true;
} }
Future<bool> _ensureNotificationPermission() async {
if (!Platform.isIOS) return true;
final status = await Permission.notification.status;
if (status.isGranted || status.isProvisional) return true;
if (_notificationPermissionRequested ||
status.isPermanentlyDenied ||
status.isRestricted) {
return false;
}
_notificationPermissionRequested = true;
final requested = await Permission.notification.request();
return requested.isGranted || requested.isProvisional;
}
Future<void> _showSafely({
required int id,
required String title,
required String body,
required NotificationDetails details,
}) async {
if (!await _ensureNotificationPermission()) return;
try {
await _notifications.show(
id: id,
title: title,
body: body,
notificationDetails: details,
);
} on PlatformException catch (e) {
final isNotificationsNotAllowed =
Platform.isIOS &&
(e.code == 'Error 1' ||
(e.message?.contains('UNErrorDomain error 1') ?? false) ||
e.toString().contains('UNErrorDomain error 1'));
if (isNotificationsNotAllowed) {
debugPrint(
'iOS notifications not allowed; skipping local notification',
);
return;
}
rethrow;
}
}
Future<void> showDownloadProgress({ Future<void> showDownloadProgress({
required String trackName, required String trackName,
required String artistName, required String artistName,
@@ -89,11 +163,11 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
await _notifications.show( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: 'Downloading $trackName', title: 'Downloading $trackName',
body: '$artistName$percentage%', body: '$artistName$percentage%',
notificationDetails: details, details: details,
); );
} }
@@ -132,11 +206,11 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
await _notifications.show( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: 'Finalizing $trackName', title: 'Finalizing $trackName',
body: '$artistName • Embedding metadata...', body: '$artistName • Embedding metadata...',
notificationDetails: details, details: details,
); );
} }
@@ -182,11 +256,11 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
await _notifications.show( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: title, title: title,
body: '$trackName - $artistName', body: '$trackName - $artistName',
notificationDetails: details, details: details,
); );
} }
@@ -222,11 +296,11 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
await _notifications.show( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: title, title: title,
body: '$completedCount tracks downloaded successfully', body: '$completedCount tracks downloaded successfully',
notificationDetails: details, details: details,
); );
} }
@@ -234,6 +308,175 @@ class NotificationService {
await _notifications.cancel(id: downloadProgressId); await _notifications.cancel(id: downloadProgressId);
} }
Future<void> showLibraryScanProgress({
required double progress,
required int scannedFiles,
required int totalFiles,
String? currentFile,
}) async {
if (!_isInitialized) await initialize();
final clampedProgress = progress.clamp(0.0, 100.0);
final percentage = clampedProgress.round();
final progressBody = totalFiles > 0
? '$scannedFiles/$totalFiles files • $percentage%'
: '$scannedFiles files scanned • $percentage%';
final body = (currentFile != null && currentFile.isNotEmpty)
? '$progressBody\n$currentFile'
: progressBody;
final androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: percentage,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Scanning local library',
body: body,
details: details,
);
}
Future<void> showLibraryScanComplete({
required int totalTracks,
int excludedDownloadedCount = 0,
int errorCount = 0,
}) async {
if (!_isInitialized) await initialize();
final extras = <String>[];
if (excludedDownloadedCount > 0) {
extras.add('$excludedDownloadedCount excluded');
}
if (errorCount > 0) {
extras.add('$errorCount errors');
}
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Library scan complete',
body: '$totalTracks tracks indexed$suffix',
details: details,
);
}
Future<void> showLibraryScanFailed(String message) async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Library scan failed',
body: message,
details: details,
);
}
Future<void> showLibraryScanCancelled() async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Library scan cancelled',
body: 'Scan stopped before completion.',
details: details,
);
}
Future<void> cancelLibraryScanNotification() async {
await _notifications.cancel(id: libraryScanId);
}
Future<void> showUpdateDownloadProgress({ Future<void> showUpdateDownloadProgress({
required String version, required String version,
required int received, required int received,
@@ -273,11 +516,11 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
await _notifications.show( await _showSafely(
id: updateDownloadId, id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version', title: 'Downloading SpotiFLAC v$version',
body: '$receivedMB / $totalMB MB • $percentage%', body: '$receivedMB / $totalMB MB • $percentage%',
notificationDetails: details, details: details,
); );
} }
@@ -306,11 +549,11 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
await _notifications.show( await _showSafely(
id: updateDownloadId, id: updateDownloadId,
title: 'Update Ready', title: 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.', body: 'SpotiFLAC v$version downloaded. Tap to install.',
notificationDetails: details, details: details,
); );
} }
@@ -338,11 +581,11 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
await _notifications.show( await _showSafely(
id: updateDownloadId, id: updateDownloadId,
title: 'Update Failed', title: 'Update Failed',
body: 'Could not download update. Try again later.', body: 'Could not download update. Try again later.',
notificationDetails: details, details: details,
); );
} }
+1 -5
View File
@@ -103,8 +103,6 @@ class PlatformBridge {
return response; return response;
} }
static Future<Map<String, dynamic>> getDownloadProgress() async { static Future<Map<String, dynamic>> getDownloadProgress() async {
final result = await _channel.invokeMethod('getDownloadProgress'); final result = await _channel.invokeMethod('getDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
@@ -509,6 +507,7 @@ class PlatformBridge {
return { return {
'genre': data['genre'] as String? ?? '', 'genre': data['genre'] as String? ?? '',
'label': data['label'] as String? ?? '', 'label': data['label'] as String? ?? '',
'copyright': data['copyright'] as String? ?? '',
}; };
} catch (e) { } catch (e) {
_log.w('Failed to get Deezer extended metadata for $trackId: $e'); _log.w('Failed to get Deezer extended metadata for $trackId: $e');
@@ -719,8 +718,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
static Future<void> cleanupExtensions() async { static Future<void> cleanupExtensions() async {
_log.d('cleanupExtensions'); _log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions');
@@ -1130,5 +1127,4 @@ class PlatformBridge {
} }
// ==================== YOUTUBE / COBALT ==================== // ==================== YOUTUBE / COBALT ====================
} }
+76 -8
View File
@@ -12,6 +12,14 @@ final _iosContainerRootPattern = RegExp(
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$', r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
caseSensitive: false, caseSensitive: false,
); );
final _iosContainerPathWithoutLeadingSlashPattern = RegExp(
r'^(private/)?var/mobile/Containers/Data/Application/[A-F0-9\-]+/.+',
caseSensitive: false,
);
final _iosLegacyRelativeDocumentsPattern = RegExp(
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
caseSensitive: false,
);
/// Checks if a path is a valid writable directory on iOS. /// Checks if a path is a valid writable directory on iOS.
/// Returns false if: /// Returns false if:
@@ -21,6 +29,7 @@ final _iosContainerRootPattern = RegExp(
bool isValidIosWritablePath(String path) { bool isValidIosWritablePath(String path) {
if (!Platform.isIOS) return true; if (!Platform.isIOS) return true;
if (path.isEmpty) return false; if (path.isEmpty) return false;
if (!path.startsWith('/')) return false;
// Check if it's the container root (without Documents/, tmp/, etc.) // Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) { if (_iosContainerRootPattern.hasMatch(path)) {
@@ -54,16 +63,64 @@ bool isValidIosWritablePath(String path) {
/// Validates and potentially corrects an iOS path. /// Validates and potentially corrects an iOS path.
/// Returns a valid Documents subdirectory path if the input is invalid. /// Returns a valid Documents subdirectory path if the input is invalid.
Future<String> validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async { Future<String> validateOrFixIosPath(
String path, {
String subfolder = 'SpotiFLAC',
}) async {
if (!Platform.isIOS) return path; if (!Platform.isIOS) return path;
if (isValidIosWritablePath(path)) { final trimmed = path.trim();
return path; if (isValidIosWritablePath(trimmed)) {
return trimmed;
}
final docDir = await getApplicationDocumentsDirectory();
final candidates = <String>[];
if (trimmed.isNotEmpty) {
candidates.add(trimmed);
}
// Some pickers can return absolute iOS paths without the leading slash.
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
candidates.add('/$trimmed');
}
// Recover legacy relative iOS path format:
// Data/Application/<UUID>/Documents/<subdir>
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
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',
);
}
// Generic salvage for relative paths containing `Documents/...`.
if (!trimmed.startsWith('/')) {
final documentsMarker = 'Documents/';
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');
}
}
for (final candidate in candidates) {
if (isValidIosWritablePath(candidate)) {
return candidate;
}
} }
// Fall back to app Documents directory // Fall back to app Documents directory
final dir = await getApplicationDocumentsDirectory(); final musicDir = Directory('${docDir.path}/$subfolder');
final musicDir = Directory('${dir.path}/$subfolder');
if (!await musicDir.exists()) { if (!await musicDir.exists()) {
await musicDir.create(recursive: true); await musicDir.create(recursive: true);
} }
@@ -96,11 +153,20 @@ IosPathValidationResult validateIosPath(String path) {
); );
} }
if (!path.startsWith('/')) {
return const IosPathValidationResult(
isValid: false,
errorReason:
'Invalid path format. Please choose a local folder from Files.',
);
}
// Check if it's the container root // Check if it's the container root
if (_iosContainerRootPattern.hasMatch(path)) { if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult( return const IosPathValidationResult(
isValid: false, isValid: false,
errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.', errorReason:
'Cannot write to app container root. Please choose a subfolder like Documents.',
); );
} }
@@ -110,7 +176,8 @@ IosPathValidationResult validateIosPath(String path) {
path.contains('com~apple~CloudDocs')) { path.contains('com~apple~CloudDocs')) {
return const IosPathValidationResult( return const IosPathValidationResult(
isValid: false, isValid: false,
errorReason: 'iCloud Drive is not supported. Please choose a local folder.', errorReason:
'iCloud Drive is not supported. Please choose a local folder.',
); );
} }
@@ -125,7 +192,8 @@ IosPathValidationResult validateIosPath(String path) {
if (remainingPath.isEmpty || remainingPath == '/') { if (remainingPath.isEmpty || remainingPath == '/') {
return const IosPathValidationResult( return const IosPathValidationResult(
isValid: false, isValid: false,
errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.', errorReason:
'Cannot write to app container root. Please use the default folder or choose a different location.',
); );
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.6.5+79 version: 3.6.6+80
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -42,7 +42,7 @@ dependencies:
# Material Expressive 3 / Dynamic Color # Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1 material_color_utilities: ">=0.11.1 <0.14.0"
# Permissions # Permissions
permission_handler: ^12.0.1 permission_handler: ^12.0.1