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
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.26"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.26"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
+25
View File
@@ -1,5 +1,30 @@
# 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
### Highlights
+190 -44
View File
@@ -43,6 +43,7 @@ type OggQuality struct {
SampleRate int
BitDepth int
Duration int
Bitrate int // estimated bitrate in bps
}
// =============================================================================
@@ -664,50 +665,144 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
file.Seek(audioStart, io.SeekStart)
// Find first valid MP3 frame sync
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 {
break
}
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
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))
}
}
pos, _ := file.Seek(0, io.SeekCurrent)
frameStart = pos - 4
break
}
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
}
@@ -981,7 +1076,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
defer file.Close()
quality := &OggQuality{}
isOpus := false
packets, err := collectOggPackets(file, 5, 10)
if err != nil && len(packets) == 0 {
@@ -997,15 +1091,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
}
}
if streamType == oggStreamOpus {
isOpus = true
isOpus := streamType == oggStreamOpus
var preSkip int
if isOpus {
for _, pkt := range packets {
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
if quality.SampleRate == 0 {
quality.SampleRate = 48000
}
quality.BitDepth = 16
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
break
}
}
@@ -1013,26 +1109,76 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
for _, pkt := range packets {
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
quality.BitDepth = 16
break
}
}
}
// Read granule position from the last Ogg page for accurate duration
stat, err := file.Stat()
if err == nil {
// Very rough duration estimate based on file size
// Assume ~128kbps average for Opus, ~160kbps for Vorbis
avgBitrate := 128000
if !isOpus {
avgBitrate = 160000
if err != nil {
return quality, nil
}
fileSize := stat.Size()
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
}
// 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
// =============================================================================
+102 -29
View File
@@ -213,6 +213,9 @@ type DownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
Genre string
Label string
Copyright string
LyricsLRC string
DecryptionKey string
}
@@ -260,6 +263,21 @@ func buildDownloadSuccessResponse(
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{
Success: true,
Message: message,
@@ -277,14 +295,85 @@ func buildDownloadSuccessResponse(
DiscNumber: discNumber,
ISRC: isrc,
CoverURL: req.CoverURL,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Genre: genre,
Label: label,
Copyright: copyright,
LyricsLRC: result.LyricsLRC,
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) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -303,6 +392,8 @@ func DownloadTrack(requestJSON string) (string, error) {
AddAllowedDownloadDir(req.OutputDir)
}
enrichRequestExtendedMetadata(&req)
var result DownloadResult
var err error
@@ -390,11 +481,8 @@ func DownloadTrack(requestJSON string) (string, error) {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
result.FilePath = actualPath
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
@@ -407,14 +495,7 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
quality, qErr := GetAudioQuality(result.FilePath)
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)
}
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
@@ -488,6 +569,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
AddAllowedDownloadDir(req.OutputDir)
}
enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
if preferredService == "" {
@@ -585,11 +668,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err == nil {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
result.FilePath = actualPath
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
@@ -602,14 +682,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
quality, qErr := GetAudioQuality(result.FilePath)
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)
}
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
+8 -8
View File
@@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0
toolchain go1.25.7
toolchain go1.26.0
require (
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/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
golang.org/x/net v0.49.0
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
golang.org/x/net v0.50.0
)
require (
@@ -20,10 +20,10 @@ require (
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/klauspost/compress v1.17.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.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=
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.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/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/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/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/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.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/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/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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
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"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"`
}
@@ -289,8 +290,11 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
@@ -326,8 +330,11 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
+5
View File
@@ -46,6 +46,11 @@ post_install do |installer|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
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
+44 -15
View File
@@ -83,18 +83,12 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "downloadTrack":
case "downloadByStrategy":
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 }
return response
case "getDownloadProgress":
let response = GobackendGetDownloadProgress()
return response
@@ -209,6 +203,41 @@ import Gobackend // Import Go framework
case "cleanupConnections":
GobackendCleanupConnections()
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":
let args = call.arguments as! [String: Any]
@@ -479,12 +508,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
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":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -492,6 +515,12 @@ import Gobackend // Import Go framework
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error }
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":
let args = call.arguments as! [String: Any]
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.6.5';
static const String buildNumber = '79';
static const String version = '3.6.6';
static const String buildNumber = '80';
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
+839 -212
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
+34 -16
View File
@@ -21,6 +21,7 @@ class AppSettings {
final String folderOrganization;
final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final bool filterContributingArtistsInAlbumArtist;
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
@@ -36,18 +37,24 @@ class AppSettings {
final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool 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
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool
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
final bool localLibraryEnabled; // Enable local library scanning
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
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
const AppSettings({
this.defaultService = 'tidal',
@@ -67,6 +74,7 @@ class AppSettings {
this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false,
this.filterContributingArtistsInAlbumArtist = false,
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
@@ -112,6 +120,7 @@ class AppSettings {
String? folderOrganization,
bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly,
bool? filterContributingArtistsInAlbumArtist,
String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload,
@@ -157,18 +166,25 @@ class AppSettings {
folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly:
usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
filterContributingArtistsInAlbumArtist ??
this.filterContributingArtistsInAlbumArtist,
historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
askQualityBeforeDownload:
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
useCustomSpotifyCredentials:
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
@@ -176,12 +192,14 @@ class AppSettings {
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
// Tutorial
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
);
+4
View File
@@ -24,6 +24,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
filterContributingArtistsInAlbumArtist:
json['filterContributingArtistsInAlbumArtist'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -72,6 +74,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
'filterContributingArtistsInAlbumArtist':
instance.filterContributingArtistsInAlbumArtist,
'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
+223 -86
View File
@@ -323,7 +323,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) {
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;
}
candidateIndexes.add(i);
@@ -344,52 +347,59 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
for (var c = 0; c < candidateIndexes.length; c++) {
final i = candidateIndexes[c];
final item = items[i];
final rawPath = item.filePath.trim();
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
final exists = await fileExists(item.filePath);
if (exists) {
final verified = item.copyWith(
safRepaired: true,
safFileName: item.safFileName ?? _fileNameFromUri(item.filePath),
);
updatedItems[i] = verified;
changed = true;
verifiedCount++;
await _db.upsert(verified.toJson());
} else {
final fallbackName =
item.safFileName ?? _fileNameFromUri(item.filePath);
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
if (isDirectSafUri) {
final exists = await fileExists(rawPath);
if (exists) {
final verified = item.copyWith(
safRepaired: true,
safFileName: item.safFileName ?? _fileNameFromUri(rawPath),
);
updatedItems[i] = verified;
changed = true;
verifiedCount++;
await _db.upsert(verified.toJson());
continue;
}
}
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = resolved['uri'] as String? ?? '';
if (newUri.isEmpty) continue;
var fallbackName = (item.safFileName ?? '').trim();
if (fallbackName.isEmpty && isDirectSafUri) {
fallbackName = _fileNameFromUri(rawPath);
}
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
continue;
}
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir:
(newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = (resolved['uri'] as String? ?? '').trim();
if (newUri.isEmpty) continue;
updatedItems[i] = updated;
changed = true;
repairedCount++;
await _db.upsert(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir:
(newRelativeDir != null && newRelativeDir.isNotEmpty)
? 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) {
@@ -421,19 +431,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
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) {
final updatedItems = state.items
.where((i) => i.id != existing!.id)
.toList();
updatedItems.insert(0, item);
updatedItems.insert(0, mergedItem);
state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}');
_historyLog.d('Updated existing history entry: ${mergedItem.trackName}');
} else {
state = state.copyWith(items: [item, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}');
state = state.copyWith(items: [mergedItem, ...state.items]);
_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');
});
}
@@ -1173,11 +1197,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
}) async {
String baseDir = state.outputDir;
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
? normalizedAlbumArtist ?? track.artistName
: track.artistName;
if (useAlbumArtistForFolders &&
filterContributingArtistsInAlbumArtist &&
normalizedAlbumArtist != null) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
@@ -1285,6 +1316,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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) {
return Platform.isAndroid &&
settings.storageMode == 'saf' &&
@@ -1309,10 +1349,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
}) async {
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
? normalizedAlbumArtist ?? track.artistName
: track.artistName;
if (useAlbumArtistForFolders &&
filterContributingArtistsInAlbumArtist &&
normalizedAlbumArtist != null) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
@@ -1728,6 +1775,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
track,
settings,
);
if (!settings.useExtensionProviders) return;
@@ -1742,8 +1793,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'album_artist':
_normalizeOptionalString(track.albumArtist) ?? track.artistName,
'album_artist': resolvedAlbumArtist,
'track_number': track.trackNumber ?? 1,
'disc_number': track.discNumber ?? 1,
'isrc': track.isrc ?? '',
@@ -1803,7 +1853,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Track _buildTrackForMetadataEmbedding(
Track baseTrack,
Map<String, dynamic> backendResult,
String? normalizedAlbumArtist,
String resolvedAlbumArtist,
) {
final backendTrackNum = _parsePositiveInt(backendResult['track_number']);
final backendDiscNum = _parsePositiveInt(backendResult['disc_number']);
@@ -1826,7 +1876,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
name: baseTrack.name,
artistName: baseTrack.artistName,
albumName: backendAlbum ?? baseTrack.albumName,
albumArtist: normalizedAlbumArtist,
albumArtist: resolvedAlbumArtist,
coverUrl: baseTrack.coverUrl,
duration: baseTrack.duration,
isrc: baseTrack.isrc,
@@ -1890,16 +1940,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = 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['DISC'] = track.discNumber.toString();
}
@@ -2033,16 +2082,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = 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['DISC'] = track.discNumber.toString();
}
@@ -2198,15 +2246,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString();
}
@@ -2442,6 +2489,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
} else if (!isValidIosWritablePath(state.outputDir)) {
// Check for other invalid paths (like container root without Documents/)
_log.w(
@@ -2451,6 +2499,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final correctedPath = await validateOrFixIosPath(state.outputDir);
_log.i('Corrected path: $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}');
final normalizedAlbumArtist = _normalizeOptionalString(
trackToDownload.albumArtist,
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
trackToDownload,
settings,
);
final quality = item.qualityOverride ?? state.audioQuality;
@@ -2731,6 +2781,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
)
: '';
String? appOutputDir;
@@ -2743,6 +2795,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
);
var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode;
@@ -2768,6 +2822,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? genre;
String? label;
String? copyright;
String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
@@ -2845,9 +2900,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(trackToDownload.isrc == null && deezerIsrc != null) ||
(!_isValidISRC(trackToDownload.isrc ?? '') &&
deezerIsrc != null) ||
(trackToDownload.trackNumber == null &&
deezerTrackNum != null) ||
(trackToDownload.discNumber == null && deezerDiscNum != null);
((trackToDownload.trackNumber == null ||
trackToDownload.trackNumber! <= 0) &&
deezerTrackNum != null &&
deezerTrackNum > 0) ||
((trackToDownload.discNumber == null ||
trackToDownload.discNumber! <= 0) &&
deezerDiscNum != null &&
deezerDiscNum > 0);
if (needsEnrich) {
trackToDownload = Track(
@@ -2861,8 +2921,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc
: trackToDownload.isrc,
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum,
discNumber: trackToDownload.discNumber ?? deezerDiscNum,
trackNumber:
(trackToDownload.trackNumber != null &&
trackToDownload.trackNumber! > 0)
? trackToDownload.trackNumber
: deezerTrackNum,
discNumber:
(trackToDownload.discNumber != null &&
trackToDownload.discNumber! > 0)
? trackToDownload.discNumber
: deezerDiscNum,
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
deezerId: deezerTrackId,
availability: trackToDownload.availability,
@@ -2889,8 +2957,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (extendedMetadata != null) {
genre = extendedMetadata['genre'];
label = extendedMetadata['label'];
copyright = extendedMetadata['copyright'];
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) {
@@ -2937,6 +3008,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
_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(
isrc: trackToDownload.isrc ?? '',
service: item.service,
@@ -2944,7 +3026,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist ?? trackToDownload.artistName,
albumArtist: resolvedAlbumArtist,
coverUrl: trackToDownload.coverUrl ?? '',
outputDir: outputDir,
filenameFormat: state.filenameFormat,
@@ -2952,14 +3034,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Keep prior behavior: non-YouTube paths were implicitly true.
embedLyrics: isYouTube ? settings.embedLyrics : true,
embedMaxQualityCover: settings.maxQualityCover,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
trackNumber: normalizedTrackNumber,
discNumber: normalizedDiscNumber,
releaseDate: trackToDownload.releaseDate ?? '',
itemId: item.id,
durationMs: trackToDownload.duration,
source: trackToDownload.source ?? '',
genre: genre ?? '',
label: label ?? '',
copyright: copyright ?? '',
deezerId: deezerTrackId ?? '',
lyricsMode: settings.lyricsMode,
storageMode: storageMode,
@@ -2992,6 +3075,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
);
final fallbackResult = await runDownload(
useSaf: false,
@@ -3329,7 +3414,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
@@ -3493,7 +3578,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
@@ -3553,7 +3638,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
@@ -3613,7 +3698,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
@@ -3650,7 +3735,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
@@ -3748,6 +3833,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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(
item.id,
DownloadStatus.completed,
@@ -3840,13 +3966,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] 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}');
final historyAlbumArtist =
(normalizedAlbumArtist != null &&
normalizedAlbumArtist != trackToDownload.artistName)
? normalizedAlbumArtist
resolvedAlbumArtist != trackToDownload.artistName
? resolvedAlbumArtist
: null;
final isMp3 = filePath.endsWith('.mp3');
@@ -3899,9 +4036,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
quality: actualQuality,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
genre: backendGenre,
label: backendLabel,
copyright: backendCopyright,
genre: effectiveGenre,
label: effectiveLabel,
copyright: effectiveCopyright,
),
);
+169 -4
View File
@@ -3,8 +3,10 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/history_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/utils/logger.dart';
@@ -116,6 +118,7 @@ class LocalLibraryState {
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance;
final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer;
bool _isLoaded = false;
@@ -180,6 +183,58 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
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(
String folderPath, {
bool forceFullScan = false,
@@ -202,6 +257,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanErrorCount: 0,
scanWasCancelled: false,
);
await _showScanProgressNotification(
progress: 0,
scannedFiles: 0,
totalFiles: 0,
currentFile: null,
);
try {
final appSupportDir = await getApplicationSupportDirectory();
@@ -217,10 +278,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
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 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(
'Excluding ${downloadedPaths.length} downloaded files from library scan',
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
'(${downloadedPathKeys.length} path keys)',
);
if (forceFullScan) {
@@ -230,6 +307,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
: await PlatformBridge.scanLibraryFolder(folderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return;
}
@@ -238,7 +316,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in results) {
final filePath = json['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) {
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
}
@@ -275,6 +353,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'Full scan complete: ${items.length} tracks found, '
'$skippedDownloads already in downloads',
);
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
@@ -308,6 +391,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return;
}
@@ -344,7 +428,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in scannedList) {
final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?;
if (filePath != null && downloadedPaths.contains(filePath)) {
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
}
@@ -399,10 +483,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
);
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
}
} catch (e, stack) {
_log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false, scanWasCancelled: false);
await _showScanFailedNotification(e.toString());
} finally {
_stopProgressPolling();
}
@@ -441,6 +531,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scannedFiles: scannedFiles,
scanErrorCount: errorCount,
);
await _showScanProgressNotification(
progress: normalizedProgress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: currentFile,
);
}
if (progress['is_complete'] == true) {
@@ -473,6 +569,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false, scanWasCancelled: true);
_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 {
+5
View File
@@ -236,6 +236,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setFilterContributingArtistsInAlbumArtist(bool enabled) {
state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
+15 -1
View File
@@ -560,7 +560,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
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 =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
+10 -2
View File
@@ -70,7 +70,12 @@ class UnifiedLibraryItem {
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
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 =
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
}
@@ -897,7 +902,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
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 '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
@@ -25,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
bool _artistFolderFiltersExpanded = false;
@override
void initState() {
@@ -363,19 +364,53 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value),
showDivider: false,
),
SettingsItem(
icon: Icons.filter_alt_outlined,
title: 'Artist Name Filters',
subtitle: _getArtistFolderFilterSubtitle(
context,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterAlbumArtistContributors:
settings.filterContributingArtistsInAlbumArtist,
),
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),
showDivider: false,
trailing: Icon(
_artistFolderFiltersExpanded
? Icons.expand_less
: Icons.expand_more,
),
onTap: () {
setState(() {
_artistFolderFiltersExpanded =
!_artistFolderFiltersExpanded;
});
},
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) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
content: Text(
validation.errorReason ??
context.l10n.setupIcloudNotSupported,
),
backgroundColor: Theme.of(ctx).colorScheme.error,
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) {
switch (mode) {
case 'external':
@@ -1456,9 +1508,7 @@ class _ServiceChip extends StatelessWidget {
return Expanded(
child: Material(
color: isSelected
? colorScheme.primaryContainer
: unselectedColor,
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
+11 -2
View File
@@ -68,10 +68,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _checkInitialPermissions() async {
if (Platform.isIOS) {
final notificationStatus = await Permission.notification.status;
if (mounted) {
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
_notificationPermissionGranted =
notificationStatus.isGranted || notificationStatus.isProvisional;
});
}
} else if (Platform.isAndroid) {
@@ -181,7 +183,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _requestNotificationPermission() async {
setState(() => _isLoading = true);
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();
if (status.isGranted) {
setState(() => _notificationPermissionGranted = true);
+94 -8
View File
@@ -416,6 +416,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
int? get sampleRate =>
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
@@ -424,8 +425,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt =>
_isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt;
DateTime get _addedAt {
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 cleanFilePath {
@@ -433,6 +444,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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() {
_hasMetadataChanges = true;
}
@@ -913,7 +968,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string - prefer stored quality from download
String? audioQualityStr;
final fileName = _filePath.split('/').last;
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExt = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: '';
@@ -921,8 +976,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Use stored quality from download history if available
if (_quality != null && _quality!.isNotEmpty) {
audioQualityStr = _quality;
} else if (bitDepth != null && sampleRate != null) {
// Fallback for FLAC files without stored quality
} else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) {
// 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);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
} else {
@@ -1031,7 +1090,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool fileExists,
int? fileSize,
) {
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
final displayFilePath = _formatPathForDisplay(cleanFilePath);
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExtension = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: '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(
padding: const EdgeInsets.symmetric(
horizontal: 12,
@@ -1194,7 +1280,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
children: [
Expanded(
child: Text(
cleanFilePath,
displayFilePath,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
+14 -1
View File
@@ -23,6 +23,7 @@ class LocalLibraryItem {
final String? releaseDate;
final int? bitDepth;
final int? sampleRate;
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
final String? genre;
final String? format; // flac, mp3, opus, m4a
@@ -43,6 +44,7 @@ class LocalLibraryItem {
this.releaseDate,
this.bitDepth,
this.sampleRate,
this.bitrate,
this.genre,
this.format,
});
@@ -64,6 +66,7 @@ class LocalLibraryItem {
'releaseDate': releaseDate,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
'bitrate': bitrate,
'genre': genre,
'format': format,
};
@@ -86,6 +89,7 @@ class LocalLibraryItem {
releaseDate: json['releaseDate'] as String?,
bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?,
bitrate: (json['bitrate'] as num?)?.toInt(),
genre: json['genre'] as String?,
format: json['format'] as String?,
);
@@ -115,7 +119,7 @@ class LibraryDatabase {
return await openDatabase(
path,
version: 3, // Bumped version for file_mod_time migration
version: 4, // Bumped version for bitrate column
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -142,6 +146,7 @@ class LibraryDatabase {
release_date TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
bitrate INTEGER,
genre TEXT,
format TEXT
)
@@ -169,6 +174,12 @@ class LibraryDatabase {
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
_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) {
@@ -189,6 +200,7 @@ class LibraryDatabase {
'release_date': json['releaseDate'],
'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'],
'bitrate': json['bitrate'],
'genre': json['genre'],
'format': json['format'],
};
@@ -212,6 +224,7 @@ class LibraryDatabase {
'releaseDate': row['release_date'],
'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'],
'bitrate': row['bitrate'],
'genre': row['genre'],
'format': row['format'],
};
+276 -33
View File
@@ -1,27 +1,39 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
bool _notificationPermissionRequested = false;
static const int downloadProgressId = 1;
static const int updateDownloadId = 2;
static const int libraryScanId = 3;
static const String channelId = 'download_progress';
static const String channelName = 'Download Progress';
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 {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
@@ -33,24 +45,86 @@ class NotificationService {
await _notifications.initialize(settings: initSettings);
if (Platform.isAndroid) {
await _notifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(
const AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
final androidImpl = _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
await androidImpl?.createNotificationChannel(
const AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.low,
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;
}
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({
required String trackName,
required String artistName,
@@ -60,7 +134,7 @@ class NotificationService {
if (!_isInitialized) await initialize();
final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
@@ -89,11 +163,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: 'Downloading $trackName',
body: '$artistName$percentage%',
notificationDetails: details,
details: details,
);
}
@@ -132,11 +206,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: 'Finalizing $trackName',
body: '$artistName • Embedding metadata...',
notificationDetails: details,
details: details,
);
}
@@ -182,11 +256,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: title,
body: '$trackName - $artistName',
notificationDetails: details,
details: details,
);
}
@@ -222,11 +296,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: title,
body: '$completedCount tracks downloaded successfully',
notificationDetails: details,
details: details,
);
}
@@ -234,6 +308,175 @@ class NotificationService {
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({
required String version,
required int received,
@@ -244,7 +487,7 @@ class NotificationService {
final percentage = total > 0 ? (received * 100 ~/ total) : 0;
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
@@ -273,11 +516,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version',
body: '$receivedMB / $totalMB MB • $percentage%',
notificationDetails: details,
details: details,
);
}
@@ -306,11 +549,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: updateDownloadId,
title: 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.',
notificationDetails: details,
details: details,
);
}
@@ -338,11 +581,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: updateDownloadId,
title: 'Update Failed',
body: 'Could not download update. Try again later.',
notificationDetails: details,
details: details,
);
}
+1 -5
View File
@@ -103,8 +103,6 @@ class PlatformBridge {
return response;
}
static Future<Map<String, dynamic>> getDownloadProgress() async {
final result = await _channel.invokeMethod('getDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
@@ -509,6 +507,7 @@ class PlatformBridge {
return {
'genre': data['genre'] as String? ?? '',
'label': data['label'] as String? ?? '',
'copyright': data['copyright'] as String? ?? '',
};
} catch (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();
}
static Future<void> cleanupExtensions() async {
_log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions');
@@ -1130,5 +1127,4 @@ class PlatformBridge {
}
// ==================== YOUTUBE / COBALT ====================
}
+76 -8
View File
@@ -12,6 +12,14 @@ final _iosContainerRootPattern = RegExp(
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
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.
/// Returns false if:
@@ -21,6 +29,7 @@ final _iosContainerRootPattern = RegExp(
bool isValidIosWritablePath(String path) {
if (!Platform.isIOS) return true;
if (path.isEmpty) return false;
if (!path.startsWith('/')) return false;
// Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) {
@@ -54,16 +63,64 @@ bool isValidIosWritablePath(String path) {
/// Validates and potentially corrects an iOS path.
/// 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 (isValidIosWritablePath(path)) {
return path;
final trimmed = path.trim();
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
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/$subfolder');
final musicDir = Directory('${docDir.path}/$subfolder');
if (!await musicDir.exists()) {
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
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
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')) {
return const IosPathValidationResult(
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 == '/') {
return const IosPathValidationResult(
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
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.6.5+79
version: 3.6.6+80
environment:
sdk: ^3.10.0
@@ -42,7 +42,7 @@ dependencies:
# Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1
material_color_utilities: ">=0.11.1 <0.14.0"
# Permissions
permission_handler: ^12.0.1