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