mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8615cde898 | |||
| 207c0653cc | |||
| de756e5d86 | |||
| fd5db3f7b6 | |||
| d087da9409 | |||
| 43469a7ef2 | |||
| add4af831e | |||
| 4e530ffbc3 | |||
| 14f6776fdc | |||
| da1c6e9171 | |||
| 9c3e934395 | |||
| 15d2c3b465 | |||
| 8aaa6d5cbe | |||
| 9158d0228d |
@@ -164,13 +164,18 @@ jobs:
|
|||||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-15
|
||||||
needs: get-version # Only depends on version, NOT android build!
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Select Xcode 26.1.1
|
||||||
|
run: |
|
||||||
|
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
||||||
|
xcodebuild -version
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getByName("profile") {
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
// For local builds: use release signing if key.properties exists
|
// For local builds: use release signing if key.properties exists
|
||||||
// For CI builds: APK is signed by GitHub Action after build
|
// For CI builds: APK is signed by GitHub Action after build
|
||||||
@@ -71,6 +83,9 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,24 +94,6 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<!-- Audio playback service for media notification / background audio -->
|
|
||||||
<service
|
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
|
||||||
android:exported="true"
|
|
||||||
android:foregroundServiceType="mediaPlayback">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<!-- flutter_local_notifications receivers -->
|
<!-- flutter_local_notifications receivers -->
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
|
|||||||
@@ -944,16 +944,19 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val srcFile = java.io.File(goFilePath)
|
val srcFile = java.io.File(goFilePath)
|
||||||
if (srcFile.exists() && srcFile.length() > 0) {
|
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||||
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||||
srcFile.inputStream().use { input ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
srcFile.delete()
|
|
||||||
}
|
}
|
||||||
|
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||||
|
srcFile.inputStream().use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||||
|
srcFile.delete()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
document.delete()
|
||||||
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
|
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
|
||||||
|
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
respObj.put("file_path", document.uri.toString())
|
respObj.put("file_path", document.uri.toString())
|
||||||
@@ -2965,6 +2968,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"setDownloadFallbackExtensionIds" -> {
|
||||||
|
val extensionIdsJson = call.argument<String>("extension_ids") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setExtensionFallbackProviderIDsJSON(extensionIdsJson)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"setMetadataProviderPriority" -> {
|
"setMetadataProviderPriority" -> {
|
||||||
val priorityJson = call.argument<String>("priority") ?: "[]"
|
val priorityJson = call.argument<String>("priority") ?: "[]"
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|||||||
+12
-10
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -367,12 +366,9 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
|||||||
case "DATE":
|
case "DATE":
|
||||||
metadata.Date = value
|
metadata.Date = value
|
||||||
case "TRACK", "TRACKNUMBER":
|
case "TRACK", "TRACKNUMBER":
|
||||||
// APE track format can be "3" or "3/12"
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
|
||||||
metadata.TrackNumber = trackNum
|
|
||||||
case "DISC", "DISCNUMBER":
|
case "DISC", "DISCNUMBER":
|
||||||
discNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
metadata.DiscNumber = discNum
|
|
||||||
case "ISRC":
|
case "ISRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "LYRICS", "UNSYNCEDLYRICS":
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
@@ -425,10 +421,10 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
|||||||
addItem("Year", metadata.Year)
|
addItem("Year", metadata.Year)
|
||||||
}
|
}
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
addItem("Track", strconv.Itoa(metadata.TrackNumber))
|
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||||
}
|
}
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
addItem("Disc", strconv.Itoa(metadata.DiscNumber))
|
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||||
}
|
}
|
||||||
addItem("ISRC", metadata.ISRC)
|
addItem("ISRC", metadata.ISRC)
|
||||||
addItem("Lyrics", metadata.Lyrics)
|
addItem("Lyrics", metadata.Lyrics)
|
||||||
@@ -453,7 +449,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
|||||||
"artist": "ARTIST",
|
"artist": "ARTIST",
|
||||||
"album": "ALBUM",
|
"album": "ALBUM",
|
||||||
"album_artist": "ALBUM ARTIST",
|
"album_artist": "ALBUM ARTIST",
|
||||||
"date": "YEAR",
|
"date": "DATE",
|
||||||
"genre": "GENRE",
|
"genre": "GENRE",
|
||||||
"track_number": "TRACK",
|
"track_number": "TRACK",
|
||||||
"disc_number": "DISC",
|
"disc_number": "DISC",
|
||||||
@@ -475,7 +471,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Some fields have reader aliases that must also be cleared when the
|
// Some fields have reader aliases that must also be cleared when the
|
||||||
// canonical key is updated (e.g. "Year" writer ↔ DATE/YEAR reader,
|
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
|
||||||
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
||||||
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||||
if _, present := fields["date"]; present {
|
if _, present := fields["date"]; present {
|
||||||
@@ -484,9 +480,15 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
|||||||
if _, present := fields["disc_number"]; present {
|
if _, present := fields["disc_number"]; present {
|
||||||
result["DISCNUMBER"] = struct{}{}
|
result["DISCNUMBER"] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if _, present := fields["disc_total"]; present {
|
||||||
|
result["DISCNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
if _, present := fields["track_number"]; present {
|
if _, present := fields["track_number"]; present {
|
||||||
result["TRACKNUMBER"] = struct{}{}
|
result["TRACKNUMBER"] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if _, present := fields["track_total"]; present {
|
||||||
|
result["TRACKNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
if _, present := fields["album_artist"]; present {
|
if _, present := fields["album_artist"]; present {
|
||||||
result["ALBUMARTIST"] = struct{}{}
|
result["ALBUMARTIST"] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ type AudioMetadata struct {
|
|||||||
Year string
|
Year string
|
||||||
Date string
|
Date string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
|
TotalTracks int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
TotalDiscs int
|
||||||
ISRC string
|
ISRC string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
Label string
|
Label string
|
||||||
@@ -173,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
|||||||
case "TCO":
|
case "TCO":
|
||||||
metadata.Genre = cleanGenre(value)
|
metadata.Genre = cleanGenre(value)
|
||||||
case "TRK":
|
case "TRK":
|
||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
case "TPA":
|
case "TPA":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
case "TCM":
|
case "TCM":
|
||||||
metadata.Composer = value
|
metadata.Composer = value
|
||||||
case "TPB":
|
case "TPB":
|
||||||
@@ -292,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
case "TCON":
|
case "TCON":
|
||||||
metadata.Genre = cleanGenre(value)
|
metadata.Genre = cleanGenre(value)
|
||||||
case "TRCK":
|
case "TRCK":
|
||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
case "TPOS":
|
case "TPOS":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
case "TSRC":
|
case "TSRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "TCOM":
|
case "TCOM":
|
||||||
@@ -580,14 +582,28 @@ func cleanGenre(genre string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseTrackNumber(s string) int {
|
func parseTrackNumber(s string) int {
|
||||||
s = strings.TrimSpace(s)
|
num, _ := parseIndexPair(s)
|
||||||
if idx := strings.Index(s, "/"); idx > 0 {
|
|
||||||
s = s[:idx]
|
|
||||||
}
|
|
||||||
num, _ := strconv.Atoi(s)
|
|
||||||
return num
|
return num
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseIndexPair(s string) (int, int) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
first := s
|
||||||
|
second := ""
|
||||||
|
if idx := strings.Index(s, "/"); idx > 0 {
|
||||||
|
first = s[:idx]
|
||||||
|
second = s[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
num, _ := strconv.Atoi(strings.TrimSpace(first))
|
||||||
|
total, _ := strconv.Atoi(strings.TrimSpace(second))
|
||||||
|
return num, total
|
||||||
|
}
|
||||||
|
|
||||||
func removeUnsync(data []byte) []byte {
|
func removeUnsync(data []byte) []byte {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return data
|
return data
|
||||||
@@ -1037,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
case "GENRE":
|
case "GENRE":
|
||||||
metadata.Genre = value
|
metadata.Genre = value
|
||||||
case "TRACKNUMBER", "TRACK":
|
case "TRACKNUMBER", "TRACK":
|
||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
case "DISCNUMBER", "DISC":
|
case "DISCNUMBER", "DISC":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
case "ISRC":
|
case "ISRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "COMPOSER":
|
case "COMPOSER":
|
||||||
|
|||||||
@@ -513,6 +513,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
album = "Unknown Album"
|
album = "Unknown Album"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composer := track.Composer
|
||||||
|
if composer == "" {
|
||||||
|
composer = sheet.Composer
|
||||||
|
}
|
||||||
|
|
||||||
var duration int
|
var duration int
|
||||||
if i+1 < len(sheet.Tracks) {
|
if i+1 < len(sheet.Tracks) {
|
||||||
nextStart := sheet.Tracks[i+1].StartTime
|
nextStart := sheet.Tracks[i+1].StartTime
|
||||||
@@ -539,12 +544,15 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
ScannedAt: scanTime,
|
ScannedAt: scanTime,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
TrackNumber: track.Number,
|
TrackNumber: track.Number,
|
||||||
|
TotalTracks: len(sheet.Tracks),
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
|
TotalDiscs: 1,
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
ReleaseDate: sheet.Date,
|
ReleaseDate: sheet.Date,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
Genre: sheet.Genre,
|
Genre: sheet.Genre,
|
||||||
|
Composer: composer,
|
||||||
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -630,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
totalDiscs := 0
|
||||||
|
for _, track := range allTracks {
|
||||||
|
if track.DiskNumber > totalDiscs {
|
||||||
|
totalDiscs = track.DiskNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
albumType := album.RecordType
|
albumType := album.RecordType
|
||||||
@@ -658,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
TrackNumber: trackNum,
|
TrackNumber: trackNum,
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
|
TotalDiscs: totalDiscs,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
|||||||
@@ -1,444 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
|
|
||||||
|
|
||||||
type DeezerDownloadResult struct {
|
|
||||||
FilePath string
|
|
||||||
BitDepth int
|
|
||||||
SampleRate int
|
|
||||||
Title string
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
ReleaseDate string
|
|
||||||
TrackNumber int
|
|
||||||
DiscNumber int
|
|
||||||
ISRC string
|
|
||||||
LyricsLRC string
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLikelySpotifyTrackID(value string) bool {
|
|
||||||
if len(value) != 22 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range value {
|
|
||||||
switch {
|
|
||||||
case r >= 'A' && r <= 'Z':
|
|
||||||
case r >= 'a' && r <= 'z':
|
|
||||||
case r >= '0' && r <= '9':
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|
||||||
deezerID := strings.TrimSpace(req.DeezerID)
|
|
||||||
if deezerID == "" {
|
|
||||||
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
|
|
||||||
deezerID = strings.TrimSpace(prefixed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if deezerID != "" {
|
|
||||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
|
||||||
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
|
|
||||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
|
||||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
|
||||||
}
|
|
||||||
return trackURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
|
||||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
|
||||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
|
||||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
|
||||||
if resolvedID != "" {
|
|
||||||
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
|
|
||||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
|
||||||
// Fall through to ISRC search instead of using wrong track.
|
|
||||||
} else {
|
|
||||||
return availability.DeezerURL, nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return availability.DeezerURL, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isrc := strings.TrimSpace(req.ISRC)
|
|
||||||
if isrc != "" {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
|
||||||
defer cancel()
|
|
||||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
|
||||||
if err == nil && track != nil {
|
|
||||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
|
||||||
if resolvedID != "" {
|
|
||||||
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
|
|
||||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
|
||||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
|
||||||
defer cancel()
|
|
||||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil // Can't verify — don't block the download.
|
|
||||||
}
|
|
||||||
resolved := resolvedTrackInfo{
|
|
||||||
Title: trackResp.Track.Name,
|
|
||||||
ArtistName: trackResp.Track.Artists,
|
|
||||||
ISRC: trackResp.Track.ISRC,
|
|
||||||
Duration: trackResp.Track.DurationMS / 1000,
|
|
||||||
SkipNameVerification: skipNameVerification,
|
|
||||||
}
|
|
||||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
|
||||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
|
||||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
|
||||||
}
|
|
||||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerMusicDLRequest struct {
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
|
|
||||||
payload := deezerMusicDLRequest{
|
|
||||||
Platform: "deezer",
|
|
||||||
URL: deezerTrackURL,
|
|
||||||
}
|
|
||||||
jsonData, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("MusicDL request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
var raw map[string]any
|
|
||||||
if err := json.Unmarshal(body, &raw); err != nil {
|
|
||||||
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
|
||||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, key := range []string{"download_url", "url", "link"} {
|
|
||||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
|
||||||
return strings.TrimSpace(urlVal), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if data, ok := raw["data"].(map[string]any); ok {
|
|
||||||
for _, key := range []string{"download_url", "url", "link"} {
|
|
||||||
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
|
||||||
return strings.TrimSpace(urlVal), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("no download URL found in MusicDL response")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
|
||||||
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
|
||||||
|
|
||||||
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
if itemID != "" {
|
|
||||||
StartItemProgress(itemID)
|
|
||||||
defer CompleteItemProgress(itemID)
|
|
||||||
ctx = initDownloadCancel(itemID)
|
|
||||||
defer clearDownloadCancel(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create download request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := GetDownloadClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := openOutputForWrite(outputPath, outputFD)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
||||||
var written int64
|
|
||||||
if itemID != "" {
|
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
|
||||||
written, err = io.Copy(pw, resp.Body)
|
|
||||||
} else {
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
flushErr := bufWriter.Flush()
|
|
||||||
closeErr := out.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
|
||||||
}
|
|
||||||
if flushErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|
||||||
deezerClient := GetDeezerClient()
|
|
||||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
||||||
|
|
||||||
if !isSafOutput {
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
|
||||||
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
|
||||||
"title": req.TrackName,
|
|
||||||
"artist": req.ArtistName,
|
|
||||||
"album": req.AlbumName,
|
|
||||||
"track": req.TrackNumber,
|
|
||||||
"year": extractYear(req.ReleaseDate),
|
|
||||||
"date": req.ReleaseDate,
|
|
||||||
"disc": req.DiscNumber,
|
|
||||||
})
|
|
||||||
|
|
||||||
var outputPath string
|
|
||||||
if isSafOutput {
|
|
||||||
outputPath = strings.TrimSpace(req.OutputPath)
|
|
||||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
|
||||||
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var parallelResult *ParallelDownloadResult
|
|
||||||
parallelDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(parallelDone)
|
|
||||||
coverURL := req.CoverURL
|
|
||||||
embedLyrics := req.EmbedLyrics
|
|
||||||
if !req.EmbedMetadata {
|
|
||||||
coverURL = ""
|
|
||||||
embedLyrics = false
|
|
||||||
}
|
|
||||||
parallelResult = FetchCoverAndLyricsParallel(
|
|
||||||
coverURL,
|
|
||||||
req.EmbedMaxQualityCover,
|
|
||||||
req.SpotifyID,
|
|
||||||
req.TrackName,
|
|
||||||
req.ArtistName,
|
|
||||||
embedLyrics,
|
|
||||||
int64(req.DurationMS),
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
|
||||||
if deezerURLErr != nil {
|
|
||||||
return DeezerDownloadResult{}, fmt.Errorf(
|
|
||||||
"deezer download failed: could not resolve Deezer URL: %w",
|
|
||||||
deezerURLErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
|
||||||
downloadErr := deezerClient.DownloadFromMusicDL(
|
|
||||||
deezerTrackURL,
|
|
||||||
outputPath,
|
|
||||||
req.OutputFD,
|
|
||||||
req.ItemID,
|
|
||||||
)
|
|
||||||
if downloadErr != nil {
|
|
||||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
|
||||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return DeezerDownloadResult{}, fmt.Errorf(
|
|
||||||
"deezer download failed via MusicDL: %w",
|
|
||||||
downloadErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<-parallelDone
|
|
||||||
|
|
||||||
if req.ItemID != "" {
|
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
|
||||||
SetItemFinalizing(req.ItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
AlbumArtist: req.AlbumArtist,
|
|
||||||
ArtistTagMode: req.ArtistTagMode,
|
|
||||||
Date: req.ReleaseDate,
|
|
||||||
TrackNumber: req.TrackNumber,
|
|
||||||
TotalTracks: req.TotalTracks,
|
|
||||||
DiscNumber: req.DiscNumber,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
Genre: req.Genre,
|
|
||||||
Label: req.Label,
|
|
||||||
Copyright: req.Copyright,
|
|
||||||
}
|
|
||||||
|
|
||||||
var coverData []byte
|
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
|
||||||
coverData = parallelResult.CoverData
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSafOutput || !req.EmbedMetadata {
|
|
||||||
if !req.EmbedMetadata {
|
|
||||||
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
|
||||||
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsMode := req.LyricsMode
|
|
||||||
if lyricsMode == "" {
|
|
||||||
lyricsMode = "embed"
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
|
||||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
||||||
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
||||||
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isSafOutput {
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
bitDepth, sampleRate := 0, 0
|
|
||||||
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
|
|
||||||
bitDepth = quality.BitDepth
|
|
||||||
sampleRate = quality.SampleRate
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsLRC := ""
|
|
||||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
|
||||||
}
|
|
||||||
|
|
||||||
return DeezerDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: bitDepth,
|
|
||||||
SampleRate: sampleRate,
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
ReleaseDate: req.ReleaseDate,
|
|
||||||
TrackNumber: req.TrackNumber,
|
|
||||||
DiscNumber: req.DiscNumber,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
LyricsLRC: lyricsLRC,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
+237
-113
@@ -55,6 +55,7 @@ type DownloadRequest struct {
|
|||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"`
|
ItemID string `json:"item_id"`
|
||||||
DurationMS int `json:"duration_ms"`
|
DurationMS int `json:"duration_ms"`
|
||||||
@@ -62,6 +63,7 @@ type DownloadRequest struct {
|
|||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
TidalID string `json:"tidal_id,omitempty"`
|
TidalID string `json:"tidal_id,omitempty"`
|
||||||
QobuzID string `json:"qobuz_id,omitempty"`
|
QobuzID string `json:"qobuz_id,omitempty"`
|
||||||
DeezerID string `json:"deezer_id,omitempty"`
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
@@ -72,30 +74,34 @@ type DownloadRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
Service string `json:"service,omitempty"`
|
Service string `json:"service,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Artist string `json:"artist,omitempty"`
|
Artist string `json:"artist,omitempty"`
|
||||||
Album string `json:"album,omitempty"`
|
Album string `json:"album,omitempty"`
|
||||||
AlbumArtist string `json:"album_artist,omitempty"`
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
Genre string `json:"genre,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
Copyright string `json:"copyright,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
Composer string `json:"composer,omitempty"`
|
||||||
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||||
|
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||||
|
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||||
|
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
@@ -107,14 +113,18 @@ type DownloadResult struct {
|
|||||||
Album string
|
Album string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
|
TotalTracks int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
TotalDiscs int
|
||||||
ISRC string
|
ISRC string
|
||||||
CoverURL string
|
CoverURL string
|
||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
Copyright string
|
||||||
|
Composer string
|
||||||
LyricsLRC string
|
LyricsLRC string
|
||||||
DecryptionKey string
|
DecryptionKey string
|
||||||
|
Decryption *DownloadDecryptionInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
type reEnrichRequest struct {
|
type reEnrichRequest struct {
|
||||||
@@ -130,11 +140,14 @@ type reEnrichRequest struct {
|
|||||||
AlbumArtist string `json:"album_artist"`
|
AlbumArtist string `json:"album_artist"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Genre string `json:"genre"`
|
Genre string `json:"genre"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Copyright string `json:"copyright"`
|
Copyright string `json:"copyright"`
|
||||||
|
Composer string `json:"composer"`
|
||||||
DurationMs int64 `json:"duration_ms"`
|
DurationMs int64 `json:"duration_ms"`
|
||||||
SearchOnline bool `json:"search_online"`
|
SearchOnline bool `json:"search_online"`
|
||||||
UpdateFields []string `json:"update_fields,omitempty"`
|
UpdateFields []string `json:"update_fields,omitempty"`
|
||||||
@@ -172,6 +185,12 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.shouldUpdateField("basic_tags") {
|
if req.shouldUpdateField("basic_tags") {
|
||||||
|
if track.Name != "" {
|
||||||
|
req.TrackName = track.Name
|
||||||
|
}
|
||||||
|
if track.Artists != "" {
|
||||||
|
req.ArtistName = track.Artists
|
||||||
|
}
|
||||||
if track.AlbumName != "" {
|
if track.AlbumName != "" {
|
||||||
req.AlbumName = track.AlbumName
|
req.AlbumName = track.AlbumName
|
||||||
}
|
}
|
||||||
@@ -183,9 +202,15 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
|||||||
if track.TrackNumber > 0 {
|
if track.TrackNumber > 0 {
|
||||||
req.TrackNumber = track.TrackNumber
|
req.TrackNumber = track.TrackNumber
|
||||||
}
|
}
|
||||||
|
if track.TotalTracks > 0 {
|
||||||
|
req.TotalTracks = track.TotalTracks
|
||||||
|
}
|
||||||
if track.DiscNumber > 0 {
|
if track.DiscNumber > 0 {
|
||||||
req.DiscNumber = track.DiscNumber
|
req.DiscNumber = track.DiscNumber
|
||||||
}
|
}
|
||||||
|
if track.TotalDiscs > 0 {
|
||||||
|
req.TotalDiscs = track.TotalDiscs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("release_info") {
|
if req.shouldUpdateField("release_info") {
|
||||||
if track.ReleaseDate != "" {
|
if track.ReleaseDate != "" {
|
||||||
@@ -213,9 +238,35 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
|||||||
if track.Copyright != "" {
|
if track.Copyright != "" {
|
||||||
req.Copyright = track.Copyright
|
req.Copyright = track.Copyright
|
||||||
}
|
}
|
||||||
|
if track.Composer != "" {
|
||||||
|
req.Composer = track.Composer
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPlaceholderReEnrichValue(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "", "unknown", "unknown artist", "unknown title", "unknown album":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReEnrichSearchQuery(req reEnrichRequest) string {
|
||||||
|
parts := make([]string, 0, 2)
|
||||||
|
if !isPlaceholderReEnrichValue(req.TrackName) {
|
||||||
|
parts = append(parts, strings.TrimSpace(req.TrackName))
|
||||||
|
}
|
||||||
|
if !isPlaceholderReEnrichValue(req.ArtistName) {
|
||||||
|
parts = append(parts, strings.TrimSpace(req.ArtistName))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
|
||||||
|
parts = append(parts, strings.TrimSpace(req.AlbumName))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
|
||||||
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
||||||
return DownloadRequest{
|
return DownloadRequest{
|
||||||
TrackName: req.TrackName,
|
TrackName: req.TrackName,
|
||||||
@@ -225,12 +276,23 @@ func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
|||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
DurationMS: int(req.DurationMs),
|
DurationMS: int(req.DurationMs),
|
||||||
ArtistTagMode: req.ArtistTagMode,
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
|
TrackNumber: req.TrackNumber,
|
||||||
|
TotalTracks: req.TotalTracks,
|
||||||
|
DiscNumber: req.DiscNumber,
|
||||||
|
TotalDiscs: req.TotalDiscs,
|
||||||
|
Composer: req.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
|
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
|
||||||
metadata := map[string]string{}
|
metadata := map[string]string{}
|
||||||
if req.shouldUpdateField("basic_tags") {
|
if req.shouldUpdateField("basic_tags") {
|
||||||
|
if req.TrackName != "" {
|
||||||
|
metadata["TITLE"] = req.TrackName
|
||||||
|
}
|
||||||
|
if req.ArtistName != "" {
|
||||||
|
metadata["ARTIST"] = req.ArtistName
|
||||||
|
}
|
||||||
if req.AlbumName != "" {
|
if req.AlbumName != "" {
|
||||||
metadata["ALBUM"] = req.AlbumName
|
metadata["ALBUM"] = req.AlbumName
|
||||||
}
|
}
|
||||||
@@ -256,13 +318,16 @@ func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[str
|
|||||||
if req.Copyright != "" {
|
if req.Copyright != "" {
|
||||||
metadata["COPYRIGHT"] = req.Copyright
|
metadata["COPYRIGHT"] = req.Copyright
|
||||||
}
|
}
|
||||||
|
if req.Composer != "" {
|
||||||
|
metadata["COMPOSER"] = req.Composer
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("track_info") {
|
if req.shouldUpdateField("track_info") {
|
||||||
if req.TrackNumber > 0 {
|
if req.TrackNumber > 0 {
|
||||||
metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
|
metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks)
|
||||||
}
|
}
|
||||||
if req.DiscNumber > 0 {
|
if req.DiscNumber > 0 {
|
||||||
metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
|
metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("lyrics") {
|
if req.shouldUpdateField("lyrics") {
|
||||||
@@ -367,11 +432,14 @@ func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrac
|
|||||||
Images: track.Images,
|
Images: track.Images,
|
||||||
ReleaseDate: track.ReleaseDate,
|
ReleaseDate: track.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: track.TrackNumber,
|
||||||
|
TotalTracks: track.TotalTracks,
|
||||||
DiscNumber: track.DiscNumber,
|
DiscNumber: track.DiscNumber,
|
||||||
|
TotalDiscs: track.TotalDiscs,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
ProviderID: providerID,
|
ProviderID: providerID,
|
||||||
DeezerID: deezerID,
|
DeezerID: deezerID,
|
||||||
SpotifyID: track.SpotifyID,
|
SpotifyID: track.SpotifyID,
|
||||||
|
Composer: track.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,6 +601,11 @@ func buildDownloadSuccessResponse(
|
|||||||
copyright = req.Copyright
|
copyright = req.Copyright
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composer := result.Composer
|
||||||
|
if composer == "" {
|
||||||
|
composer = req.Composer
|
||||||
|
}
|
||||||
|
|
||||||
coverURL := strings.TrimSpace(result.CoverURL)
|
coverURL := strings.TrimSpace(result.CoverURL)
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
coverURL = strings.TrimSpace(req.CoverURL)
|
coverURL = strings.TrimSpace(req.CoverURL)
|
||||||
@@ -552,14 +625,18 @@ func buildDownloadSuccessResponse(
|
|||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
ReleaseDate: releaseDate,
|
ReleaseDate: releaseDate,
|
||||||
TrackNumber: trackNumber,
|
TrackNumber: trackNumber,
|
||||||
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: discNumber,
|
DiscNumber: discNumber,
|
||||||
|
TotalDiscs: req.TotalDiscs,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
CoverURL: coverURL,
|
CoverURL: coverURL,
|
||||||
Genre: genre,
|
Genre: genre,
|
||||||
Label: label,
|
Label: label,
|
||||||
Copyright: copyright,
|
Copyright: copyright,
|
||||||
|
Composer: composer,
|
||||||
LyricsLRC: result.LyricsLRC,
|
LyricsLRC: result.LyricsLRC,
|
||||||
DecryptionKey: result.DecryptionKey,
|
DecryptionKey: result.DecryptionKey,
|
||||||
|
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,24 +784,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "deezer":
|
|
||||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
|
||||||
if deezerErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: deezerResult.FilePath,
|
|
||||||
BitDepth: deezerResult.BitDepth,
|
|
||||||
SampleRate: deezerResult.SampleRate,
|
|
||||||
Title: deezerResult.Title,
|
|
||||||
Artist: deezerResult.Artist,
|
|
||||||
Album: deezerResult.Album,
|
|
||||||
ReleaseDate: deezerResult.ReleaseDate,
|
|
||||||
TrackNumber: deezerResult.TrackNumber,
|
|
||||||
DiscNumber: deezerResult.DiscNumber,
|
|
||||||
ISRC: deezerResult.ISRC,
|
|
||||||
LyricsLRC: deezerResult.LyricsLRC,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = deezerErr
|
|
||||||
default:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
@@ -775,7 +834,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
|||||||
serviceNormalized := strings.ToLower(serviceRaw)
|
serviceNormalized := strings.ToLower(serviceRaw)
|
||||||
|
|
||||||
normalizedReq := req
|
normalizedReq := req
|
||||||
if isBuiltInProvider(serviceNormalized) {
|
if isBuiltInDownloadProvider(serviceNormalized) {
|
||||||
normalizedReq.Service = serviceNormalized
|
normalizedReq.Service = serviceNormalized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +847,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
|||||||
if req.UseExtensions {
|
if req.UseExtensions {
|
||||||
// Respect strict mode when auto fallback is disabled:
|
// Respect strict mode when auto fallback is disabled:
|
||||||
// for built-in providers, route directly to selected service only.
|
// for built-in providers, route directly to selected service only.
|
||||||
if !req.UseFallback && isBuiltInProvider(serviceNormalized) {
|
if !req.UseFallback && isBuiltInDownloadProvider(serviceNormalized) {
|
||||||
return DownloadTrack(normalizedJSON)
|
return DownloadTrack(normalizedJSON)
|
||||||
}
|
}
|
||||||
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
||||||
@@ -827,9 +886,9 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
enrichRequestExtendedMetadata(&req)
|
enrichRequestExtendedMetadata(&req)
|
||||||
|
|
||||||
allServices := []string{"tidal", "qobuz", "deezer"}
|
allServices := []string{"tidal", "qobuz"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if !isBuiltInDownloadProvider(preferredService) {
|
||||||
preferredService = "tidal"
|
preferredService = "tidal"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,26 +954,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "deezer":
|
|
||||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
|
||||||
if deezerErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: deezerResult.FilePath,
|
|
||||||
BitDepth: deezerResult.BitDepth,
|
|
||||||
SampleRate: deezerResult.SampleRate,
|
|
||||||
Title: deezerResult.Title,
|
|
||||||
Artist: deezerResult.Artist,
|
|
||||||
Album: deezerResult.Album,
|
|
||||||
ReleaseDate: deezerResult.ReleaseDate,
|
|
||||||
TrackNumber: deezerResult.TrackNumber,
|
|
||||||
DiscNumber: deezerResult.DiscNumber,
|
|
||||||
ISRC: deezerResult.ISRC,
|
|
||||||
LyricsLRC: deezerResult.LyricsLRC,
|
|
||||||
}
|
|
||||||
} else if !errors.Is(deezerErr, ErrDownloadCancelled) {
|
|
||||||
GoLog("[DownloadWithFallback] Deezer error: %v\n", deezerErr)
|
|
||||||
}
|
|
||||||
err = deezerErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
||||||
@@ -1005,7 +1044,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
"album_artist": "",
|
"album_artist": "",
|
||||||
"date": "",
|
"date": "",
|
||||||
"track_number": 0,
|
"track_number": 0,
|
||||||
|
"total_tracks": 0,
|
||||||
"disc_number": 0,
|
"disc_number": 0,
|
||||||
|
"total_discs": 0,
|
||||||
"isrc": "",
|
"isrc": "",
|
||||||
"lyrics": "",
|
"lyrics": "",
|
||||||
"genre": "",
|
"genre": "",
|
||||||
@@ -1033,7 +1074,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["date"] = oggMeta.Year
|
result["date"] = oggMeta.Year
|
||||||
}
|
}
|
||||||
result["track_number"] = oggMeta.TrackNumber
|
result["track_number"] = oggMeta.TrackNumber
|
||||||
|
result["total_tracks"] = oggMeta.TotalTracks
|
||||||
result["disc_number"] = oggMeta.DiscNumber
|
result["disc_number"] = oggMeta.DiscNumber
|
||||||
|
result["total_discs"] = oggMeta.TotalDiscs
|
||||||
result["isrc"] = oggMeta.ISRC
|
result["isrc"] = oggMeta.ISRC
|
||||||
result["lyrics"] = oggMeta.Lyrics
|
result["lyrics"] = oggMeta.Lyrics
|
||||||
result["genre"] = oggMeta.Genre
|
result["genre"] = oggMeta.Genre
|
||||||
@@ -1054,7 +1097,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["album_artist"] = metadata.AlbumArtist
|
result["album_artist"] = metadata.AlbumArtist
|
||||||
result["date"] = metadata.Date
|
result["date"] = metadata.Date
|
||||||
result["track_number"] = metadata.TrackNumber
|
result["track_number"] = metadata.TrackNumber
|
||||||
|
result["total_tracks"] = metadata.TotalTracks
|
||||||
result["disc_number"] = metadata.DiscNumber
|
result["disc_number"] = metadata.DiscNumber
|
||||||
|
result["total_discs"] = metadata.TotalDiscs
|
||||||
result["isrc"] = metadata.ISRC
|
result["isrc"] = metadata.ISRC
|
||||||
result["lyrics"] = metadata.Lyrics
|
result["lyrics"] = metadata.Lyrics
|
||||||
result["genre"] = metadata.Genre
|
result["genre"] = metadata.Genre
|
||||||
@@ -1088,7 +1133,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["date"] = meta.Year
|
result["date"] = meta.Year
|
||||||
}
|
}
|
||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
|
result["total_tracks"] = meta.TotalTracks
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
|
result["total_discs"] = meta.TotalDiscs
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
result["lyrics"] = meta.Lyrics
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
@@ -1118,7 +1165,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["date"] = meta.Year
|
result["date"] = meta.Year
|
||||||
}
|
}
|
||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
|
result["total_tracks"] = meta.TotalTracks
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
|
result["total_discs"] = meta.TotalDiscs
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
result["lyrics"] = meta.Lyrics
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
@@ -1149,7 +1198,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["date"] = meta.Year
|
result["date"] = meta.Year
|
||||||
}
|
}
|
||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
|
result["total_tracks"] = meta.TotalTracks
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
|
result["total_discs"] = meta.TotalDiscs
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
result["lyrics"] = meta.Lyrics
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
@@ -1182,7 +1233,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["date"] = meta.Year
|
result["date"] = meta.Year
|
||||||
}
|
}
|
||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
|
result["total_tracks"] = meta.TotalTracks
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
|
result["total_discs"] = meta.TotalDiscs
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
result["lyrics"] = meta.Lyrics
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
@@ -1281,13 +1334,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
// APE/WV/MPC: write APEv2 tags natively
|
// APE/WV/MPC: write APEv2 tags natively
|
||||||
if isApeFile {
|
if isApeFile {
|
||||||
trackNum := 0
|
trackNum := 0
|
||||||
|
totalTracks := 0
|
||||||
discNum := 0
|
discNum := 0
|
||||||
|
totalDiscs := 0
|
||||||
if v, ok := fields["track_number"]; ok && v != "" {
|
if v, ok := fields["track_number"]; ok && v != "" {
|
||||||
fmt.Sscanf(v, "%d", &trackNum)
|
fmt.Sscanf(v, "%d", &trackNum)
|
||||||
}
|
}
|
||||||
|
if v, ok := fields["track_total"]; ok && v != "" {
|
||||||
|
fmt.Sscanf(v, "%d", &totalTracks)
|
||||||
|
}
|
||||||
if v, ok := fields["disc_number"]; ok && v != "" {
|
if v, ok := fields["disc_number"]; ok && v != "" {
|
||||||
fmt.Sscanf(v, "%d", &discNum)
|
fmt.Sscanf(v, "%d", &discNum)
|
||||||
}
|
}
|
||||||
|
if v, ok := fields["disc_total"]; ok && v != "" {
|
||||||
|
fmt.Sscanf(v, "%d", &totalDiscs)
|
||||||
|
}
|
||||||
|
|
||||||
meta := &AudioMetadata{
|
meta := &AudioMetadata{
|
||||||
Title: fields["title"],
|
Title: fields["title"],
|
||||||
@@ -1296,7 +1357,9 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
AlbumArtist: fields["album_artist"],
|
AlbumArtist: fields["album_artist"],
|
||||||
Date: fields["date"],
|
Date: fields["date"],
|
||||||
TrackNumber: trackNum,
|
TrackNumber: trackNum,
|
||||||
|
TotalTracks: totalTracks,
|
||||||
DiscNumber: discNum,
|
DiscNumber: discNum,
|
||||||
|
TotalDiscs: totalDiscs,
|
||||||
ISRC: fields["isrc"],
|
ISRC: fields["isrc"],
|
||||||
Genre: fields["genre"],
|
Genre: fields["genre"],
|
||||||
Label: fields["label"],
|
Label: fields["label"],
|
||||||
@@ -1930,11 +1993,13 @@ func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
|
|||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
"total_tracks": track.TotalTracks,
|
"total_tracks": track.TotalTracks,
|
||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
|
"total_discs": track.TotalDiscs,
|
||||||
"external_urls": track.ExternalURL,
|
"external_urls": track.ExternalURL,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"album_id": track.AlbumID,
|
"album_id": track.AlbumID,
|
||||||
"artist_id": track.ArtistID,
|
"artist_id": track.ArtistID,
|
||||||
"album_type": track.AlbumType,
|
"album_type": track.AlbumType,
|
||||||
|
"composer": track.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||||
@@ -2230,14 +2295,12 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
// When search_online is true, search for metadata from internet using the
|
// When search_online is true, search for metadata from internet using the
|
||||||
// configured metadata-provider priority.
|
// configured metadata-provider priority.
|
||||||
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
|
if req.SearchOnline {
|
||||||
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
|
|
||||||
searchQuery := req.TrackName + " " + req.ArtistName
|
|
||||||
found := false
|
found := false
|
||||||
|
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
||||||
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||||
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
|
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
|
||||||
@@ -2245,17 +2308,23 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
searchQuery := buildReEnrichSearchQuery(req)
|
||||||
if searchErr == nil && len(tracks) > 0 {
|
if searchQuery != "" {
|
||||||
track := selectBestReEnrichTrack(req, tracks)
|
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
|
||||||
if track != nil {
|
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||||
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
if searchErr == nil && len(tracks) > 0 {
|
||||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
track := selectBestReEnrichTrack(req, tracks)
|
||||||
applyReEnrichTrackMetadata(&req, *track)
|
if track != nil {
|
||||||
found = true
|
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||||
|
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
||||||
|
applyReEnrichTrackMetadata(&req, *track)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
} else if searchErr != nil {
|
||||||
|
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||||
}
|
}
|
||||||
} else if searchErr != nil {
|
} else {
|
||||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get extended metadata from Deezer if not already set
|
// Try to get extended metadata from Deezer if not already set
|
||||||
@@ -2374,12 +2443,16 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
"duration_ms": req.DurationMs,
|
"duration_ms": req.DurationMs,
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("basic_tags") {
|
if req.shouldUpdateField("basic_tags") {
|
||||||
|
enrichedMeta["track_name"] = req.TrackName
|
||||||
|
enrichedMeta["artist_name"] = req.ArtistName
|
||||||
enrichedMeta["album_name"] = req.AlbumName
|
enrichedMeta["album_name"] = req.AlbumName
|
||||||
enrichedMeta["album_artist"] = req.AlbumArtist
|
enrichedMeta["album_artist"] = req.AlbumArtist
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("track_info") {
|
if req.shouldUpdateField("track_info") {
|
||||||
enrichedMeta["track_number"] = req.TrackNumber
|
enrichedMeta["track_number"] = req.TrackNumber
|
||||||
|
enrichedMeta["total_tracks"] = req.TotalTracks
|
||||||
enrichedMeta["disc_number"] = req.DiscNumber
|
enrichedMeta["disc_number"] = req.DiscNumber
|
||||||
|
enrichedMeta["total_discs"] = req.TotalDiscs
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("release_info") {
|
if req.shouldUpdateField("release_info") {
|
||||||
enrichedMeta["release_date"] = req.ReleaseDate
|
enrichedMeta["release_date"] = req.ReleaseDate
|
||||||
@@ -2392,6 +2465,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
enrichedMeta["genre"] = req.Genre
|
enrichedMeta["genre"] = req.Genre
|
||||||
enrichedMeta["label"] = req.Label
|
enrichedMeta["label"] = req.Label
|
||||||
enrichedMeta["copyright"] = req.Copyright
|
enrichedMeta["copyright"] = req.Copyright
|
||||||
|
enrichedMeta["composer"] = req.Composer
|
||||||
}
|
}
|
||||||
|
|
||||||
if isFlac {
|
if isFlac {
|
||||||
@@ -2403,12 +2477,16 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
ArtistTagMode: req.ArtistTagMode,
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("basic_tags") {
|
if req.shouldUpdateField("basic_tags") {
|
||||||
|
metadata.Title = req.TrackName
|
||||||
|
metadata.Artist = req.ArtistName
|
||||||
metadata.Album = req.AlbumName
|
metadata.Album = req.AlbumName
|
||||||
metadata.AlbumArtist = req.AlbumArtist
|
metadata.AlbumArtist = req.AlbumArtist
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("track_info") {
|
if req.shouldUpdateField("track_info") {
|
||||||
metadata.TrackNumber = req.TrackNumber
|
metadata.TrackNumber = req.TrackNumber
|
||||||
|
metadata.TotalTracks = req.TotalTracks
|
||||||
metadata.DiscNumber = req.DiscNumber
|
metadata.DiscNumber = req.DiscNumber
|
||||||
|
metadata.TotalDiscs = req.TotalDiscs
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("release_info") {
|
if req.shouldUpdateField("release_info") {
|
||||||
metadata.Date = req.ReleaseDate
|
metadata.Date = req.ReleaseDate
|
||||||
@@ -2421,6 +2499,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
metadata.Genre = req.Genre
|
metadata.Genre = req.Genre
|
||||||
metadata.Label = req.Label
|
metadata.Label = req.Label
|
||||||
metadata.Copyright = req.Copyright
|
metadata.Copyright = req.Copyright
|
||||||
|
metadata.Composer = req.Composer
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(coverDataBytes) > 0 {
|
if len(coverDataBytes) > 0 {
|
||||||
@@ -2471,7 +2550,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -2485,7 +2564,7 @@ func InitExtensionSystem(extensionsDir, dataDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadExtensionsFromDir(dirPath string) (string, error) {
|
func LoadExtensionsFromDir(dirPath string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
|
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
@@ -2506,7 +2585,7 @@ func LoadExtensionsFromDir(dirPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadExtensionFromPath(filePath string) (string, error) {
|
func LoadExtensionFromPath(filePath string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.LoadExtensionFromFile(filePath)
|
ext, err := manager.LoadExtensionFromFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -2529,17 +2608,17 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UnloadExtensionByID(extensionID string) error {
|
func UnloadExtensionByID(extensionID string) error {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
return manager.UnloadExtension(extensionID)
|
return manager.UnloadExtension(extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RemoveExtensionByID(extensionID string) error {
|
func RemoveExtensionByID(extensionID string) error {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
return manager.RemoveExtension(extensionID)
|
return manager.RemoveExtension(extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpgradeExtensionFromPath(filePath string) (string, error) {
|
func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.UpgradeExtension(filePath)
|
ext, err := manager.UpgradeExtension(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -2561,17 +2640,17 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
|
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
return manager.CheckExtensionUpgradeJSON(filePath)
|
return manager.CheckExtensionUpgradeJSON(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetInstalledExtensions() (string, error) {
|
func GetInstalledExtensions() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
return manager.GetInstalledExtensionsJSON()
|
return manager.GetInstalledExtensionsJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
|
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
return manager.SetExtensionEnabled(extensionID, enabled)
|
return manager.SetExtensionEnabled(extensionID, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2594,6 +2673,30 @@ func GetProviderPriorityJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetExtensionFallbackProviderIDsJSON(providerIDsJSON string) error {
|
||||||
|
if strings.TrimSpace(providerIDsJSON) == "" {
|
||||||
|
SetExtensionFallbackProviderIDs(nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerIDs []string
|
||||||
|
if err := json.Unmarshal([]byte(providerIDsJSON), &providerIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs(providerIDs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetExtensionFallbackProviderIDsJSON() (string, error) {
|
||||||
|
providerIDs := GetExtensionFallbackProviderIDs()
|
||||||
|
jsonBytes, err := json.Marshal(providerIDs)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
||||||
var priority []string
|
var priority []string
|
||||||
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
||||||
@@ -2636,12 +2739,12 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
return manager.InitializeExtension(extensionID, settings)
|
return manager.InitializeExtension(extensionID, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
tracks, err := manager.SearchTracksWithExtensions(query, limit)
|
tracks, err := manager.SearchTracksWithExtensions(query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -2656,7 +2759,7 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
|
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
|
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -2703,12 +2806,12 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CleanupExtensions() {
|
func CleanupExtensions() {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
manager.UnloadAllExtensions()
|
manager.UnloadAllExtensions()
|
||||||
}
|
}
|
||||||
|
|
||||||
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
result, err := manager.InvokeAction(extensionID, actionName)
|
result, err := manager.InvokeAction(extensionID, actionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -2845,7 +2948,7 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return trackJSON, nil
|
return trackJSON, nil
|
||||||
@@ -2860,7 +2963,7 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
|||||||
return trackJSON, fmt.Errorf("failed to parse track: %w", err)
|
return trackJSON, fmt.Errorf("failed to parse track: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := newExtensionProviderWrapper(ext)
|
||||||
enrichedTrack, err := provider.EnrichTrack(&track)
|
enrichedTrack, err := provider.EnrichTrack(&track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return trackJSON, nil
|
return trackJSON, nil
|
||||||
@@ -2875,7 +2978,7 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
|
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -2892,7 +2995,7 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := newExtensionProviderWrapper(ext)
|
||||||
tracks, err := provider.CustomSearch(query, options)
|
tracks, err := provider.CustomSearch(query, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -2910,11 +3013,14 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
|||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
|
"total_tracks": track.TotalTracks,
|
||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
|
"total_discs": track.TotalDiscs,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
"item_type": track.ItemType,
|
"item_type": track.ItemType,
|
||||||
"album_type": track.AlbumType,
|
"album_type": track.AlbumType,
|
||||||
|
"composer": track.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2927,7 +3033,7 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetSearchProvidersJSON() (string, error) {
|
func GetSearchProvidersJSON() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
providers := manager.GetSearchProviders()
|
providers := manager.GetSearchProviders()
|
||||||
|
|
||||||
result := make([]map[string]interface{}, 0, len(providers))
|
result := make([]map[string]interface{}, 0, len(providers))
|
||||||
@@ -2950,7 +3056,7 @@ func GetSearchProvidersJSON() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func HandleURLWithExtensionJSON(url string) (string, error) {
|
func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
resultWithID, err := manager.HandleURLWithExtension(url)
|
resultWithID, err := manager.HandleURLWithExtension(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -2981,9 +3087,12 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"images": result.Track.ResolvedCoverURL(),
|
"images": result.Track.ResolvedCoverURL(),
|
||||||
"release_date": result.Track.ReleaseDate,
|
"release_date": result.Track.ReleaseDate,
|
||||||
"track_number": result.Track.TrackNumber,
|
"track_number": result.Track.TrackNumber,
|
||||||
|
"total_tracks": result.Track.TotalTracks,
|
||||||
"disc_number": result.Track.DiscNumber,
|
"disc_number": result.Track.DiscNumber,
|
||||||
|
"total_discs": result.Track.TotalDiscs,
|
||||||
"isrc": result.Track.ISRC,
|
"isrc": result.Track.ISRC,
|
||||||
"provider_id": result.Track.ProviderID,
|
"provider_id": result.Track.ProviderID,
|
||||||
|
"composer": result.Track.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3000,11 +3109,14 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
|
"total_tracks": track.TotalTracks,
|
||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
|
"total_discs": track.TotalDiscs,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
"item_type": track.ItemType,
|
"item_type": track.ItemType,
|
||||||
"album_type": track.AlbumType,
|
"album_type": track.AlbumType,
|
||||||
|
"composer": track.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response["tracks"] = tracks
|
response["tracks"] = tracks
|
||||||
@@ -3090,10 +3202,13 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
|
"total_tracks": track.TotalTracks,
|
||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
|
"total_discs": track.TotalDiscs,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
"spotify_id": track.SpotifyID,
|
"spotify_id": track.SpotifyID,
|
||||||
|
"composer": track.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
artistResponse["top_tracks"] = topTracks
|
artistResponse["top_tracks"] = topTracks
|
||||||
@@ -3111,7 +3226,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FindURLHandlerJSON(url string) string {
|
func FindURLHandlerJSON(url string) string {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
handler := manager.FindURLHandler(url)
|
handler := manager.FindURLHandler(url)
|
||||||
if handler == nil {
|
if handler == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -3120,7 +3235,7 @@ func FindURLHandlerJSON(url string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -3133,7 +3248,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := newExtensionProviderWrapper(ext)
|
||||||
album, err := provider.GetAlbum(albumID)
|
album, err := provider.GetAlbum(albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -3163,11 +3278,14 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
"cover_url": trackCover,
|
"cover_url": trackCover,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": trackNum,
|
"track_number": trackNum,
|
||||||
|
"total_tracks": track.TotalTracks,
|
||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
|
"total_discs": track.TotalDiscs,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
"item_type": track.ItemType,
|
"item_type": track.ItemType,
|
||||||
"album_type": track.AlbumType,
|
"album_type": track.AlbumType,
|
||||||
|
"composer": track.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3193,7 +3311,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
|
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -3264,11 +3382,14 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
"cover_url": trackCover,
|
"cover_url": trackCover,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
|
"total_tracks": track.TotalTracks,
|
||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
|
"total_discs": track.TotalDiscs,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
"item_type": track.ItemType,
|
"item_type": track.ItemType,
|
||||||
"album_type": track.AlbumType,
|
"album_type": track.AlbumType,
|
||||||
|
"composer": track.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3291,7 +3412,7 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -3301,7 +3422,7 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := newExtensionProviderWrapper(ext)
|
||||||
artist, err := provider.GetArtist(artistID)
|
artist, err := provider.GetArtist(artistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -3375,10 +3496,13 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
|
"total_tracks": track.TotalTracks,
|
||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
|
"total_discs": track.TotalDiscs,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
"spotify_id": track.SpotifyID,
|
"spotify_id": track.SpotifyID,
|
||||||
|
"composer": track.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response["top_tracks"] = topTracks
|
response["top_tracks"] = topTracks
|
||||||
@@ -3393,7 +3517,7 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetURLHandlersJSON() (string, error) {
|
func GetURLHandlersJSON() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
handlers := manager.GetURLHandlers()
|
handlers := manager.GetURLHandlers()
|
||||||
|
|
||||||
result := make([]map[string]interface{}, 0, len(handlers))
|
result := make([]map[string]interface{}, 0, len(handlers))
|
||||||
@@ -3421,7 +3545,7 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
result, err := manager.RunPostProcessing(filePath, metadata)
|
result, err := manager.RunPostProcessing(filePath, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -3450,7 +3574,7 @@ func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
result, err := manager.RunPostProcessingV2(input, metadata)
|
result, err := manager.RunPostProcessingV2(input, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -3465,7 +3589,7 @@ func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetPostProcessingProvidersJSON() (string, error) {
|
func GetPostProcessingProvidersJSON() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
providers := manager.GetPostProcessingProviders()
|
providers := manager.GetPostProcessingProviders()
|
||||||
|
|
||||||
result := make([]map[string]interface{}, 0, len(providers))
|
result := make([]map[string]interface{}, 0, len(providers))
|
||||||
@@ -3631,7 +3755,7 @@ func ClearStoreCacheJSON() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
|
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
+123
-6
@@ -2,6 +2,21 @@ package gobackend
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
|
||||||
|
|
||||||
|
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||||
|
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := GetExtensionFallbackProviderIDs(); got != nil {
|
||||||
|
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||||
req := DownloadRequest{
|
req := DownloadRequest{
|
||||||
TrackName: "Bonus Track",
|
TrackName: "Bonus Track",
|
||||||
@@ -114,6 +129,38 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Track",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := DownloadResult{
|
||||||
|
Title: "Track",
|
||||||
|
Artist: "Artist",
|
||||||
|
DecryptionKey: "00112233",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildDownloadSuccessResponse(
|
||||||
|
req,
|
||||||
|
result,
|
||||||
|
"amazon",
|
||||||
|
"ok",
|
||||||
|
"/tmp/test.m4a",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.Decryption == nil {
|
||||||
|
t.Fatal("expected decryption descriptor to be present")
|
||||||
|
}
|
||||||
|
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||||
|
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
|
||||||
|
}
|
||||||
|
if resp.Decryption.Key != result.DecryptionKey {
|
||||||
|
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||||
req := reEnrichRequest{
|
req := reEnrichRequest{
|
||||||
SpotifyID: "spotify-track-id",
|
SpotifyID: "spotify-track-id",
|
||||||
@@ -195,13 +242,11 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
|||||||
|
|
||||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||||
|
|
||||||
// Title and Artist are never written by re-enrich (they are search keys
|
if metadata["TITLE"] != "Song" {
|
||||||
// preserved as-is from the file).
|
t.Fatalf("title = %q", metadata["TITLE"])
|
||||||
if _, exists := metadata["TITLE"]; exists {
|
|
||||||
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
|
|
||||||
}
|
}
|
||||||
if _, exists := metadata["ARTIST"]; exists {
|
if metadata["ARTIST"] != "Artist" {
|
||||||
t.Fatalf("ARTIST should not be in metadata: %#v", metadata)
|
t.Fatalf("artist = %q", metadata["ARTIST"])
|
||||||
}
|
}
|
||||||
if metadata["ALBUM"] != "Album" {
|
if metadata["ALBUM"] != "Album" {
|
||||||
t.Fatalf("album = %q", metadata["ALBUM"])
|
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||||
@@ -224,3 +269,75 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Sign of the Times",
|
||||||
|
ArtistName: "Unknown Artist",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := buildReEnrichSearchQuery(req)
|
||||||
|
if query != "Sign of the Times" {
|
||||||
|
t.Fatalf("query = %q", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = reEnrichRequest{
|
||||||
|
TrackName: "Unknown Title",
|
||||||
|
ArtistName: "Unknown Artist",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
}
|
||||||
|
query = buildReEnrichSearchQuery(req)
|
||||||
|
if query != "Harry Styles" {
|
||||||
|
t.Fatalf("fallback album query = %q", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
|
||||||
|
req := reEnrichRequest{}
|
||||||
|
|
||||||
|
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||||
|
Name: "Resolved Song",
|
||||||
|
Artists: "Resolved Artist",
|
||||||
|
TrackNumber: 7,
|
||||||
|
TotalTracks: 12,
|
||||||
|
DiscNumber: 2,
|
||||||
|
TotalDiscs: 3,
|
||||||
|
Composer: "Composer",
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.TrackNumber != 7 || req.TotalTracks != 12 {
|
||||||
|
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
|
||||||
|
}
|
||||||
|
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
|
||||||
|
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
|
||||||
|
}
|
||||||
|
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
|
||||||
|
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
|
||||||
|
}
|
||||||
|
if req.Composer != "Composer" {
|
||||||
|
t.Fatalf("composer = %q", req.Composer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackNumber: 7,
|
||||||
|
TotalTracks: 12,
|
||||||
|
DiscNumber: 2,
|
||||||
|
TotalDiscs: 3,
|
||||||
|
Composer: "Composer",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||||
|
|
||||||
|
if metadata["TRACKNUMBER"] != "7/12" {
|
||||||
|
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
|
||||||
|
}
|
||||||
|
if metadata["DISCNUMBER"] != "2/3" {
|
||||||
|
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
|
||||||
|
}
|
||||||
|
if metadata["COMPOSER"] != "Composer" {
|
||||||
|
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ func compareVersions(v1, v2 string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadedExtension struct {
|
type loadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
runtime *ExtensionRuntime
|
runtime *extensionRuntime
|
||||||
initialized bool
|
initialized bool
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@@ -73,7 +73,7 @@ func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
|
||||||
if ext.VM == nil || ext.runtime == nil {
|
if ext.VM == nil || ext.runtime == nil {
|
||||||
if err := initializeVMLocked(ext); err != nil {
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
@@ -100,14 +100,14 @@ func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
func (ext *loadedExtension) ensureRuntimeReady() error {
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
return ensureRuntimeReadyLocked(ext, true)
|
return ensureRuntimeReadyLocked(ext, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||||
ext.VMMu.Unlock()
|
ext.VMMu.Unlock()
|
||||||
@@ -116,28 +116,28 @@ func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
|||||||
return ext.VM, nil
|
return ext.VM, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionManager struct {
|
type extensionManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
extensions map[string]*LoadedExtension
|
extensions map[string]*loadedExtension
|
||||||
extensionsDir string
|
extensionsDir string
|
||||||
dataDir string
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalExtManager *ExtensionManager
|
globalExtManager *extensionManager
|
||||||
globalExtManagerOnce sync.Once
|
globalExtManagerOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetExtensionManager() *ExtensionManager {
|
func getExtensionManager() *extensionManager {
|
||||||
globalExtManagerOnce.Do(func() {
|
globalExtManagerOnce.Do(func() {
|
||||||
globalExtManager = &ExtensionManager{
|
globalExtManager = &extensionManager{
|
||||||
extensions: make(map[string]*LoadedExtension),
|
extensions: make(map[string]*loadedExtension),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalExtManager
|
return globalExtManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -272,7 +272,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Enabled: false, // New extensions start disabled
|
Enabled: false, // New extensions start disabled
|
||||||
@@ -292,7 +292,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeVMLocked(ext *LoadedExtension) error {
|
func initializeVMLocked(ext *loadedExtension) error {
|
||||||
ext.VM = nil
|
ext.VM = nil
|
||||||
ext.runtime = nil
|
ext.runtime = nil
|
||||||
ext.initialized = false
|
ext.initialized = false
|
||||||
@@ -305,7 +305,7 @@ func initializeVMLocked(ext *LoadedExtension) error {
|
|||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
ext.runtime = runtime
|
ext.runtime = runtime
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
@@ -342,14 +342,14 @@ func initializeVMLocked(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
return initializeVMLocked(ext)
|
return initializeVMLocked(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeExtensionWithSettingsLocked(
|
func initializeExtensionWithSettingsLocked(
|
||||||
ext *LoadedExtension,
|
ext *loadedExtension,
|
||||||
settings map[string]interface{},
|
settings map[string]interface{},
|
||||||
) error {
|
) error {
|
||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
@@ -405,7 +405,7 @@ func initializeExtensionWithSettingsLocked(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCleanupLocked(ext *LoadedExtension) error {
|
func runCleanupLocked(ext *loadedExtension) error {
|
||||||
if ext.VM != nil {
|
if ext.VM != nil {
|
||||||
script := `
|
script := `
|
||||||
(function() {
|
(function() {
|
||||||
@@ -446,7 +446,7 @@ func runCleanupLocked(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func teardownVMLocked(ext *LoadedExtension) {
|
func teardownVMLocked(ext *loadedExtension) {
|
||||||
if err := runCleanupLocked(ext); err != nil {
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||||
}
|
}
|
||||||
@@ -461,7 +461,7 @@ func teardownVMLocked(ext *LoadedExtension) {
|
|||||||
ext.initialized = false
|
ext.initialized = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateExtensionLoad(ext *LoadedExtension) error {
|
func validateExtensionLoad(ext *loadedExtension) error {
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ func validateExtensionLoad(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -491,7 +491,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -502,18 +502,18 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
func (m *extensionManager) GetAllExtensions() []*loadedExtension {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
result := make([]*LoadedExtension, 0, len(m.extensions))
|
result := make([]*loadedExtension, 0, len(m.extensions))
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
result = append(result, ext)
|
result = append(result, ext)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -547,7 +547,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||||
var loaded []string
|
var loaded []string
|
||||||
var errors []error
|
var errors []error
|
||||||
|
|
||||||
@@ -585,7 +585,7 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
return loaded, errors
|
return loaded, errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -615,7 +615,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Enabled: false, // Will be restored from settings store
|
Enabled: false, // Will be restored from settings store
|
||||||
@@ -643,7 +643,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||||
ext, err := m.GetExtension(extensionID)
|
ext, err := m.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -663,7 +663,7 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only allows upgrades (new version > current version), not downgrades
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -777,7 +777,7 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: newManifest.Name,
|
ID: newManifest.Name,
|
||||||
Manifest: newManifest,
|
Manifest: newManifest,
|
||||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||||
@@ -812,7 +812,7 @@ type ExtensionUpgradeInfo struct {
|
|||||||
IsInstalled bool `json:"is_installed"`
|
IsInstalled bool `json:"is_installed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -871,7 +871,7 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -885,7 +885,7 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||||
extensions := m.GetAllExtensions()
|
extensions := m.GetAllExtensions()
|
||||||
|
|
||||||
type ExtensionInfo struct {
|
type ExtensionInfo struct {
|
||||||
@@ -982,7 +982,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -1000,7 +1000,7 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return initializeExtensionWithSettingsLocked(ext, settings)
|
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
func (m *extensionManager) CleanupExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -1022,7 +1022,7 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
func (m *extensionManager) UnloadAllExtensions() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
extensionIDs := make([]string, 0, len(m.extensions))
|
extensionIDs := make([]string, 0, len(m.extensions))
|
||||||
for id := range m.extensions {
|
for id := range m.extensions {
|
||||||
@@ -1037,7 +1037,7 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
|||||||
GoLog("[Extension] All extensions unloaded\n")
|
GoLog("[Extension] All extensions unloaded\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ type ExtTrackMetadata struct {
|
|||||||
Images string `json:"images,omitempty"`
|
Images string `json:"images,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
ProviderID string `json:"provider_id"`
|
ProviderID string `json:"provider_id"`
|
||||||
ItemType string `json:"item_type,omitempty"`
|
ItemType string `json:"item_type,omitempty"`
|
||||||
@@ -41,6 +43,7 @@ type ExtTrackMetadata struct {
|
|||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||||
@@ -93,6 +96,15 @@ type ExtDownloadURLResult struct {
|
|||||||
SampleRate int `json:"sample_rate,omitempty"`
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DownloadDecryptionInfo struct {
|
||||||
|
Strategy string `json:"strategy,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
IV string `json:"iv,omitempty"`
|
||||||
|
InputFormat string `json:"input_format,omitempty"`
|
||||||
|
OutputExtension string `json:"output_extension,omitempty"`
|
||||||
|
Options map[string]interface{} `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ExtDownloadResult struct {
|
type ExtDownloadResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
@@ -101,31 +113,105 @@ type ExtDownloadResult struct {
|
|||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Artist string `json:"artist,omitempty"`
|
Artist string `json:"artist,omitempty"`
|
||||||
Album string `json:"album,omitempty"`
|
Album string `json:"album,omitempty"`
|
||||||
AlbumArtist string `json:"album_artist,omitempty"`
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||||
|
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionProviderWrapper struct {
|
const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key"
|
||||||
extension *LoadedExtension
|
|
||||||
|
func cloneDownloadDecryptionInfo(info *DownloadDecryptionInfo) *DownloadDecryptionInfo {
|
||||||
|
if info == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := &DownloadDecryptionInfo{
|
||||||
|
Strategy: strings.TrimSpace(info.Strategy),
|
||||||
|
Key: strings.TrimSpace(info.Key),
|
||||||
|
IV: strings.TrimSpace(info.IV),
|
||||||
|
InputFormat: strings.TrimSpace(info.InputFormat),
|
||||||
|
OutputExtension: strings.TrimSpace(info.OutputExtension),
|
||||||
|
}
|
||||||
|
if len(info.Options) > 0 {
|
||||||
|
cloned.Options = make(map[string]interface{}, len(info.Options))
|
||||||
|
for key, value := range info.Options {
|
||||||
|
cloned.Options[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDownloadDecryptionStrategy(strategy string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(strategy)) {
|
||||||
|
case "", "ffmpeg.mov_key", "ffmpeg_mov_key", "mov_decryption_key", "mp4_decryption_key", "ffmpeg.mp4_decryption_key":
|
||||||
|
return genericFFmpegMOVDecryptionStrategy
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(strategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDownloadDecryptionInfo(info *DownloadDecryptionInfo, legacyKey string) *DownloadDecryptionInfo {
|
||||||
|
normalized := cloneDownloadDecryptionInfo(info)
|
||||||
|
trimmedLegacyKey := strings.TrimSpace(legacyKey)
|
||||||
|
|
||||||
|
if normalized == nil {
|
||||||
|
if trimmedLegacyKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &DownloadDecryptionInfo{
|
||||||
|
Strategy: genericFFmpegMOVDecryptionStrategy,
|
||||||
|
Key: trimmedLegacyKey,
|
||||||
|
InputFormat: "mov",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.Strategy = normalizeDownloadDecryptionStrategy(normalized.Strategy)
|
||||||
|
if normalized.Key == "" && trimmedLegacyKey != "" {
|
||||||
|
normalized.Key = trimmedLegacyKey
|
||||||
|
}
|
||||||
|
if normalized.Strategy == "" && normalized.Key != "" {
|
||||||
|
normalized.Strategy = genericFFmpegMOVDecryptionStrategy
|
||||||
|
}
|
||||||
|
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.InputFormat == "" {
|
||||||
|
normalized.InputFormat = "mov"
|
||||||
|
}
|
||||||
|
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.Key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedDownloadDecryptionKey(info *DownloadDecryptionInfo, legacyKey string) string {
|
||||||
|
if normalized := normalizeDownloadDecryptionInfo(info, legacyKey); normalized != nil {
|
||||||
|
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy {
|
||||||
|
return normalized.Key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(legacyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
type extensionProviderWrapper struct {
|
||||||
|
extension *loadedExtension
|
||||||
vm *goja.Runtime
|
vm *goja.Runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
|
func newExtensionProviderWrapper(ext *loadedExtension) *extensionProviderWrapper {
|
||||||
return &ExtensionProviderWrapper{
|
return &extensionProviderWrapper{
|
||||||
extension: ext,
|
extension: ext,
|
||||||
vm: ext.VM,
|
vm: ext.VM,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
func (p *extensionProviderWrapper) lockReadyVM() error {
|
||||||
vm, err := p.extension.lockReadyVM()
|
vm, err := p.extension.lockReadyVM()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -134,7 +220,7 @@ func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -194,7 +280,7 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
return &searchResult, nil
|
return &searchResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -243,7 +329,7 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
|||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -295,7 +381,7 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
|||||||
return &album, nil
|
return &album, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -350,7 +436,7 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
return &artist, nil
|
return &artist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
@@ -412,7 +498,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
return &enrichedTrack, nil
|
return &enrichedTrack, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
|
func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -460,7 +546,7 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
|||||||
return &availability, nil
|
return &availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -510,7 +596,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
|
|
||||||
const ExtDownloadTimeout = DownloadTimeout
|
const ExtDownloadTimeout = DownloadTimeout
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -597,44 +683,52 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID
|
|||||||
ErrorType: "internal_error",
|
ErrorType: "internal_error",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
downloadResult.Decryption = normalizeDownloadDecryptionInfo(
|
||||||
|
downloadResult.Decryption,
|
||||||
|
downloadResult.DecryptionKey,
|
||||||
|
)
|
||||||
|
downloadResult.DecryptionKey = normalizedDownloadDecryptionKey(
|
||||||
|
downloadResult.Decryption,
|
||||||
|
downloadResult.DecryptionKey,
|
||||||
|
)
|
||||||
|
|
||||||
return &downloadResult, nil
|
return &downloadResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
func (m *extensionManager) GetMetadataProviders() []*extensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
var providers []*ExtensionProviderWrapper
|
var providers []*extensionProviderWrapper
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" {
|
if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" {
|
||||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
func (m *extensionManager) GetDownloadProviders() []*extensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
var providers []*ExtensionProviderWrapper
|
var providers []*extensionProviderWrapper
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" {
|
if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" {
|
||||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
|
func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||||
providers := m.GetMetadataProviders()
|
providers := m.GetMetadataProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
|
providerByID := make(map[string]*extensionProviderWrapper, len(providers))
|
||||||
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
|
orderedProviders := make([]*extensionProviderWrapper, 0, len(providers))
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
providerByID[provider.extension.ID] = provider
|
providerByID[provider.extension.ID] = provider
|
||||||
}
|
}
|
||||||
@@ -673,6 +767,9 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
|||||||
var providerPriority []string
|
var providerPriority []string
|
||||||
var providerPriorityMu sync.RWMutex
|
var providerPriorityMu sync.RWMutex
|
||||||
|
|
||||||
|
var extensionFallbackProviderIDs []string
|
||||||
|
var extensionFallbackProviderIDsMu sync.RWMutex
|
||||||
|
|
||||||
var metadataProviderPriority []string
|
var metadataProviderPriority []string
|
||||||
var metadataProviderPriorityMu sync.RWMutex
|
var metadataProviderPriorityMu sync.RWMutex
|
||||||
|
|
||||||
@@ -681,8 +778,8 @@ var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
|
|||||||
func SetProviderPriority(providerIDs []string) {
|
func SetProviderPriority(providerIDs []string) {
|
||||||
providerPriorityMu.Lock()
|
providerPriorityMu.Lock()
|
||||||
defer providerPriorityMu.Unlock()
|
defer providerPriorityMu.Unlock()
|
||||||
providerPriority = providerIDs
|
providerPriority = sanitizeDownloadProviderPriority(providerIDs)
|
||||||
GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
|
GoLog("[Extension] Download provider priority set: %v\n", providerPriority)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetProviderPriority() []string {
|
func GetProviderPriority() []string {
|
||||||
@@ -690,7 +787,7 @@ func GetProviderPriority() []string {
|
|||||||
defer providerPriorityMu.RUnlock()
|
defer providerPriorityMu.RUnlock()
|
||||||
|
|
||||||
if len(providerPriority) == 0 {
|
if len(providerPriority) == 0 {
|
||||||
return []string{"tidal", "qobuz", "deezer"}
|
return []string{"tidal", "qobuz"}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]string, len(providerPriority))
|
result := make([]string, len(providerPriority))
|
||||||
@@ -698,6 +795,102 @@ func GetProviderPriority() []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeDownloadProviderPriority(providerIDs []string) []string {
|
||||||
|
sanitized := make([]string, 0, len(providerIDs)+2)
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
|
||||||
|
for _, providerID := range providerIDs {
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
if providerID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedBuiltIn := strings.ToLower(providerID)
|
||||||
|
if normalizedBuiltIn == "deezer" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isBuiltInDownloadProvider(normalizedBuiltIn) {
|
||||||
|
providerID = normalizedBuiltIn
|
||||||
|
}
|
||||||
|
|
||||||
|
seenKey := strings.ToLower(providerID)
|
||||||
|
if _, exists := seen[seenKey]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[seenKey] = struct{}{}
|
||||||
|
sanitized = append(sanitized, providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, providerID := range []string{"tidal", "qobuz"} {
|
||||||
|
if _, exists := seen[providerID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[providerID] = struct{}{}
|
||||||
|
sanitized = append(sanitized, providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetExtensionFallbackProviderIDs(providerIDs []string) {
|
||||||
|
extensionFallbackProviderIDsMu.Lock()
|
||||||
|
defer extensionFallbackProviderIDsMu.Unlock()
|
||||||
|
|
||||||
|
if providerIDs == nil {
|
||||||
|
extensionFallbackProviderIDs = nil
|
||||||
|
GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized := make([]string, 0, len(providerIDs))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, providerID := range providerIDs {
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
if providerID == "" || isBuiltInDownloadProvider(strings.ToLower(providerID)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[providerID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[providerID] = struct{}{}
|
||||||
|
sanitized = append(sanitized, providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionFallbackProviderIDs = sanitized
|
||||||
|
GoLog("[Extension] Extension fallback providers set: %v\n", sanitized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetExtensionFallbackProviderIDs() []string {
|
||||||
|
extensionFallbackProviderIDsMu.RLock()
|
||||||
|
defer extensionFallbackProviderIDsMu.RUnlock()
|
||||||
|
|
||||||
|
if extensionFallbackProviderIDs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, len(extensionFallbackProviderIDs))
|
||||||
|
copy(result, extensionFallbackProviderIDs)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExtensionFallbackAllowed(providerID string) bool {
|
||||||
|
if isBuiltInDownloadProvider(strings.ToLower(providerID)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := GetExtensionFallbackProviderIDs()
|
||||||
|
if allowed == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowedProviderID := range allowed {
|
||||||
|
if allowedProviderID == providerID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func SetMetadataProviderPriority(providerIDs []string) {
|
func SetMetadataProviderPriority(providerIDs []string) {
|
||||||
metadataProviderPriorityMu.Lock()
|
metadataProviderPriorityMu.Lock()
|
||||||
defer metadataProviderPriorityMu.Unlock()
|
defer metadataProviderPriorityMu.Unlock()
|
||||||
@@ -749,6 +942,15 @@ func isBuiltInProvider(providerID string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isBuiltInDownloadProvider(providerID string) bool {
|
||||||
|
switch providerID {
|
||||||
|
case "tidal", "qobuz":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
|
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
|
||||||
deezerID := ""
|
deezerID := ""
|
||||||
tidalID := ""
|
tidalID := ""
|
||||||
@@ -775,7 +977,9 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
|
|||||||
Images: track.Images,
|
Images: track.Images,
|
||||||
ReleaseDate: track.ReleaseDate,
|
ReleaseDate: track.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: track.TrackNumber,
|
||||||
|
TotalTracks: track.TotalTracks,
|
||||||
DiscNumber: track.DiscNumber,
|
DiscNumber: track.DiscNumber,
|
||||||
|
TotalDiscs: track.TotalDiscs,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
ProviderID: providerID,
|
ProviderID: providerID,
|
||||||
SpotifyID: prefixedID,
|
SpotifyID: prefixedID,
|
||||||
@@ -783,6 +987,7 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
|
|||||||
TidalID: tidalID,
|
TidalID: tidalID,
|
||||||
QobuzID: qobuzID,
|
QobuzID: qobuzID,
|
||||||
AlbumType: track.AlbumType,
|
AlbumType: track.AlbumType,
|
||||||
|
Composer: track.Composer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,13 +1029,13 @@ func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
|
func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
|
||||||
priority := GetMetadataProviderPriority()
|
priority := GetMetadataProviderPriority()
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
extensionProviders := make(map[string]*ExtensionProviderWrapper)
|
extensionProviders := make(map[string]*extensionProviderWrapper)
|
||||||
if includeExtensions {
|
if includeExtensions {
|
||||||
for _, provider := range m.GetMetadataProviders() {
|
for _, provider := range m.GetMetadataProviders() {
|
||||||
extensionProviders[provider.extension.ID] = provider
|
extensionProviders[provider.extension.ID] = provider
|
||||||
@@ -910,7 +1115,7 @@ func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit
|
|||||||
|
|
||||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||||
priority := GetProviderPriority()
|
priority := GetProviderPriority()
|
||||||
extManager := GetExtensionManager()
|
extManager := getExtensionManager()
|
||||||
strictMode := !req.UseFallback
|
strictMode := !req.UseFallback
|
||||||
selectedProvider := strings.TrimSpace(req.Service)
|
selectedProvider := strings.TrimSpace(req.Service)
|
||||||
|
|
||||||
@@ -924,7 +1129,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) {
|
if !strictMode && req.Service != "" && isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
|
||||||
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||||
newPriority := []string{req.Service}
|
newPriority := []string{req.Service}
|
||||||
for _, p := range priority {
|
for _, p := range priority {
|
||||||
@@ -934,7 +1139,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
priority = newPriority
|
priority = newPriority
|
||||||
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||||
} else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) {
|
} else if !strictMode && req.Service != "" && !isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
|
||||||
found := false
|
found := false
|
||||||
for _, p := range priority {
|
for _, p := range priority {
|
||||||
if strings.EqualFold(p, req.Service) {
|
if strings.EqualFold(p, req.Service) {
|
||||||
@@ -965,7 +1170,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
||||||
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
|
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := newExtensionProviderWrapper(ext)
|
||||||
trackMeta := &ExtTrackMetadata{
|
trackMeta := &ExtTrackMetadata{
|
||||||
ID: req.SpotifyID,
|
ID: req.SpotifyID,
|
||||||
Name: req.TrackName,
|
Name: req.TrackName,
|
||||||
@@ -975,8 +1180,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
ReleaseDate: req.ReleaseDate,
|
ReleaseDate: req.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: req.TrackNumber,
|
||||||
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: req.DiscNumber,
|
||||||
|
TotalDiscs: req.TotalDiscs,
|
||||||
ProviderID: req.Source,
|
ProviderID: req.Source,
|
||||||
|
Composer: req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
||||||
@@ -1041,10 +1249,22 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber)
|
GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber)
|
||||||
req.TrackNumber = enrichedTrack.TrackNumber
|
req.TrackNumber = enrichedTrack.TrackNumber
|
||||||
}
|
}
|
||||||
|
if enrichedTrack.TotalTracks > 0 && req.TotalTracks == 0 {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] TotalTracks from enrichment: %d\n", enrichedTrack.TotalTracks)
|
||||||
|
req.TotalTracks = enrichedTrack.TotalTracks
|
||||||
|
}
|
||||||
if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 {
|
if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||||
GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber)
|
GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber)
|
||||||
req.DiscNumber = enrichedTrack.DiscNumber
|
req.DiscNumber = enrichedTrack.DiscNumber
|
||||||
}
|
}
|
||||||
|
if enrichedTrack.TotalDiscs > 0 && req.TotalDiscs == 0 {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] TotalDiscs from enrichment: %d\n", enrichedTrack.TotalDiscs)
|
||||||
|
req.TotalDiscs = enrichedTrack.TotalDiscs
|
||||||
|
}
|
||||||
|
if enrichedTrack.Composer != "" && req.Composer == "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Composer from enrichment: %s\n", enrichedTrack.Composer)
|
||||||
|
req.Composer = enrichedTrack.Composer
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1077,9 +1297,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if track.TrackNumber > 0 && req.TrackNumber == 0 {
|
if track.TrackNumber > 0 && req.TrackNumber == 0 {
|
||||||
req.TrackNumber = track.TrackNumber
|
req.TrackNumber = track.TrackNumber
|
||||||
}
|
}
|
||||||
|
if track.TotalTracks > 0 && req.TotalTracks == 0 {
|
||||||
|
req.TotalTracks = track.TotalTracks
|
||||||
|
}
|
||||||
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||||
req.DiscNumber = track.DiscNumber
|
req.DiscNumber = track.DiscNumber
|
||||||
}
|
}
|
||||||
|
if track.TotalDiscs > 0 && req.TotalDiscs == 0 {
|
||||||
|
req.TotalDiscs = track.TotalDiscs
|
||||||
|
}
|
||||||
|
if track.Composer != "" && req.Composer == "" {
|
||||||
|
req.Composer = track.Composer
|
||||||
|
}
|
||||||
if track.CoverURL != "" && req.CoverURL == "" {
|
if track.CoverURL != "" && req.CoverURL == "" {
|
||||||
req.CoverURL = track.CoverURL
|
req.CoverURL = track.CoverURL
|
||||||
}
|
}
|
||||||
@@ -1125,7 +1354,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||||
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := newExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
trackID := req.SpotifyID
|
trackID := req.SpotifyID
|
||||||
|
|
||||||
@@ -1168,14 +1397,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
DecryptionKey: result.DecryptionKey,
|
DecryptionKey: result.DecryptionKey,
|
||||||
|
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
|
||||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.Manifest.SkipMetadataEnrichment {
|
if ext.Manifest.SkipMetadataEnrichment {
|
||||||
@@ -1273,14 +1505,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if skipBuiltIn && isBuiltInProvider(providerIDNormalized) {
|
if skipBuiltIn && isBuiltInDownloadProvider(providerIDNormalized) {
|
||||||
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isBuiltInDownloadProvider(providerIDNormalized) && !isExtensionFallbackAllowed(providerID) {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||||
|
|
||||||
if isBuiltInProvider(providerIDNormalized) {
|
if isBuiltInDownloadProvider(providerIDNormalized) {
|
||||||
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
|
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
|
||||||
req.ISRC != "" {
|
req.ISRC != "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||||
@@ -1346,7 +1583,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := newExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
|
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
|
||||||
if err != nil || !availability.Available {
|
if err != nil || !availability.Available {
|
||||||
@@ -1394,14 +1631,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
DecryptionKey: result.DecryptionKey,
|
DecryptionKey: result.DecryptionKey,
|
||||||
|
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
|
||||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.Manifest.SkipMetadataEnrichment {
|
if ext.Manifest.SkipMetadataEnrichment {
|
||||||
@@ -1534,24 +1774,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "deezer":
|
|
||||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
|
||||||
if deezerErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: deezerResult.FilePath,
|
|
||||||
BitDepth: deezerResult.BitDepth,
|
|
||||||
SampleRate: deezerResult.SampleRate,
|
|
||||||
Title: deezerResult.Title,
|
|
||||||
Artist: deezerResult.Artist,
|
|
||||||
Album: deezerResult.Album,
|
|
||||||
ReleaseDate: deezerResult.ReleaseDate,
|
|
||||||
TrackNumber: deezerResult.TrackNumber,
|
|
||||||
DiscNumber: deezerResult.DiscNumber,
|
|
||||||
ISRC: deezerResult.ISRC,
|
|
||||||
LyricsLRC: deezerResult.LyricsLRC,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = deezerErr
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown built-in provider: %s", providerID)
|
return nil, fmt.Errorf("unknown built-in provider: %s", providerID)
|
||||||
}
|
}
|
||||||
@@ -1579,6 +1801,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
LyricsLRC: result.LyricsLRC,
|
LyricsLRC: result.LyricsLRC,
|
||||||
DecryptionKey: result.DecryptionKey,
|
DecryptionKey: result.DecryptionKey,
|
||||||
|
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1594,12 +1817,15 @@ func buildOutputPath(req DownloadRequest) string {
|
|||||||
"album_artist": req.AlbumArtist,
|
"album_artist": req.AlbumArtist,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"track_number": req.TrackNumber,
|
"track_number": req.TrackNumber,
|
||||||
|
"total_tracks": req.TotalTracks,
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
"disc_number": req.DiscNumber,
|
"disc_number": req.DiscNumber,
|
||||||
|
"total_discs": req.TotalDiscs,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
"date": req.ReleaseDate,
|
"date": req.ReleaseDate,
|
||||||
"release_date": req.ReleaseDate,
|
"release_date": req.ReleaseDate,
|
||||||
"isrc": req.ISRC,
|
"isrc": req.ISRC,
|
||||||
|
"composer": req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||||
@@ -1617,19 +1843,24 @@ func buildOutputPath(req DownloadRequest) string {
|
|||||||
outputDir := req.OutputDir
|
outputDir := req.OutputDir
|
||||||
if strings.TrimSpace(outputDir) == "" {
|
if strings.TrimSpace(outputDir) == "" {
|
||||||
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
|
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
|
||||||
os.MkdirAll(outputDir, 0755)
|
|
||||||
AddAllowedDownloadDir(outputDir)
|
|
||||||
}
|
}
|
||||||
|
os.MkdirAll(outputDir, 0755)
|
||||||
|
AddAllowedDownloadDir(outputDir)
|
||||||
|
|
||||||
return filepath.Join(outputDir, filename+ext)
|
return filepath.Join(outputDir, filename+ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string {
|
func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
|
||||||
if strings.TrimSpace(req.OutputPath) != "" {
|
if strings.TrimSpace(req.OutputPath) != "" {
|
||||||
return strings.TrimSpace(req.OutputPath)
|
outputPath := strings.TrimSpace(req.OutputPath)
|
||||||
|
AddAllowedDownloadDir(filepath.Dir(outputPath))
|
||||||
|
return outputPath
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(req.OutputDir) != "" {
|
// SAF downloads hand extensions a detached output FD owned by the host.
|
||||||
|
// Extensions still need a real local temp file so Android can copy it into
|
||||||
|
// the target document after provider-specific post-processing completes.
|
||||||
|
if !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||||
return buildOutputPath(req)
|
return buildOutputPath(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1644,12 +1875,15 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
|
|||||||
"album_artist": req.AlbumArtist,
|
"album_artist": req.AlbumArtist,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"track_number": req.TrackNumber,
|
"track_number": req.TrackNumber,
|
||||||
|
"total_tracks": req.TotalTracks,
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
"disc_number": req.DiscNumber,
|
"disc_number": req.DiscNumber,
|
||||||
|
"total_discs": req.TotalDiscs,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
"date": req.ReleaseDate,
|
"date": req.ReleaseDate,
|
||||||
"release_date": req.ReleaseDate,
|
"release_date": req.ReleaseDate,
|
||||||
"isrc": req.ISRC,
|
"isrc": req.ISRC,
|
||||||
|
"composer": req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||||
@@ -1667,7 +1901,19 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
|
|||||||
return filepath.Join(tempDir, filename+outputExt)
|
return filepath.Join(tempDir, filename+outputExt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
func canEmbedGenreLabel(filePath string) bool {
|
||||||
|
path := strings.TrimSpace(filePath)
|
||||||
|
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir() && info.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||||
if !p.extension.Manifest.HasCustomSearch() {
|
if !p.extension.Manifest.HasCustomSearch() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -1749,7 +1995,7 @@ type ExtURLHandleResult struct {
|
|||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||||
if !p.extension.Manifest.HasURLHandler() {
|
if !p.extension.Manifest.HasURLHandler() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -1835,7 +2081,7 @@ type MatchTrackResult struct {
|
|||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
|
func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
|
||||||
if !p.extension.Manifest.HasCustomMatching() {
|
if !p.extension.Manifest.HasCustomMatching() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -1906,7 +2152,7 @@ type PostProcessInput struct {
|
|||||||
|
|
||||||
const PostProcessTimeout = 2 * time.Minute
|
const PostProcessTimeout = 2 * time.Minute
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||||
if !p.extension.Manifest.HasPostProcessing() {
|
if !p.extension.Manifest.HasPostProcessing() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -1969,7 +2215,7 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
|||||||
return &postResult, nil
|
return &postResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||||
if !p.extension.Manifest.HasPostProcessing() {
|
if !p.extension.Manifest.HasPostProcessing() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -2039,39 +2285,39 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
|||||||
return &postResult, nil
|
return &postResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
func (m *extensionManager) GetSearchProviders() []*extensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
var providers []*ExtensionProviderWrapper
|
var providers []*extensionProviderWrapper
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" {
|
if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" {
|
||||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
func (m *extensionManager) GetURLHandlers() []*extensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
var providers []*ExtensionProviderWrapper
|
var providers []*extensionProviderWrapper
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" {
|
if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" {
|
||||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
|
func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
|
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
|
||||||
return NewExtensionProviderWrapper(ext)
|
return newExtensionProviderWrapper(ext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -2082,7 +2328,7 @@ type ExtURLHandleResultWithExtID struct {
|
|||||||
ExtensionID string
|
ExtensionID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
func (m *extensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
||||||
handler := m.FindURLHandler(url)
|
handler := m.FindURLHandler(url)
|
||||||
if handler == nil {
|
if handler == nil {
|
||||||
return nil, fmt.Errorf("no extension found to handle URL: %s", url)
|
return nil, fmt.Errorf("no extension found to handle URL: %s", url)
|
||||||
@@ -2102,20 +2348,20 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
func (m *extensionManager) GetPostProcessingProviders() []*extensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
var providers []*ExtensionProviderWrapper
|
var providers []*extensionProviderWrapper
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" {
|
if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" {
|
||||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
|
func (m *extensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||||
providers := m.GetPostProcessingProviders()
|
providers := m.GetPostProcessingProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
return &PostProcessResult{Success: true, NewFilePath: filePath}, nil
|
return &PostProcessResult{Success: true, NewFilePath: filePath}, nil
|
||||||
@@ -2160,7 +2406,7 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
|||||||
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||||
providers := m.GetPostProcessingProviders()
|
providers := m.GetPostProcessingProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
||||||
@@ -2228,7 +2474,7 @@ type ExtLyricsLine struct {
|
|||||||
EndTimeMs int64 `json:"endTimeMs"`
|
EndTimeMs int64 `json:"endTimeMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
if !p.extension.Manifest.IsLyricsProvider() {
|
if !p.extension.Manifest.IsLyricsProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -2326,14 +2572,14 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
var providers []*ExtensionProviderWrapper
|
var providers []*extensionProviderWrapper
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
|
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
|
||||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||||
original := GetMetadataProviderPriority()
|
original := GetMetadataProviderPriority()
|
||||||
@@ -19,6 +23,183 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
|
||||||
|
|
||||||
|
got := GetExtensionFallbackProviderIDs()
|
||||||
|
want := []string{"ext-a", "ext-b"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs(nil)
|
||||||
|
|
||||||
|
if !isExtensionFallbackAllowed("custom-ext") {
|
||||||
|
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
||||||
|
}
|
||||||
|
if !isExtensionFallbackAllowed("qobuz") {
|
||||||
|
t.Fatal("expected built-in provider to remain allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
|
||||||
|
|
||||||
|
if !isExtensionFallbackAllowed("allowed-ext") {
|
||||||
|
t.Fatal("expected explicitly allowed extension to be permitted")
|
||||||
|
}
|
||||||
|
if isExtensionFallbackAllowed("blocked-ext") {
|
||||||
|
t.Fatal("expected extension outside allowlist to be blocked")
|
||||||
|
}
|
||||||
|
if isExtensionFallbackAllowed("deezer") {
|
||||||
|
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||||
|
original := GetProviderPriority()
|
||||||
|
defer SetProviderPriority(original)
|
||||||
|
|
||||||
|
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||||
|
|
||||||
|
got := GetProviderPriority()
|
||||||
|
want := []string{"qobuz", "custom-ext", "tidal"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||||
|
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||||
|
if normalized == nil {
|
||||||
|
t.Fatal("expected legacy decryption key to produce normalized descriptor")
|
||||||
|
}
|
||||||
|
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||||
|
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||||
|
}
|
||||||
|
if normalized.Key != "001122" {
|
||||||
|
t.Fatalf("key = %q", normalized.Key)
|
||||||
|
}
|
||||||
|
if normalized.InputFormat != "mov" {
|
||||||
|
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||||
|
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
|
||||||
|
Strategy: "mp4_decryption_key",
|
||||||
|
Key: "abcd",
|
||||||
|
InputFormat: "",
|
||||||
|
}, "")
|
||||||
|
if normalized == nil {
|
||||||
|
t.Fatal("expected descriptor to remain available")
|
||||||
|
}
|
||||||
|
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||||
|
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||||
|
}
|
||||||
|
if normalized.InputFormat != "mov" {
|
||||||
|
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
outputPath := buildOutputPath(DownloadRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputDir: outputDir,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
FilenameFormat: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !isPathInAllowedDirs(outputPath) {
|
||||||
|
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
outputPath := filepath.Join(outputDir, "custom.flac")
|
||||||
|
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||||
|
|
||||||
|
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||||
|
OutputPath: outputPath,
|
||||||
|
}, ext)
|
||||||
|
|
||||||
|
if resolved != outputPath {
|
||||||
|
t.Fatalf("resolved output path = %q", resolved)
|
||||||
|
}
|
||||||
|
if !isPathInAllowedDirs(outputPath) {
|
||||||
|
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||||
|
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputDir: filepath.Join("Artist", "Album"),
|
||||||
|
OutputFD: 123,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
}, ext)
|
||||||
|
|
||||||
|
expectedBase := filepath.Join(ext.DataDir, "downloads")
|
||||||
|
if !isPathWithinBase(expectedBase, resolved) {
|
||||||
|
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
|
||||||
|
}
|
||||||
|
if !isPathInAllowedDirs(resolved) {
|
||||||
|
t.Fatalf("expected resolved output path %q to be allowed", resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||||
|
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||||
|
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if canEmbedGenreLabel("relative.flac") {
|
||||||
|
t.Fatal("expected relative path to be rejected")
|
||||||
|
}
|
||||||
|
if canEmbedGenreLabel("content://example") {
|
||||||
|
t.Fatal("expected content URI to be rejected")
|
||||||
|
}
|
||||||
|
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
||||||
|
t.Fatal("expected missing file to be rejected")
|
||||||
|
}
|
||||||
|
if !canEmbedGenreLabel(tempFile) {
|
||||||
|
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||||
originalPriority := GetMetadataProviderPriority()
|
originalPriority := GetMetadataProviderPriority()
|
||||||
originalSearch := searchBuiltInMetadataTracksFunc
|
originalSearch := searchBuiltInMetadataTracksFunc
|
||||||
@@ -51,7 +232,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
|||||||
state.IsAuthenticated = accessToken != ""
|
state.IsAuthenticated = accessToken != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionRuntime struct {
|
type extensionRuntime struct {
|
||||||
extensionID string
|
extensionID string
|
||||||
manifest *ExtensionManifest
|
manifest *ExtensionManifest
|
||||||
settings map[string]interface{}
|
settings map[string]interface{}
|
||||||
@@ -123,10 +123,10 @@ var (
|
|||||||
privateIPCacheMu sync.RWMutex
|
privateIPCacheMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||||
jar, _ := newSimpleCookieJar()
|
jar, _ := newSimpleCookieJar()
|
||||||
|
|
||||||
runtime := &ExtensionRuntime{
|
runtime := &extensionRuntime{
|
||||||
extensionID: ext.ID,
|
extensionID: ext.ID,
|
||||||
manifest: ext.Manifest,
|
manifest: ext.Manifest,
|
||||||
settings: make(map[string]interface{}),
|
settings: make(map[string]interface{}),
|
||||||
@@ -142,25 +142,25 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
return runtime
|
return runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||||
r.activeDownloadMu.Lock()
|
r.activeDownloadMu.Lock()
|
||||||
defer r.activeDownloadMu.Unlock()
|
defer r.activeDownloadMu.Unlock()
|
||||||
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
func (r *extensionRuntime) clearActiveDownloadItemID() {
|
||||||
r.activeDownloadMu.Lock()
|
r.activeDownloadMu.Lock()
|
||||||
defer r.activeDownloadMu.Unlock()
|
defer r.activeDownloadMu.Unlock()
|
||||||
r.activeDownloadItemID = ""
|
r.activeDownloadItemID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||||
r.activeDownloadMu.RLock()
|
r.activeDownloadMu.RLock()
|
||||||
defer r.activeDownloadMu.RUnlock()
|
defer r.activeDownloadMu.RUnlock()
|
||||||
return r.activeDownloadItemID
|
return r.activeDownloadItemID
|
||||||
}
|
}
|
||||||
|
|
||||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||||
@@ -329,11 +329,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
|||||||
return j.cookies[u.Host]
|
return j.cookies[u.Host]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||||
r.settings = settings
|
r.settings = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||||
r.vm = vm
|
r.vm = vm
|
||||||
|
|
||||||
httpObj := vm.NewObject()
|
httpObj := vm.NewObject()
|
||||||
@@ -377,7 +377,9 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
fileObj.Set("exists", r.fileExists)
|
fileObj.Set("exists", r.fileExists)
|
||||||
fileObj.Set("delete", r.fileDelete)
|
fileObj.Set("delete", r.fileDelete)
|
||||||
fileObj.Set("read", r.fileRead)
|
fileObj.Set("read", r.fileRead)
|
||||||
|
fileObj.Set("readBytes", r.fileReadBytes)
|
||||||
fileObj.Set("write", r.fileWrite)
|
fileObj.Set("write", r.fileWrite)
|
||||||
|
fileObj.Set("writeBytes", r.fileWriteBytes)
|
||||||
fileObj.Set("copy", r.fileCopy)
|
fileObj.Set("copy", r.fileCopy)
|
||||||
fileObj.Set("move", r.fileMove)
|
fileObj.Set("move", r.fileMove)
|
||||||
fileObj.Set("getSize", r.fileGetSize)
|
fileObj.Set("getSize", r.fileGetSize)
|
||||||
@@ -407,6 +409,8 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
|
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||||
|
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||||
vm.Set("utils", utilsObj)
|
vm.Set("utils", utilsObj)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
|
|||||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -99,7 +99,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(state.AuthCode)
|
return r.vm.ToValue(state.AuthCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
delete(extensionAuthState, r.extensionID)
|
delete(extensionAuthState, r.extensionID)
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
@@ -162,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
|||||||
return r.vm.ToValue(state.IsAuthenticated)
|
return r.vm.ToValue(state.IsAuthenticated)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ func generatePKCEChallenge(verifier string) string {
|
|||||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||||
length := 64
|
length := 64
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||||
@@ -265,7 +265,7 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -385,7 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
"golang.org/x/crypto/blowfish"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runtimeBlockCipherOptions struct {
|
||||||
|
Algorithm string
|
||||||
|
Mode string
|
||||||
|
Key []byte
|
||||||
|
IV []byte
|
||||||
|
InputEncoding string
|
||||||
|
OutputEncoding string
|
||||||
|
Padding string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
|
||||||
|
if len(call.Arguments) <= index {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value := call.Arguments[index]
|
||||||
|
if goja.IsUndefined(value) || goja.IsNull(value) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := value.Export()
|
||||||
|
if options, ok := exported.(map[string]interface{}); ok {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
|
||||||
|
if options == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
raw, ok := options[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
case []byte:
|
||||||
|
if len(value) > 0 {
|
||||||
|
return string(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
|
||||||
|
if options == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
raw, ok := options[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case bool:
|
||||||
|
return value
|
||||||
|
case int:
|
||||||
|
return value != 0
|
||||||
|
case int64:
|
||||||
|
return value != 0
|
||||||
|
case float64:
|
||||||
|
return value != 0
|
||||||
|
case string:
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
|
||||||
|
if options == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
raw, ok := options[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case int:
|
||||||
|
return int64(value)
|
||||||
|
case int32:
|
||||||
|
return int64(value)
|
||||||
|
case int64:
|
||||||
|
return value
|
||||||
|
case float32:
|
||||||
|
return int64(value)
|
||||||
|
case float64:
|
||||||
|
return int64(value)
|
||||||
|
case string:
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
var parsed int64
|
||||||
|
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
|
||||||
|
if options == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, exists := options[key]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
|
case "", "utf8", "utf-8", "text":
|
||||||
|
return []byte(input), nil
|
||||||
|
case "base64":
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid base64 data: %w", err)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
case "hex":
|
||||||
|
decoded, err := hex.DecodeString(strings.TrimSpace(input))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid hex data: %w", err)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
return decodeRuntimeBytesString(value, encoding)
|
||||||
|
case []byte:
|
||||||
|
cloned := make([]byte, len(value))
|
||||||
|
copy(cloned, value)
|
||||||
|
return cloned, nil
|
||||||
|
case []interface{}:
|
||||||
|
decoded := make([]byte, len(value))
|
||||||
|
for i, item := range value {
|
||||||
|
switch num := item.(type) {
|
||||||
|
case int:
|
||||||
|
decoded[i] = byte(num)
|
||||||
|
case int64:
|
||||||
|
decoded[i] = byte(num)
|
||||||
|
case float64:
|
||||||
|
decoded[i] = byte(int(num))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported byte payload type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
|
case "", "base64":
|
||||||
|
return base64.StdEncoding.EncodeToString(data), nil
|
||||||
|
case "hex":
|
||||||
|
return hex.EncodeToString(data), nil
|
||||||
|
case "utf8", "utf-8", "text":
|
||||||
|
return string(data), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
|
||||||
|
parsed := &runtimeBlockCipherOptions{
|
||||||
|
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
|
||||||
|
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
|
||||||
|
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
|
||||||
|
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
|
||||||
|
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
|
||||||
|
}
|
||||||
|
if parsed.Algorithm == "" {
|
||||||
|
return nil, fmt.Errorf("algorithm is required")
|
||||||
|
}
|
||||||
|
if parsed.Mode == "" {
|
||||||
|
return nil, fmt.Errorf("mode is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid key: %w", err)
|
||||||
|
}
|
||||||
|
if len(key) == 0 {
|
||||||
|
return nil, fmt.Errorf("key is required")
|
||||||
|
}
|
||||||
|
parsed.Key = key
|
||||||
|
|
||||||
|
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid iv: %w", err)
|
||||||
|
}
|
||||||
|
parsed.IV = iv
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
|
||||||
|
switch options.Algorithm {
|
||||||
|
case "blowfish":
|
||||||
|
return blowfish.NewCipher(options.Key)
|
||||||
|
case "aes":
|
||||||
|
return aes.NewCipher(options.Key)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPKCS7Padding(data []byte, blockSize int) []byte {
|
||||||
|
padding := blockSize - (len(data) % blockSize)
|
||||||
|
if padding == 0 {
|
||||||
|
padding = blockSize
|
||||||
|
}
|
||||||
|
out := make([]byte, len(data)+padding)
|
||||||
|
copy(out, data)
|
||||||
|
for i := len(data); i < len(out); i++ {
|
||||||
|
out[i] = byte(padding)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
|
||||||
|
if len(data) == 0 || len(data)%blockSize != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid padded payload length")
|
||||||
|
}
|
||||||
|
padding := int(data[len(data)-1])
|
||||||
|
if padding <= 0 || padding > blockSize || padding > len(data) {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
for i := len(data) - padding; i < len(data); i++ {
|
||||||
|
if int(data[i]) != padding {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:len(data)-padding], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "data and options are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 1)
|
||||||
|
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if parsedOptions.Mode != "cbc" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := newRuntimeBlockCipher(parsedOptions)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parsedOptions.IV) != block.BlockSize() {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := inputData
|
||||||
|
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
||||||
|
data = applyPKCS7Padding(data, block.BlockSize())
|
||||||
|
}
|
||||||
|
if len(data)%block.BlockSize() != 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
output := make([]byte, len(data))
|
||||||
|
if decrypt {
|
||||||
|
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
if parsedOptions.Padding == "pkcs7" {
|
||||||
|
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": encoded,
|
||||||
|
"block_size": block.BlockSize(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.transformBlockCipher(call, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.transformBlockCipher(call, true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "binary-test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "binary-test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
File: withFilePermission,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := newExtensionRuntime(ext)
|
||||||
|
vm := goja.New()
|
||||||
|
runtime.RegisterAPIs(vm)
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var decoded T
|
||||||
|
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
|
||||||
|
t.Fatalf("failed to decode JSON result: %v", err)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, true)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
|
||||||
|
if (!first.success) throw new Error(first.error);
|
||||||
|
|
||||||
|
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
|
||||||
|
if (!second.success) throw new Error(second.error);
|
||||||
|
|
||||||
|
var all = file.readBytes("bytes.bin", {encoding: "hex"});
|
||||||
|
if (!all.success) throw new Error(all.error);
|
||||||
|
|
||||||
|
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
|
||||||
|
if (!slice.success) throw new Error(slice.error);
|
||||||
|
|
||||||
|
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
|
||||||
|
if (!tail.success) throw new Error(tail.error);
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
all: all.data,
|
||||||
|
slice: slice.data,
|
||||||
|
size: all.size,
|
||||||
|
sliceBytes: slice.bytes_read,
|
||||||
|
sliceEof: slice.eof,
|
||||||
|
tailBytes: tail.bytes_read,
|
||||||
|
tailEof: tail.eof
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("file byte APIs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
All string `json:"all"`
|
||||||
|
Slice string `json:"slice"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SliceBytes int `json:"sliceBytes"`
|
||||||
|
SliceEof bool `json:"sliceEof"`
|
||||||
|
TailBytes int `json:"tailBytes"`
|
||||||
|
TailEof bool `json:"tailEof"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.All != "0001020304ff" {
|
||||||
|
t.Fatalf("all = %q", decoded.All)
|
||||||
|
}
|
||||||
|
if decoded.Slice != "0203" {
|
||||||
|
t.Fatalf("slice = %q", decoded.Slice)
|
||||||
|
}
|
||||||
|
if decoded.Size != 6 {
|
||||||
|
t.Fatalf("size = %d", decoded.Size)
|
||||||
|
}
|
||||||
|
if decoded.SliceBytes != 2 {
|
||||||
|
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
|
||||||
|
}
|
||||||
|
if decoded.SliceEof {
|
||||||
|
t.Fatal("slice should not be EOF")
|
||||||
|
}
|
||||||
|
if decoded.TailBytes != 0 || !decoded.TailEof {
|
||||||
|
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "blowfish",
|
||||||
|
mode: "cbc",
|
||||||
|
key: "0123456789ABCDEFF0E1D2C3B4A59687",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0001020304050607",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "hex",
|
||||||
|
outputEncoding: "hex",
|
||||||
|
padding: "none"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blowfish block cipher failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Enc string `json:"enc"`
|
||||||
|
Dec string `json:"dec"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Dec != "00112233445566778899aabbccddeeff" {
|
||||||
|
t.Fatalf("dec = %q", decoded.Dec)
|
||||||
|
}
|
||||||
|
if decoded.Enc == decoded.Dec {
|
||||||
|
t.Fatal("expected ciphertext to differ from plaintext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "cbc",
|
||||||
|
key: "000102030405060708090a0b0c0d0e0f",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "utf8",
|
||||||
|
outputEncoding: "base64",
|
||||||
|
padding: "pkcs7"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("hello generic cbc", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "cbc",
|
||||||
|
key: options.key,
|
||||||
|
keyEncoding: options.keyEncoding,
|
||||||
|
iv: options.iv,
|
||||||
|
ivEncoding: options.ivEncoding,
|
||||||
|
inputEncoding: "base64",
|
||||||
|
outputEncoding: "utf8",
|
||||||
|
padding: "pkcs7"
|
||||||
|
});
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return dec.data;
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes block cipher failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.String() != "hello generic cbc" {
|
||||||
|
t.Fatalf("unexpected decrypted value: %q", result.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
|
|||||||
delete(ffmpegCommands, commandID)
|
delete(ffmpegCommands, commandID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -107,7 +107,7 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -134,7 +134,7 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
func (r *extensionRuntime) validatePath(path string) (string, error) {
|
||||||
if !r.manifest.Permissions.File {
|
if !r.manifest.Permissions.File {
|
||||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
|||||||
return absPath, nil
|
return absPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -271,7 +271,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -286,7 +286,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(err == nil)
|
return r.vm.ToValue(err == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -315,7 +315,7 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -346,7 +346,105 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 1)
|
||||||
|
offset := runtimeOptionInt64(options, "offset", 0)
|
||||||
|
length := runtimeOptionInt64(options, "length", -1)
|
||||||
|
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||||
|
if offset < 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "offset must be >= 0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
size := info.Size()
|
||||||
|
if offset > size {
|
||||||
|
offset = size
|
||||||
|
}
|
||||||
|
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
switch {
|
||||||
|
case length == 0:
|
||||||
|
data = []byte{}
|
||||||
|
case length > 0:
|
||||||
|
buf := make([]byte, int(length))
|
||||||
|
n, readErr := file.Read(buf)
|
||||||
|
if readErr != nil && readErr != io.EOF {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read file: %v", readErr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data = buf[:n]
|
||||||
|
default:
|
||||||
|
data, err = io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": encoded,
|
||||||
|
"bytes_read": len(data),
|
||||||
|
"offset": offset,
|
||||||
|
"size": size,
|
||||||
|
"eof": offset+int64(len(data)) >= size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -386,7 +484,108 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path and data are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 2)
|
||||||
|
appendMode := runtimeOptionBool(options, "append", false)
|
||||||
|
truncate := runtimeOptionBool(options, "truncate", false)
|
||||||
|
hasOffset := runtimeOptionHasKey(options, "offset")
|
||||||
|
offset := runtimeOptionInt64(options, "offset", 0)
|
||||||
|
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||||
|
|
||||||
|
if appendMode && hasOffset {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "append and offset cannot be used together",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "offset must be >= 0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(fullPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := os.O_CREATE | os.O_WRONLY
|
||||||
|
if appendMode {
|
||||||
|
flags |= os.O_APPEND
|
||||||
|
}
|
||||||
|
if truncate {
|
||||||
|
flags |= os.O_TRUNC
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(fullPath, flags, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if hasOffset && !appendMode {
|
||||||
|
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
written, err := file.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
info, statErr := file.Stat()
|
||||||
|
size := int64(0)
|
||||||
|
if statErr == nil {
|
||||||
|
size = info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullPath,
|
||||||
|
"bytes_written": written,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -459,7 +658,7 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -507,7 +706,7 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type HTTPResponse struct {
|
|||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||||
parsed, err := url.Parse(urlStr)
|
parsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid URL: %w", err)
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
@@ -49,7 +49,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -124,7 +124,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -221,7 +221,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -330,19 +330,19 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PUT", call)
|
return r.httpMethodShortcut("PUT", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("DELETE", call)
|
return r.httpMethodShortcut("DELETE", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PATCH", call)
|
return r.httpMethodShortcut("PATCH", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -455,7 +455,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||||
jar.mu.Lock()
|
jar.mu.Lock()
|
||||||
jar.cookies = make(map[string][]*http.Cookie)
|
jar.cookies = make(map[string][]*http.Cookie)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(0.0)
|
return r.vm.ToValue(0.0)
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
|||||||
return r.vm.ToValue(similarity)
|
return r.vm.ToValue(similarity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(diff <= tolerance)
|
return r.vm.ToValue(diff <= tolerance)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.createFetchError("URL is required")
|
return r.createFetchError("URL is required")
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return responseObj
|
return responseObj
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
func (r *extensionRuntime) createFetchError(message string) goja.Value {
|
||||||
errorObj := r.vm.NewObject()
|
errorObj := r.vm.NewObject()
|
||||||
errorObj.Set("ok", false)
|
errorObj.Set("ok", false)
|
||||||
errorObj.Set("status", 0)
|
errorObj.Set("status", 0)
|
||||||
@@ -148,7 +148,7 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
|||||||
return errorObj
|
return errorObj
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
encoder := call.This
|
encoder := call.This
|
||||||
encoder.Set("encoding", "utf-8")
|
encoder.Set("encoding", "utf-8")
|
||||||
@@ -252,7 +252,7 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||||
urlObj := call.This
|
urlObj := call.This
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||||
jsonScript := `
|
jsonScript := `
|
||||||
if (typeof JSON === 'undefined') {
|
if (typeof JSON === 'undefined') {
|
||||||
var JSON = {
|
var JSON = {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
storageFlushRetryDelay = 2 * time.Second
|
storageFlushRetryDelay = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getStoragePath() string {
|
func (r *extensionRuntime) getStoragePath() string {
|
||||||
return filepath.Join(r.dataDir, "storage.json")
|
return filepath.Join(r.dataDir, "storage.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
|||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
func (r *extensionRuntime) ensureStorageLoaded() error {
|
||||||
r.storageMu.RLock()
|
r.storageMu.RLock()
|
||||||
if r.storageLoaded {
|
if r.storageLoaded {
|
||||||
r.storageMu.RUnlock()
|
r.storageMu.RUnlock()
|
||||||
@@ -74,7 +74,7 @@ func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||||
if err := r.ensureStorageLoaded(); err != nil {
|
if err := r.ensureStorageLoaded(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
|||||||
return cloneInterfaceMap(r.storageCache), nil
|
return cloneInterfaceMap(r.storageCache), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||||
if r.storageClosed {
|
if r.storageClosed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
|||||||
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||||
data, err := json.Marshal(storage)
|
data, err := json.Marshal(storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -106,13 +106,13 @@ func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}
|
|||||||
return os.WriteFile(r.getStoragePath(), data, 0600)
|
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
|
func (r *extensionRuntime) flushStorageDirtyAsync() {
|
||||||
if err := r.flushStorageDirty(); err != nil {
|
if err := r.flushStorageDirty(); err != nil {
|
||||||
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) flushStorageDirty() error {
|
func (r *extensionRuntime) flushStorageDirty() error {
|
||||||
r.storageMu.Lock()
|
r.storageMu.Lock()
|
||||||
if r.storageClosed {
|
if r.storageClosed {
|
||||||
r.storageTimer = nil
|
r.storageTimer = nil
|
||||||
@@ -140,7 +140,7 @@ func (r *ExtensionRuntime) flushStorageDirty() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) flushStorageNow() error {
|
func (r *extensionRuntime) flushStorageNow() error {
|
||||||
r.storageMu.Lock()
|
r.storageMu.Lock()
|
||||||
if r.storageTimer != nil {
|
if r.storageTimer != nil {
|
||||||
r.storageTimer.Stop()
|
r.storageTimer.Stop()
|
||||||
@@ -157,7 +157,7 @@ func (r *ExtensionRuntime) flushStorageNow() error {
|
|||||||
return r.persistStorageSnapshot(snapshot)
|
return r.persistStorageSnapshot(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) closeStorageFlusher() {
|
func (r *extensionRuntime) closeStorageFlusher() {
|
||||||
r.storageMu.Lock()
|
r.storageMu.Lock()
|
||||||
r.storageClosed = true
|
r.storageClosed = true
|
||||||
r.storageDirty = false
|
r.storageDirty = false
|
||||||
@@ -168,7 +168,7 @@ func (r *ExtensionRuntime) closeStorageFlusher() {
|
|||||||
r.storageMu.Unlock()
|
r.storageMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -254,15 +254,15 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
func (r *extensionRuntime) getCredentialsPath() string {
|
||||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getSaltPath() string {
|
func (r *extensionRuntime) getSaltPath() string {
|
||||||
return filepath.Join(r.dataDir, ".cred_salt")
|
return filepath.Join(r.dataDir, ".cred_salt")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||||
saltPath := r.getSaltPath()
|
saltPath := r.getSaltPath()
|
||||||
|
|
||||||
salt, err := os.ReadFile(saltPath)
|
salt, err := os.ReadFile(saltPath)
|
||||||
@@ -282,7 +282,7 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
|||||||
return salt, nil
|
return salt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||||
salt, err := r.getOrCreateSalt()
|
salt, err := r.getOrCreateSalt()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -293,7 +293,7 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
|||||||
return hash[:], nil
|
return hash[:], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
||||||
r.credentialsMu.RLock()
|
r.credentialsMu.RLock()
|
||||||
if r.credentialsLoaded {
|
if r.credentialsLoaded {
|
||||||
r.credentialsMu.RUnlock()
|
r.credentialsMu.RUnlock()
|
||||||
@@ -340,7 +340,7 @@ func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
|||||||
return cloneInterfaceMap(r.credentialsCache), nil
|
return cloneInterfaceMap(r.credentialsCache), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||||
data, err := json.Marshal(creds)
|
data, err := json.Marshal(creds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -377,7 +377,7 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -414,7 +414,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -439,7 +439,7 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -464,7 +464,7 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
|
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
result := runtime.storageSet(goja.FunctionCall{
|
result := runtime.storageSet(goja.FunctionCall{
|
||||||
Arguments: []goja.Value{
|
Arguments: []goja.Value{
|
||||||
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "storage-test",
|
ID: "storage-test",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "storage-test",
|
Name: "storage-test",
|
||||||
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
runtime.storageFlushDelay = 25 * time.Millisecond
|
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||||
runtime.RegisterAPIs(goja.New())
|
runtime.RegisterAPIs(goja.New())
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "unload-storage-test",
|
ID: "unload-storage-test",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "unload-storage-test",
|
Name: "unload-storage-test",
|
||||||
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
|||||||
VM: goja.New(),
|
VM: goja.New(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
runtime.storageFlushDelay = time.Hour
|
runtime.storageFlushDelay = time.Hour
|
||||||
runtime.RegisterAPIs(ext.VM)
|
runtime.RegisterAPIs(ext.VM)
|
||||||
ext.runtime = runtime
|
ext.runtime = runtime
|
||||||
|
|
||||||
manager := &ExtensionManager{
|
manager := &extensionManager{
|
||||||
extensions: map[string]*LoadedExtension{
|
extensions: map[string]*loadedExtension{
|
||||||
ext.ID: ext,
|
ext.ID: ext,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue([]byte{})
|
return r.vm.ToValue([]byte{})
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(jsArray)
|
return r.vm.ToValue(jsArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(data))
|
return r.vm.ToValue(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -187,7 +187,7 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -222,7 +222,7 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||||
length := 32
|
length := 32
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||||
@@ -245,35 +245,35 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||||
return r.vm.ToValue(getRandomUserAgent())
|
return r.vm.ToValue(getRandomUserAgent())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||||
parts := make([]string, len(args))
|
parts := make([]string, len(args))
|
||||||
for i, arg := range args {
|
for i, arg := range args {
|
||||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||||
@@ -281,7 +281,7 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -289,7 +289,7 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(sanitizeFilename(input))
|
return r.vm.ToValue(sanitizeFilename(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||||
gobackendObj := vm.Get("gobackend")
|
gobackendObj := vm.Get("gobackend")
|
||||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||||
gobackendObj = vm.NewObject()
|
gobackendObj = vm.NewObject()
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
installed := make(map[string]string) // id -> version
|
installed := make(map[string]string) // id -> version
|
||||||
|
|
||||||
if manager != nil {
|
if manager != nil {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestIsDomainAllowed(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||||
// Create a mock extension with limited network permissions
|
// Create a mock extension with limited network permissions
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -110,7 +110,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||||
@@ -132,7 +132,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -143,7 +143,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
validPath, err := runtime.validatePath("test.txt")
|
validPath, err := runtime.validatePath("test.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -177,7 +177,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
t.Error("Expected absolute path to be blocked")
|
t.Error("Expected absolute path to be blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
extNoFile := &LoadedExtension{
|
extNoFile := &loadedExtension{
|
||||||
ID: "test-ext-no-file",
|
ID: "test-ext-no-file",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext-no-file",
|
Name: "test-ext-no-file",
|
||||||
@@ -187,7 +187,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
},
|
},
|
||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
}
|
}
|
||||||
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
runtimeNoFile := newExtensionRuntime(extNoFile)
|
||||||
_, err = runtimeNoFile.validatePath("test.txt")
|
_, err = runtimeNoFile.validatePath("test.txt")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected file access to be denied without file permission")
|
t.Error("Expected file access to be denied without file permission")
|
||||||
@@ -195,7 +195,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -203,7 +203,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||||
// Create extension with limited network permissions
|
// Create extension with limited network permissions
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -254,7 +254,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
privateIPs := []string{
|
privateIPs := []string{
|
||||||
"http://localhost/admin",
|
"http://localhost/admin",
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
GoLog("[extensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
case <-time.After(60 * time.Second):
|
case <-time.After(60 * time.Second):
|
||||||
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||||
// Log a warning — the VM should NOT be reused after this.
|
// Log a warning — the VM should NOT be reused after this.
|
||||||
GoLog("[ExtensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||||
return nil, &JSExecutionError{
|
return nil, &JSExecutionError{
|
||||||
Message: "execution timeout exceeded (force)",
|
Message: "execution timeout exceeded (force)",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ type LibraryScanResult struct {
|
|||||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
TrackNumber int `json:"trackNumber,omitempty"`
|
TrackNumber int `json:"trackNumber,omitempty"`
|
||||||
|
TotalTracks int `json:"totalTracks,omitempty"`
|
||||||
DiscNumber int `json:"discNumber,omitempty"`
|
DiscNumber int `json:"discNumber,omitempty"`
|
||||||
|
TotalDiscs int `json:"totalDiscs,omitempty"`
|
||||||
Duration int `json:"duration,omitempty"`
|
Duration int `json:"duration,omitempty"`
|
||||||
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)
|
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Copyright string `json:"copyright,omitempty"`
|
||||||
Format string `json:"format,omitempty"`
|
Format string `json:"format,omitempty"`
|
||||||
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -365,9 +370,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
|
|||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
quality, err := GetAudioQuality(filePath)
|
quality, err := GetAudioQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -397,12 +407,17 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
if result.ReleaseDate == "" {
|
if result.ReleaseDate == "" {
|
||||||
result.ReleaseDate = metadata.Year
|
result.ReleaseDate = metadata.Year
|
||||||
}
|
}
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
}
|
}
|
||||||
|
|
||||||
quality, err := GetM4AQuality(filePath)
|
quality, err := GetM4AQuality(filePath)
|
||||||
@@ -427,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
result.AlbumName = metadata.Album
|
result.AlbumName = metadata.Album
|
||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
@@ -435,6 +452,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
result.ReleaseDate = metadata.Year
|
result.ReleaseDate = metadata.Year
|
||||||
}
|
}
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
quality, err := GetMP3Quality(filePath)
|
quality, err := GetMP3Quality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -464,9 +484,14 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
quality, err := GetOggQuality(filePath)
|
quality, err := GetOggQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -501,13 +526,18 @@ func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
} else {
|
} else {
|
||||||
result.ReleaseDate = metadata.Year
|
result.ReleaseDate = metadata.Year
|
||||||
}
|
}
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
|
|||||||
@@ -385,8 +385,8 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
primaryArtist := normalizeArtistName(artistName)
|
primaryArtist := normalizeArtistName(artistName)
|
||||||
fetchOptions := GetLyricsFetchOptions()
|
fetchOptions := GetLyricsFetchOptions()
|
||||||
|
|
||||||
extManager := GetExtensionManager()
|
extManager := getExtensionManager()
|
||||||
var extensionProviders []*ExtensionProviderWrapper
|
var extensionProviders []*extensionProviderWrapper
|
||||||
if extManager != nil {
|
if extManager != nil {
|
||||||
extensionProviders = extManager.GetLyricsProviders()
|
extensionProviders = extManager.GetLyricsProviders()
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-25
@@ -110,6 +110,7 @@ type Metadata struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
TotalTracks int
|
TotalTracks int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
TotalDiscs int
|
||||||
ISRC string
|
ISRC string
|
||||||
Description string
|
Description string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
@@ -273,23 +274,23 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
|
|
||||||
trackNum := getComment(cmt, "TRACKNUMBER")
|
trackNum := getComment(cmt, "TRACKNUMBER")
|
||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
|
||||||
}
|
}
|
||||||
if metadata.TrackNumber == 0 {
|
if metadata.TrackNumber == 0 {
|
||||||
trackNum = getComment(cmt, "TRACK")
|
trackNum = getComment(cmt, "TRACK")
|
||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
discNum := getComment(cmt, "DISCNUMBER")
|
discNum := getComment(cmt, "DISCNUMBER")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
|
||||||
}
|
}
|
||||||
if metadata.DiscNumber == 0 {
|
if metadata.DiscNumber == 0 {
|
||||||
discNum = getComment(cmt, "DISC")
|
discNum = getComment(cmt, "DISC")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,26 +404,39 @@ func EditFlacFields(filePath string, fields map[string]string) error {
|
|||||||
removeCommentKey(cmt, "ALBUM_ARTIST")
|
removeCommentKey(cmt, "ALBUM_ARTIST")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track/disc numbers: present + empty → clear; present + "0" → clear.
|
// Track/disc numbers: present + empty → clear; when only totals are edited,
|
||||||
if v, ok := fields["track_number"]; ok {
|
// preserve the current index number and rewrite the combined value.
|
||||||
trackNum := 0
|
if _, ok := fields["track_number"]; ok || fields["track_total"] != "" || hasMapKey(fields, "track_total") {
|
||||||
if v != "" {
|
currentTrackNum, currentTotalTracks := parseIndexPair(getComment(cmt, "TRACKNUMBER"))
|
||||||
fmt.Sscanf(v, "%d", &trackNum)
|
if currentTrackNum == 0 && currentTotalTracks == 0 {
|
||||||
|
currentTrackNum, currentTotalTracks = parseIndexPair(getComment(cmt, "TRACK"))
|
||||||
}
|
}
|
||||||
if trackNum > 0 {
|
if v, ok := fields["track_number"]; ok {
|
||||||
setOrClearComment(cmt, "TRACKNUMBER", strconv.Itoa(trackNum))
|
currentTrackNum = parsePositiveInt(v)
|
||||||
|
}
|
||||||
|
if v, ok := fields["track_total"]; ok {
|
||||||
|
currentTotalTracks = parsePositiveInt(v)
|
||||||
|
}
|
||||||
|
if currentTrackNum > 0 {
|
||||||
|
setOrClearComment(cmt, "TRACKNUMBER", formatIndexValue(currentTrackNum, currentTotalTracks))
|
||||||
} else {
|
} else {
|
||||||
removeCommentKey(cmt, "TRACKNUMBER")
|
removeCommentKey(cmt, "TRACKNUMBER")
|
||||||
}
|
}
|
||||||
removeCommentKey(cmt, "TRACK") // alias
|
removeCommentKey(cmt, "TRACK") // alias
|
||||||
}
|
}
|
||||||
if v, ok := fields["disc_number"]; ok {
|
if _, ok := fields["disc_number"]; ok || fields["disc_total"] != "" || hasMapKey(fields, "disc_total") {
|
||||||
discNum := 0
|
currentDiscNum, currentTotalDiscs := parseIndexPair(getComment(cmt, "DISCNUMBER"))
|
||||||
if v != "" {
|
if currentDiscNum == 0 && currentTotalDiscs == 0 {
|
||||||
fmt.Sscanf(v, "%d", &discNum)
|
currentDiscNum, currentTotalDiscs = parseIndexPair(getComment(cmt, "DISC"))
|
||||||
}
|
}
|
||||||
if discNum > 0 {
|
if v, ok := fields["disc_number"]; ok {
|
||||||
setOrClearComment(cmt, "DISCNUMBER", strconv.Itoa(discNum))
|
currentDiscNum = parsePositiveInt(v)
|
||||||
|
}
|
||||||
|
if v, ok := fields["disc_total"]; ok {
|
||||||
|
currentTotalDiscs = parsePositiveInt(v)
|
||||||
|
}
|
||||||
|
if currentDiscNum > 0 {
|
||||||
|
setOrClearComment(cmt, "DISCNUMBER", formatIndexValue(currentDiscNum, currentTotalDiscs))
|
||||||
} else {
|
} else {
|
||||||
removeCommentKey(cmt, "DISCNUMBER")
|
removeCommentKey(cmt, "DISCNUMBER")
|
||||||
}
|
}
|
||||||
@@ -478,15 +492,11 @@ func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Me
|
|||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
if metadata.TotalTracks > 0 {
|
setComment(cmt, "TRACKNUMBER", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
|
||||||
} else {
|
|
||||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
setComment(cmt, "DISCNUMBER", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
@@ -953,9 +963,9 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
|||||||
case "\xa9lyr":
|
case "\xa9lyr":
|
||||||
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
||||||
case "trkn":
|
case "trkn":
|
||||||
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fi.Size())
|
||||||
case "disk":
|
case "disk":
|
||||||
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fi.Size())
|
||||||
case "----":
|
case "----":
|
||||||
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
||||||
if freeformErr == nil {
|
if freeformErr == nil {
|
||||||
@@ -1150,6 +1160,41 @@ func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, erro
|
|||||||
return int(binary.BigEndian.Uint16(payload[2:4])), nil
|
return int(binary.BigEndian.Uint16(payload[2:4])), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int, error) {
|
||||||
|
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if len(payload) < 6 {
|
||||||
|
return 0, 0, fmt.Errorf("index payload too short in %s", parent.typ)
|
||||||
|
}
|
||||||
|
return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePositiveInt(value string) int {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(value)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatIndexValue(number, total int) string {
|
||||||
|
if number <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
return fmt.Sprintf("%d/%d", number, total)
|
||||||
|
}
|
||||||
|
return strconv.Itoa(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMapKey(fields map[string]string, key string) bool {
|
||||||
|
_, ok := fields[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
|
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
|
||||||
start := parent.offset + parent.headerSize
|
start := parent.offset + parent.headerSize
|
||||||
end := parent.offset + parent.size
|
end := parent.offset + parent.size
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ type TrackMetadata struct {
|
|||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
TotalTracks int `json:"total_tracks,omitempty"`
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
AlbumID string `json:"album_id,omitempty"`
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
ArtistID string `json:"artist_id,omitempty"`
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
AlbumType string `json:"album_type,omitempty"`
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumTrackMetadata struct {
|
type AlbumTrackMetadata struct {
|
||||||
@@ -42,11 +44,13 @@ type AlbumTrackMetadata struct {
|
|||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
TotalTracks int `json:"total_tracks,omitempty"`
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
AlbumID string `json:"album_id,omitempty"`
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
AlbumURL string `json:"album_url,omitempty"`
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
AlbumType string `json:"album_type,omitempty"`
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumInfoMetadata struct {
|
type AlbumInfoMetadata struct {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const (
|
|||||||
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
||||||
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
|
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
|
||||||
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
|
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
|
||||||
|
qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz"
|
||||||
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
||||||
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
||||||
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
||||||
@@ -105,6 +106,10 @@ type QobuzTrack struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"performer"`
|
} `json:"performer"`
|
||||||
|
Composer struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"composer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type qobuzImageSet struct {
|
type qobuzImageSet struct {
|
||||||
@@ -349,6 +354,7 @@ func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
|
|||||||
AlbumID: qobuzPrefixedID(track.Album.ID),
|
AlbumID: qobuzPrefixedID(track.Album.ID),
|
||||||
ArtistID: qobuzTrackArtistID(track),
|
ArtistID: qobuzTrackArtistID(track),
|
||||||
AlbumType: qobuzTrackAlbumType(track),
|
AlbumType: qobuzTrackAlbumType(track),
|
||||||
|
Composer: strings.TrimSpace(track.Composer.Name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +379,7 @@ func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
|
|||||||
AlbumID: qobuzPrefixedID(track.Album.ID),
|
AlbumID: qobuzPrefixedID(track.Album.ID),
|
||||||
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
|
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
|
||||||
AlbumType: qobuzTrackAlbumType(track),
|
AlbumType: qobuzTrackAlbumType(track),
|
||||||
|
Composer: strings.TrimSpace(track.Composer.Name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,6 +1037,7 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
||||||
|
totalDiscs := 0
|
||||||
for i := range album.Tracks.Items {
|
for i := range album.Tracks.Items {
|
||||||
track := &album.Tracks.Items[i]
|
track := &album.Tracks.Items[i]
|
||||||
track.Album.ID = album.ID
|
track.Album.ID = album.ID
|
||||||
@@ -1041,8 +1049,14 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
|||||||
Large: album.Image.Large,
|
Large: album.Image.Large,
|
||||||
}
|
}
|
||||||
track.Album.TracksCount = album.TracksCount
|
track.Album.TracksCount = album.TracksCount
|
||||||
|
if track.MediaNumber > totalDiscs {
|
||||||
|
totalDiscs = track.MediaNumber
|
||||||
|
}
|
||||||
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
|
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
|
||||||
}
|
}
|
||||||
|
for i := range tracks {
|
||||||
|
tracks[i].TotalDiscs = totalDiscs
|
||||||
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
return &AlbumResponsePayload{
|
||||||
AlbumInfo: qobuzAlbumToAlbumInfo(album),
|
AlbumInfo: qobuzAlbumToAlbumInfo(album),
|
||||||
@@ -1126,6 +1140,7 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
|
|||||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||||
return []string{
|
return []string{
|
||||||
qobuzDownloadAPIURL,
|
qobuzDownloadAPIURL,
|
||||||
|
qobuzZarzDownloadAPIURL,
|
||||||
qobuzDabMusicAPIURL,
|
qobuzDabMusicAPIURL,
|
||||||
qobuzDeebAPIURL,
|
qobuzDeebAPIURL,
|
||||||
qobuzAfkarAPIURL,
|
qobuzAfkarAPIURL,
|
||||||
@@ -1147,6 +1162,7 @@ const (
|
|||||||
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
||||||
return []qobuzAPIProvider{
|
return []qobuzAPIProvider{
|
||||||
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
||||||
|
{Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
||||||
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
||||||
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
||||||
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
|
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
|
||||||
@@ -2793,10 +2809,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
TrackNumber: actualTrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: req.DiscNumber,
|
||||||
|
TotalDiscs: req.TotalDiscs,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
Composer: req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
|
|||||||
@@ -241,12 +241,13 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
|||||||
|
|
||||||
func TestQobuzAvailableProviders(t *testing.T) {
|
func TestQobuzAvailableProviders(t *testing.T) {
|
||||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||||
if len(providers) != 5 {
|
if len(providers) != 6 {
|
||||||
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
t.Fatalf("expected 6 Qobuz providers, got %d", len(providers))
|
||||||
}
|
}
|
||||||
|
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"musicdl": qobuzAPIKindMusicDL,
|
"musicdl": qobuzAPIKindMusicDL,
|
||||||
|
"zarz": qobuzAPIKindMusicDL,
|
||||||
"dabmusic": qobuzAPIKindStandard,
|
"dabmusic": qobuzAPIKindStandard,
|
||||||
"deeb": qobuzAPIKindStandard,
|
"deeb": qobuzAPIKindStandard,
|
||||||
"qbz": qobuzAPIKindStandard,
|
"qbz": qobuzAPIKindStandard,
|
||||||
@@ -518,3 +519,37 @@ func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
|||||||
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQobuzTrackMetadataIncludesComposer(t *testing.T) {
|
||||||
|
track := &QobuzTrack{
|
||||||
|
ID: 40681594,
|
||||||
|
Title: "Sign of the Times",
|
||||||
|
ISRC: "USSM11703595",
|
||||||
|
Duration: 340,
|
||||||
|
TrackNumber: 1,
|
||||||
|
MediaNumber: 1,
|
||||||
|
}
|
||||||
|
track.Performer.ID = 729886
|
||||||
|
track.Performer.Name = "Harry Styles"
|
||||||
|
track.Composer.ID = 729886
|
||||||
|
track.Composer.Name = "Harry Styles"
|
||||||
|
track.Album.ID = "0886446451985"
|
||||||
|
track.Album.Title = "Harry Styles"
|
||||||
|
track.Album.ReleaseDate = "2017-05-12"
|
||||||
|
track.Album.TracksCount = 10
|
||||||
|
track.Album.ReleaseType = "album"
|
||||||
|
track.Album.ProductType = "album"
|
||||||
|
track.Album.Artist.ID = 729886
|
||||||
|
track.Album.Artist.Name = "Harry Styles"
|
||||||
|
track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}}
|
||||||
|
|
||||||
|
trackMeta := qobuzTrackToTrackMetadata(track)
|
||||||
|
if trackMeta.Composer != "Harry Styles" {
|
||||||
|
t.Fatalf("track composer = %q", trackMeta.Composer)
|
||||||
|
}
|
||||||
|
|
||||||
|
albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track)
|
||||||
|
if albumTrackMeta.Composer != "Harry Styles" {
|
||||||
|
t.Fatalf("album track composer = %q", albumTrackMeta.Composer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1012,6 +1012,7 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
||||||
|
totalDiscs := 0
|
||||||
for _, item := range itemsModule.PagedList.Items {
|
for _, item := range itemsModule.PagedList.Items {
|
||||||
track := item.Item
|
track := item.Item
|
||||||
track.Album.ID = headerModule.Album.ID
|
track.Album.ID = headerModule.Album.ID
|
||||||
@@ -1019,8 +1020,14 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
|||||||
track.Album.Cover = headerModule.Album.Cover
|
track.Album.Cover = headerModule.Album.Cover
|
||||||
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
||||||
track.Album.URL = headerModule.Album.URL
|
track.Album.URL = headerModule.Album.URL
|
||||||
|
if track.VolumeNumber > totalDiscs {
|
||||||
|
totalDiscs = track.VolumeNumber
|
||||||
|
}
|
||||||
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
||||||
}
|
}
|
||||||
|
for i := range tracks {
|
||||||
|
tracks[i].TotalDiscs = totalDiscs
|
||||||
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
return &AlbumResponsePayload{
|
||||||
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
|
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
|
||||||
@@ -2360,10 +2367,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
TrackNumber: actualTrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: actualDiscNumber,
|
DiscNumber: actualDiscNumber,
|
||||||
|
TotalDiscs: req.TotalDiscs,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
Composer: req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
|
|||||||
@@ -607,6 +607,13 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendGetProviderPriorityJSON(&error)
|
let response = GobackendGetProviderPriorityJSON(&error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "setDownloadFallbackExtensionIds":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionIdsJson = args["extension_ids"] as? String ?? ""
|
||||||
|
GobackendSetExtensionFallbackProviderIDsJSON(extensionIdsJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
case "setMetadataProviderPriority":
|
case "setMetadataProviderPriority":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
|
|||||||
/// 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 = '4.2.0';
|
static const String version = '4.2.2';
|
||||||
static const String buildNumber = '121';
|
static const String buildNumber = '123';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
/// Shows "Internal" in debug builds, actual version in release.
|
/// Shows "Internal" in debug builds, actual version in release.
|
||||||
|
|||||||
@@ -1738,6 +1738,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
|
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
|
||||||
String get providerPriorityInfo;
|
String get providerPriorityInfo;
|
||||||
|
|
||||||
|
/// Section title for choosing which download extensions can be used as fallback providers
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Extension Fallback'**
|
||||||
|
String get providerPriorityFallbackExtensionsTitle;
|
||||||
|
|
||||||
|
/// Section description for extension fallback selection
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.'**
|
||||||
|
String get providerPriorityFallbackExtensionsDescription;
|
||||||
|
|
||||||
|
/// Hint below the extension fallback selection list
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Only enabled extensions with download-provider capability are listed here.'**
|
||||||
|
String get providerPriorityFallbackExtensionsHint;
|
||||||
|
|
||||||
/// Label for built-in providers (Tidal/Qobuz)
|
/// Label for built-in providers (Tidal/Qobuz)
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2644,6 +2662,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Set download service order'**
|
/// **'Set download service order'**
|
||||||
String get extensionsDownloadPrioritySubtitle;
|
String get extensionsDownloadPrioritySubtitle;
|
||||||
|
|
||||||
|
/// Setting and page title for choosing which download extensions can be used during fallback
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Fallback Extensions'**
|
||||||
|
String get extensionsFallbackTitle;
|
||||||
|
|
||||||
|
/// Subtitle for download fallback extensions menu
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose which installed download extensions can be used as fallback'**
|
||||||
|
String get extensionsFallbackSubtitle;
|
||||||
|
|
||||||
/// Empty state - no download providers
|
/// Empty state - no download providers
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -940,6 +940,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
|
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Integriert';
|
String get providerBuiltIn => 'Integriert';
|
||||||
|
|
||||||
@@ -1438,6 +1449,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get extensionsDownloadPrioritySubtitle =>
|
String get extensionsDownloadPrioritySubtitle =>
|
||||||
'Download-Service-Reihenfolge festlegen';
|
'Download-Service-Reihenfolge festlegen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'Keine Erweiterungen mit Download-Provider';
|
'Keine Erweiterungen mit Download-Provider';
|
||||||
|
|||||||
@@ -926,6 +926,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1415,6 +1426,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
|
|||||||
@@ -926,6 +926,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1415,6 +1426,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
|
|||||||
@@ -928,6 +928,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1417,6 +1428,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
|
|||||||
@@ -926,6 +926,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1415,6 +1426,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
|
|||||||
@@ -930,6 +930,17 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
|
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Fallback Ekstensi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Bawaan';
|
String get providerBuiltIn => 'Bawaan';
|
||||||
|
|
||||||
@@ -1423,6 +1434,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get extensionsDownloadPrioritySubtitle =>
|
String get extensionsDownloadPrioritySubtitle =>
|
||||||
'Atur urutan layanan unduhan';
|
'Atur urutan layanan unduhan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'Tidak ada ekstensi dengan provider unduhan';
|
'Tidak ada ekstensi dengan provider unduhan';
|
||||||
|
|||||||
@@ -920,6 +920,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => '内蔵';
|
String get providerBuiltIn => '内蔵';
|
||||||
|
|
||||||
@@ -1409,6 +1420,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
|
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
|
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
|
||||||
|
|
||||||
|
|||||||
@@ -908,6 +908,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1395,6 +1406,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
|
|||||||
@@ -926,6 +926,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1415,6 +1426,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
|
|||||||
@@ -926,6 +926,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1415,6 +1426,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
|
|||||||
@@ -940,6 +940,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
|
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Встроенные';
|
String get providerBuiltIn => 'Встроенные';
|
||||||
|
|
||||||
@@ -1439,6 +1450,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get extensionsDownloadPrioritySubtitle =>
|
String get extensionsDownloadPrioritySubtitle =>
|
||||||
'Установка порядок сервисов скачивания';
|
'Установка порядок сервисов скачивания';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'Нет расширений с провайдером загрузки';
|
'Нет расширений с провайдером загрузки';
|
||||||
|
|||||||
@@ -931,6 +931,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'Eğer bir şarkı ilk hizmette mevcut değilse uygulama otomatik olarak bir sonrakini deneyecektir.';
|
'Eğer bir şarkı ilk hizmette mevcut değilse uygulama otomatik olarak bir sonrakini deneyecektir.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Dahili';
|
String get providerBuiltIn => 'Dahili';
|
||||||
|
|
||||||
@@ -1421,6 +1432,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
|
|||||||
@@ -926,6 +926,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1415,6 +1426,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
|
|||||||
@@ -1203,6 +1203,18 @@
|
|||||||
"@providerPriorityInfo": {
|
"@providerPriorityInfo": {
|
||||||
"description": "Info tip about fallback behavior"
|
"description": "Info tip about fallback behavior"
|
||||||
},
|
},
|
||||||
|
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
|
||||||
|
"@providerPriorityFallbackExtensionsTitle": {
|
||||||
|
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||||
|
},
|
||||||
|
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||||
|
"@providerPriorityFallbackExtensionsDescription": {
|
||||||
|
"description": "Section description for extension fallback selection"
|
||||||
|
},
|
||||||
|
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
|
||||||
|
"@providerPriorityFallbackExtensionsHint": {
|
||||||
|
"description": "Hint below the extension fallback selection list"
|
||||||
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
@@ -1857,6 +1869,14 @@
|
|||||||
"@extensionsDownloadPrioritySubtitle": {
|
"@extensionsDownloadPrioritySubtitle": {
|
||||||
"description": "Subtitle for download priority"
|
"description": "Subtitle for download priority"
|
||||||
},
|
},
|
||||||
|
"extensionsFallbackTitle": "Fallback Extensions",
|
||||||
|
"@extensionsFallbackTitle": {
|
||||||
|
"description": "Setting and page title for choosing which download extensions can be used during fallback"
|
||||||
|
},
|
||||||
|
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
|
||||||
|
"@extensionsFallbackSubtitle": {
|
||||||
|
"description": "Subtitle for download fallback extensions menu"
|
||||||
|
},
|
||||||
"extensionsNoDownloadProvider": "No extensions with download provider",
|
"extensionsNoDownloadProvider": "No extensions with download provider",
|
||||||
"@extensionsNoDownloadProvider": {
|
"@extensionsNoDownloadProvider": {
|
||||||
"description": "Empty state - no download providers"
|
"description": "Empty state - no download providers"
|
||||||
|
|||||||
@@ -1119,6 +1119,18 @@
|
|||||||
"@providerPriorityInfo": {
|
"@providerPriorityInfo": {
|
||||||
"description": "Info tip about fallback behavior"
|
"description": "Info tip about fallback behavior"
|
||||||
},
|
},
|
||||||
|
"providerPriorityFallbackExtensionsTitle": "Fallback Ekstensi",
|
||||||
|
"@providerPriorityFallbackExtensionsTitle": {
|
||||||
|
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||||
|
},
|
||||||
|
"providerPriorityFallbackExtensionsDescription": "Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.",
|
||||||
|
"@providerPriorityFallbackExtensionsDescription": {
|
||||||
|
"description": "Section description for extension fallback selection"
|
||||||
|
},
|
||||||
|
"providerPriorityFallbackExtensionsHint": "Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.",
|
||||||
|
"@providerPriorityFallbackExtensionsHint": {
|
||||||
|
"description": "Hint below the extension fallback selection list"
|
||||||
|
},
|
||||||
"providerBuiltIn": "Bawaan",
|
"providerBuiltIn": "Bawaan",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
@@ -1713,6 +1725,14 @@
|
|||||||
"@extensionsDownloadPrioritySubtitle": {
|
"@extensionsDownloadPrioritySubtitle": {
|
||||||
"description": "Subtitle for download priority"
|
"description": "Subtitle for download priority"
|
||||||
},
|
},
|
||||||
|
"extensionsFallbackTitle": "Fallback Extensions",
|
||||||
|
"@extensionsFallbackTitle": {
|
||||||
|
"description": "Setting and page title for choosing which download extensions can be used during fallback"
|
||||||
|
},
|
||||||
|
"extensionsFallbackSubtitle": "Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback",
|
||||||
|
"@extensionsFallbackSubtitle": {
|
||||||
|
"description": "Subtitle for download fallback extensions menu"
|
||||||
|
},
|
||||||
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
|
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
|
||||||
"@extensionsNoDownloadProvider": {
|
"@extensionsNoDownloadProvider": {
|
||||||
"description": "Empty state - no download providers"
|
"description": "Empty state - no download providers"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class AppSettings {
|
|||||||
final bool askQualityBeforeDownload;
|
final bool askQualityBeforeDownload;
|
||||||
final bool enableLogging;
|
final bool enableLogging;
|
||||||
final bool useExtensionProviders;
|
final bool useExtensionProviders;
|
||||||
|
final List<String>? downloadFallbackExtensionIds;
|
||||||
final String? searchProvider;
|
final String? searchProvider;
|
||||||
final String? homeFeedProvider;
|
final String? homeFeedProvider;
|
||||||
final bool separateSingles;
|
final bool separateSingles;
|
||||||
@@ -108,6 +109,7 @@ class AppSettings {
|
|||||||
this.askQualityBeforeDownload = true,
|
this.askQualityBeforeDownload = true,
|
||||||
this.enableLogging = false,
|
this.enableLogging = false,
|
||||||
this.useExtensionProviders = true,
|
this.useExtensionProviders = true,
|
||||||
|
this.downloadFallbackExtensionIds,
|
||||||
this.searchProvider,
|
this.searchProvider,
|
||||||
this.homeFeedProvider,
|
this.homeFeedProvider,
|
||||||
this.separateSingles = false,
|
this.separateSingles = false,
|
||||||
@@ -170,6 +172,8 @@ class AppSettings {
|
|||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
bool? enableLogging,
|
bool? enableLogging,
|
||||||
bool? useExtensionProviders,
|
bool? useExtensionProviders,
|
||||||
|
List<String>? downloadFallbackExtensionIds,
|
||||||
|
bool clearDownloadFallbackExtensionIds = false,
|
||||||
String? searchProvider,
|
String? searchProvider,
|
||||||
bool clearSearchProvider = false,
|
bool clearSearchProvider = false,
|
||||||
String? homeFeedProvider,
|
String? homeFeedProvider,
|
||||||
@@ -232,6 +236,9 @@ class AppSettings {
|
|||||||
enableLogging: enableLogging ?? this.enableLogging,
|
enableLogging: enableLogging ?? this.enableLogging,
|
||||||
useExtensionProviders:
|
useExtensionProviders:
|
||||||
useExtensionProviders ?? this.useExtensionProviders,
|
useExtensionProviders ?? this.useExtensionProviders,
|
||||||
|
downloadFallbackExtensionIds: clearDownloadFallbackExtensionIds
|
||||||
|
? null
|
||||||
|
: (downloadFallbackExtensionIds ?? this.downloadFallbackExtensionIds),
|
||||||
searchProvider: clearSearchProvider
|
searchProvider: clearSearchProvider
|
||||||
? null
|
? null
|
||||||
: (searchProvider ?? this.searchProvider),
|
: (searchProvider ?? this.searchProvider),
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||||
|
downloadFallbackExtensionIds:
|
||||||
|
(json['downloadFallbackExtensionIds'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList(),
|
||||||
searchProvider: json['searchProvider'] as String?,
|
searchProvider: json['searchProvider'] as String?,
|
||||||
homeFeedProvider: json['homeFeedProvider'] as String?,
|
homeFeedProvider: json['homeFeedProvider'] as String?,
|
||||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||||
@@ -105,6 +109,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
'enableLogging': instance.enableLogging,
|
'enableLogging': instance.enableLogging,
|
||||||
'useExtensionProviders': instance.useExtensionProviders,
|
'useExtensionProviders': instance.useExtensionProviders,
|
||||||
|
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
|
||||||
'searchProvider': instance.searchProvider,
|
'searchProvider': instance.searchProvider,
|
||||||
'homeFeedProvider': instance.homeFeedProvider,
|
'homeFeedProvider': instance.homeFeedProvider,
|
||||||
'separateSingles': instance.separateSingles,
|
'separateSingles': instance.separateSingles,
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ class Track {
|
|||||||
final int duration;
|
final int duration;
|
||||||
final int? trackNumber;
|
final int? trackNumber;
|
||||||
final int? discNumber;
|
final int? discNumber;
|
||||||
|
final int? totalDiscs;
|
||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
final String? deezerId;
|
final String? deezerId;
|
||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
final String? source;
|
final String? source;
|
||||||
final String? albumType;
|
final String? albumType;
|
||||||
final int? totalTracks;
|
final int? totalTracks;
|
||||||
|
final String? composer;
|
||||||
final String? itemType;
|
final String? itemType;
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
@@ -37,12 +39,14 @@ class Track {
|
|||||||
required this.duration,
|
required this.duration,
|
||||||
this.trackNumber,
|
this.trackNumber,
|
||||||
this.discNumber,
|
this.discNumber,
|
||||||
|
this.totalDiscs,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
this.deezerId,
|
this.deezerId,
|
||||||
this.availability,
|
this.availability,
|
||||||
this.source,
|
this.source,
|
||||||
this.albumType,
|
this.albumType,
|
||||||
this.totalTracks,
|
this.totalTracks,
|
||||||
|
this.composer,
|
||||||
this.itemType,
|
this.itemType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
duration: (json['duration'] as num).toInt(),
|
duration: (json['duration'] as num).toInt(),
|
||||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||||
|
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
deezerId: json['deezerId'] as String?,
|
deezerId: json['deezerId'] as String?,
|
||||||
availability: json['availability'] == null
|
availability: json['availability'] == null
|
||||||
@@ -29,6 +30,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
source: json['source'] as String?,
|
source: json['source'] as String?,
|
||||||
albumType: json['albumType'] as String?,
|
albumType: json['albumType'] as String?,
|
||||||
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||||
|
composer: json['composer'] as String?,
|
||||||
itemType: json['itemType'] as String?,
|
itemType: json['itemType'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -45,12 +47,14 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'duration': instance.duration,
|
'duration': instance.duration,
|
||||||
'trackNumber': instance.trackNumber,
|
'trackNumber': instance.trackNumber,
|
||||||
'discNumber': instance.discNumber,
|
'discNumber': instance.discNumber,
|
||||||
|
'totalDiscs': instance.totalDiscs,
|
||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
'deezerId': instance.deezerId,
|
'deezerId': instance.deezerId,
|
||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
'source': instance.source,
|
'source': instance.source,
|
||||||
'albumType': instance.albumType,
|
'albumType': instance.albumType,
|
||||||
'totalTracks': instance.totalTracks,
|
'totalTracks': instance.totalTracks,
|
||||||
|
'composer': instance.composer,
|
||||||
'itemType': instance.itemType,
|
'itemType': instance.itemType,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -820,7 +820,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
|
for (final provider in const ['tidal', 'qobuz']) {
|
||||||
if (!result.contains(provider)) {
|
if (!result.contains(provider)) {
|
||||||
result.add(provider);
|
result.add(provider);
|
||||||
}
|
}
|
||||||
@@ -896,7 +896,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> getAllDownloadProviders() {
|
List<String> getAllDownloadProviders() {
|
||||||
final providers = ['tidal', 'qobuz', 'deezer'];
|
final providers = ['tidal', 'qobuz'];
|
||||||
for (final ext in state.extensions) {
|
for (final ext in state.extensions) {
|
||||||
if (ext.enabled && ext.hasDownloadProvider) {
|
if (ext.enabled && ext.hasDownloadProvider) {
|
||||||
providers.add(ext.id);
|
providers.add(ext.id);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
const _migrationVersionKey = 'settings_migration_version';
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
const _currentMigrationVersion = 9;
|
const _currentMigrationVersion = 10;
|
||||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||||
final _log = AppLogger('SettingsProvider');
|
final _log = AppLogger('SettingsProvider');
|
||||||
|
|
||||||
@@ -35,9 +35,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final prefs = await _prefs;
|
final prefs = await _prefs;
|
||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(
|
final loaded = AppSettings.fromJson(
|
||||||
Map<String, dynamic>.from(jsonDecode(json) as Map),
|
Map<String, dynamic>.from(jsonDecode(json) as Map),
|
||||||
);
|
);
|
||||||
|
final sanitizedDownloadFallbackExtensionIds =
|
||||||
|
_sanitizeDownloadFallbackExtensionIds(
|
||||||
|
loaded.downloadFallbackExtensionIds,
|
||||||
|
);
|
||||||
|
state = loaded.copyWith(
|
||||||
|
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
|
||||||
|
clearDownloadFallbackExtensionIds:
|
||||||
|
loaded.downloadFallbackExtensionIds != null &&
|
||||||
|
sanitizedDownloadFallbackExtensionIds == null,
|
||||||
|
);
|
||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||||
@@ -50,6 +60,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
|
|
||||||
_syncLyricsSettingsToBackend();
|
_syncLyricsSettingsToBackend();
|
||||||
_syncNetworkCompatibilitySettingsToBackend();
|
_syncNetworkCompatibilitySettingsToBackend();
|
||||||
|
_syncExtensionFallbackSettingsToBackend();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncLyricsSettingsToBackend() {
|
void _syncLyricsSettingsToBackend() {
|
||||||
@@ -83,6 +94,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncExtensionFallbackSettingsToBackend() {
|
||||||
|
if (!PlatformBridge.supportsCoreBackend) return;
|
||||||
|
|
||||||
|
PlatformBridge.setDownloadFallbackExtensionIds(
|
||||||
|
state.downloadFallbackExtensionIds,
|
||||||
|
).catchError((Object e) {
|
||||||
|
_log.w('Failed to sync extension fallback settings to backend: $e');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||||
|
|
||||||
@@ -111,8 +132,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||||
// Migration 7: YouTube is no longer a built-in service — reset to Tidal
|
// Migration 7/10: retired built-in services reset back to Tidal
|
||||||
if (state.defaultService == 'youtube') {
|
if (state.defaultService == 'youtube' ||
|
||||||
|
state.defaultService == 'deezer') {
|
||||||
state = state.copyWith(defaultService: 'tidal');
|
state = state.copyWith(defaultService: 'tidal');
|
||||||
}
|
}
|
||||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
@@ -172,6 +194,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String>? _sanitizeDownloadFallbackExtensionIds(List<String>? ids) {
|
||||||
|
if (ids == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String>[];
|
||||||
|
for (final id in ids) {
|
||||||
|
final normalized = id.trim();
|
||||||
|
if (normalized.isEmpty || result.contains(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.add(normalized);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _cleanupRetiredSpotifySettings() async {
|
Future<void> _cleanupRetiredSpotifySettings() async {
|
||||||
final storedSecret = await _secureStorage.read(
|
final storedSecret = await _secureStorage.read(
|
||||||
key: _spotifyClientSecretKey,
|
key: _spotifyClientSecretKey,
|
||||||
@@ -390,6 +428,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setDownloadFallbackExtensionIds(List<String>? extensionIds) {
|
||||||
|
final sanitized = _sanitizeDownloadFallbackExtensionIds(extensionIds);
|
||||||
|
state = state.copyWith(
|
||||||
|
downloadFallbackExtensionIds: sanitized,
|
||||||
|
clearDownloadFallbackExtensionIds:
|
||||||
|
extensionIds == null && state.downloadFallbackExtensionIds != null,
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
_syncExtensionFallbackSettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
void setSeparateSingles(bool enabled) {
|
void setSeparateSingles(bool enabled) {
|
||||||
state = state.copyWith(separateSingles: enabled);
|
state = state.copyWith(separateSingles: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -906,9 +906,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
albumType: data['album_type'] as String?,
|
albumType: normalizeOptionalString(data['album_type']?.toString()),
|
||||||
totalTracks: data['total_tracks'] as int?,
|
totalTracks: data['total_tracks'] as int?,
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,10 +941,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
totalTracks: data['total_tracks'] as int?,
|
totalTracks: data['total_tracks'] as int?,
|
||||||
source: effectiveSource,
|
source: effectiveSource,
|
||||||
albumType: data['album_type']?.toString(),
|
albumType: normalizeOptionalString(data['album_type']?.toString()),
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
itemType: itemType,
|
itemType: itemType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
String? _artistId;
|
String? _artistId;
|
||||||
|
String? _albumType;
|
||||||
|
int? _albumTotalTracks;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -112,6 +114,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
_tracks = _AlbumCache.get(widget.albumId);
|
_tracks = _AlbumCache.get(widget.albumId);
|
||||||
}
|
}
|
||||||
_artistId = widget.artistId;
|
_artistId = widget.artistId;
|
||||||
|
_albumType = _tracks?.firstOrNull?.albumType;
|
||||||
|
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
|
||||||
|
|
||||||
if (_tracks == null || _tracks!.isEmpty) {
|
if (_tracks == null || _tracks!.isEmpty) {
|
||||||
_fetchTracks();
|
_fetchTracks();
|
||||||
@@ -179,13 +183,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
deezerAlbumId,
|
deezerAlbumId,
|
||||||
);
|
);
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
?.toString();
|
?.toString();
|
||||||
|
final albumType = normalizeOptionalString(
|
||||||
|
albumInfo?['album_type']?.toString(),
|
||||||
|
);
|
||||||
|
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||||
|
final tracks = trackList
|
||||||
|
.map(
|
||||||
|
(t) => _parseTrack(
|
||||||
|
t as Map<String, dynamic>,
|
||||||
|
albumTypeFallback: albumType,
|
||||||
|
totalTracksFallback: totalTracks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
_AlbumCache.set(widget.albumId, tracks);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
@@ -193,6 +206,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
|
_albumType = albumType;
|
||||||
|
_albumTotalTracks = totalTracks;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -204,13 +219,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
qobuzAlbumId,
|
qobuzAlbumId,
|
||||||
);
|
);
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
?.toString();
|
?.toString();
|
||||||
|
final albumType = normalizeOptionalString(
|
||||||
|
albumInfo?['album_type']?.toString(),
|
||||||
|
);
|
||||||
|
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||||
|
final tracks = trackList
|
||||||
|
.map(
|
||||||
|
(t) => _parseTrack(
|
||||||
|
t as Map<String, dynamic>,
|
||||||
|
albumTypeFallback: albumType,
|
||||||
|
totalTracksFallback: totalTracks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
_AlbumCache.set(widget.albumId, tracks);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
@@ -218,6 +242,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
|
_albumType = albumType;
|
||||||
|
_albumTotalTracks = totalTracks;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -229,13 +255,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
tidalAlbumId,
|
tidalAlbumId,
|
||||||
);
|
);
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
?.toString();
|
?.toString();
|
||||||
|
final albumType = normalizeOptionalString(
|
||||||
|
albumInfo?['album_type']?.toString(),
|
||||||
|
);
|
||||||
|
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||||
|
final tracks = trackList
|
||||||
|
.map(
|
||||||
|
(t) => _parseTrack(
|
||||||
|
t as Map<String, dynamic>,
|
||||||
|
albumTypeFallback: albumType,
|
||||||
|
totalTracksFallback: totalTracks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
_AlbumCache.set(widget.albumId, tracks);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
@@ -243,6 +278,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
|
_albumType = albumType;
|
||||||
|
_albumTotalTracks = totalTracks;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -255,13 +292,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final trackList = result['tracks'] as List<dynamic>;
|
final trackList = result['tracks'] as List<dynamic>;
|
||||||
final tracks = trackList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final albumInfo = result['album'] as Map<String, dynamic>?;
|
final albumInfo = result['album'] as Map<String, dynamic>?;
|
||||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
?.toString();
|
?.toString();
|
||||||
|
final albumType = normalizeOptionalString(
|
||||||
|
albumInfo?['album_type']?.toString(),
|
||||||
|
);
|
||||||
|
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||||
|
final tracks = trackList
|
||||||
|
.map(
|
||||||
|
(t) => _parseTrack(
|
||||||
|
t as Map<String, dynamic>,
|
||||||
|
albumTypeFallback: albumType,
|
||||||
|
totalTracksFallback: totalTracks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
_AlbumCache.set(widget.albumId, tracks);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
@@ -269,6 +315,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
|
_albumType = albumType;
|
||||||
|
_albumTotalTracks = totalTracks;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -284,7 +332,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Track _parseTrack(Map<String, dynamic> data) {
|
Track _parseTrack(
|
||||||
|
Map<String, dynamic> data, {
|
||||||
|
String? albumTypeFallback,
|
||||||
|
int? totalTracksFallback,
|
||||||
|
}) {
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: data['spotify_id'] as String? ?? '',
|
||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
@@ -299,9 +351,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
albumType: data['album_type'] as String?,
|
albumType:
|
||||||
totalTracks: data['total_tracks'] as int?,
|
normalizeOptionalString(data['album_type']?.toString()) ??
|
||||||
|
albumTypeFallback ??
|
||||||
|
_albumType,
|
||||||
|
totalTracks:
|
||||||
|
data['total_tracks'] as int? ??
|
||||||
|
totalTracksFallback ??
|
||||||
|
_albumTotalTracks,
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +371,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||||
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||||
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
}
|
}
|
||||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||||
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||||
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,9 +409,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
albumType: data['album_type']?.toString() ?? album?.albumType,
|
albumType:
|
||||||
|
normalizeOptionalString(data['album_type']?.toString()) ??
|
||||||
|
album?.albumType,
|
||||||
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
source: data['provider_id']?.toString() ?? widget.extensionId,
|
source: data['provider_id']?.toString() ?? widget.extensionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1055,9 +1058,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
if (result != null && result['tracks'] != null) {
|
if (result != null && result['tracks'] != null) {
|
||||||
final tracksList = result['tracks'] as List<dynamic>;
|
final tracksList = result['tracks'] as List<dynamic>;
|
||||||
return tracksList
|
final parsedTracks = tracksList
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||||
.toList();
|
.toList();
|
||||||
|
return parsedTracks;
|
||||||
}
|
}
|
||||||
} else if (album.id.startsWith('deezer:')) {
|
} else if (album.id.startsWith('deezer:')) {
|
||||||
final deezerId = album.id.replaceFirst('deezer:', '');
|
final deezerId = album.id.replaceFirst('deezer:', '');
|
||||||
@@ -1129,9 +1133,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
trackNumber:
|
trackNumber:
|
||||||
data['track_position'] as int? ?? data['track_number'] as int?,
|
data['track_position'] as int? ?? data['track_number'] as int?,
|
||||||
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: album.releaseDate,
|
releaseDate: album.releaseDate,
|
||||||
albumType: album.albumType,
|
albumType: album.albumType,
|
||||||
totalTracks: album.totalTracks,
|
totalTracks: album.totalTracks,
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1930,6 +1936,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
albumName: album.name,
|
albumName: album.name,
|
||||||
coverUrl: album.coverUrl,
|
coverUrl: album.coverUrl,
|
||||||
|
initialAlbumType: album.albumType,
|
||||||
|
initialTotalTracks: album.totalTracks,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -299,7 +299,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
Future<void> _navigateToMetadataScreen(
|
||||||
|
DownloadHistoryItem item, {
|
||||||
|
required List<DownloadHistoryItem> navigationItems,
|
||||||
|
required int navigationIndex,
|
||||||
|
}) async {
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
_precacheCover(item.coverUrl);
|
_precacheCover(item.coverUrl);
|
||||||
final beforeModTime =
|
final beforeModTime =
|
||||||
@@ -309,7 +313,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final result = await navigator.push(
|
final result = await navigator.push(
|
||||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
slidePageRoute<bool>(
|
||||||
|
page: TrackMetadataScreen(
|
||||||
|
item: item,
|
||||||
|
historyNavigationItems: navigationItems,
|
||||||
|
navigationIndex: navigationIndex,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
@@ -691,7 +701,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: StaggeredListItem(
|
child: StaggeredListItem(
|
||||||
index: index,
|
index: index,
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: _buildTrackItem(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
track,
|
||||||
|
tracks,
|
||||||
|
index,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: tracks.length),
|
}, childCount: tracks.length),
|
||||||
@@ -709,12 +725,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
|
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
|
||||||
|
|
||||||
for (final track in discTracks) {
|
for (final track in discTracks) {
|
||||||
|
final navigationIndex = tracks.indexOf(track);
|
||||||
children.add(
|
children.add(
|
||||||
KeyedSubtree(
|
KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: StaggeredListItem(
|
child: StaggeredListItem(
|
||||||
index: revealIndex++,
|
index: revealIndex++,
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: _buildTrackItem(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
track,
|
||||||
|
tracks,
|
||||||
|
navigationIndex,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -774,6 +797,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
DownloadHistoryItem track,
|
DownloadHistoryItem track,
|
||||||
|
List<DownloadHistoryItem> navigationItems,
|
||||||
|
int navigationIndex,
|
||||||
) {
|
) {
|
||||||
final isSelected = _selectedIds.contains(track.id);
|
final isSelected = _selectedIds.contains(track.id);
|
||||||
|
|
||||||
@@ -791,7 +816,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
onTap: _isSelectionMode
|
onTap: _isSelectionMode
|
||||||
? () => _toggleSelection(track.id)
|
? () => _toggleSelection(track.id)
|
||||||
: () => _navigateToMetadataScreen(track),
|
: () => _navigateToMetadataScreen(
|
||||||
|
track,
|
||||||
|
navigationItems: navigationItems,
|
||||||
|
navigationIndex: navigationIndex,
|
||||||
|
),
|
||||||
onLongPress: _isSelectionMode
|
onLongPress: _isSelectionMode
|
||||||
? null
|
? null
|
||||||
: () => _enterSelectionMode(track.id),
|
: () => _enterSelectionMode(track.id),
|
||||||
|
|||||||
@@ -1443,7 +1443,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
button: true,
|
button: true,
|
||||||
label: 'Open track ${item.trackName} by ${item.artistName}',
|
label: 'Open track ${item.trackName} by ${item.artistName}',
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _navigateToMetadataScreen(item),
|
onTap: () => _navigateToMetadataScreen(
|
||||||
|
item,
|
||||||
|
navigationItems: items
|
||||||
|
.take(itemCount)
|
||||||
|
.toList(growable: false),
|
||||||
|
navigationIndex: index,
|
||||||
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
margin: const EdgeInsets.only(right: 12),
|
margin: const EdgeInsets.only(right: 12),
|
||||||
@@ -1840,6 +1846,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
duration: item.durationMs ~/ 1000,
|
duration: item.durationMs ~/ 1000,
|
||||||
trackNumber: null,
|
trackNumber: null,
|
||||||
discNumber: null,
|
discNumber: null,
|
||||||
|
totalDiscs: null,
|
||||||
isrc: null,
|
isrc: null,
|
||||||
releaseDate: item.releaseDate,
|
releaseDate: item.releaseDate,
|
||||||
coverUrl: item.coverUrl,
|
coverUrl: item.coverUrl,
|
||||||
@@ -2216,7 +2223,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
Future<void> _navigateToMetadataScreen(
|
||||||
|
DownloadHistoryItem item, {
|
||||||
|
List<DownloadHistoryItem>? navigationItems,
|
||||||
|
int? navigationIndex,
|
||||||
|
}) async {
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
_precacheCover(item.coverUrl);
|
_precacheCover(item.coverUrl);
|
||||||
final beforeModTime =
|
final beforeModTime =
|
||||||
@@ -2225,7 +2236,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final result = await navigator.push(
|
final result = await navigator.push(
|
||||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
slidePageRoute<bool>(
|
||||||
|
page: TrackMetadataScreen(
|
||||||
|
item: item,
|
||||||
|
historyNavigationItems: navigationItems,
|
||||||
|
navigationIndex: navigationIndex,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
@@ -3014,6 +3031,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
albumId: albumItem.id,
|
albumId: albumItem.id,
|
||||||
albumName: albumItem.name,
|
albumName: albumItem.name,
|
||||||
coverUrl: albumItem.coverUrl,
|
coverUrl: albumItem.coverUrl,
|
||||||
|
initialAlbumType: albumItem.albumType,
|
||||||
|
initialTotalTracks: albumItem.totalTracks,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -4298,6 +4317,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
|||||||
final String albumId;
|
final String albumId;
|
||||||
final String albumName;
|
final String albumName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
|
final String? initialAlbumType;
|
||||||
|
final int? initialTotalTracks;
|
||||||
|
|
||||||
const ExtensionAlbumScreen({
|
const ExtensionAlbumScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -4305,6 +4326,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
|||||||
required this.albumId,
|
required this.albumId,
|
||||||
required this.albumName,
|
required this.albumName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.initialAlbumType,
|
||||||
|
this.initialTotalTracks,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -4318,10 +4341,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
String? _artistId;
|
String? _artistId;
|
||||||
String? _artistName;
|
String? _artistName;
|
||||||
|
String? _albumType;
|
||||||
|
int? _albumTotalTracks;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_albumType = normalizeOptionalString(widget.initialAlbumType);
|
||||||
|
_albumTotalTracks = widget.initialTotalTracks;
|
||||||
_fetchTracks();
|
_fetchTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4355,17 +4382,28 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final tracks = trackList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
|
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
|
||||||
final artistName = result['artists'] as String?;
|
final artistName = result['artists'] as String?;
|
||||||
|
final albumType =
|
||||||
|
normalizeOptionalString(result['album_type']?.toString()) ??
|
||||||
|
_albumType;
|
||||||
|
final totalTracks = result['total_tracks'] as int? ?? _albumTotalTracks;
|
||||||
|
final tracks = trackList
|
||||||
|
.map(
|
||||||
|
(t) => _parseTrack(
|
||||||
|
t as Map<String, dynamic>,
|
||||||
|
albumTypeFallback: albumType,
|
||||||
|
totalTracksFallback: totalTracks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
_artistName = artistName;
|
_artistName = artistName;
|
||||||
|
_albumType = albumType;
|
||||||
|
_albumTotalTracks = totalTracks;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -4377,7 +4415,11 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Track _parseTrack(Map<String, dynamic> data) {
|
Track _parseTrack(
|
||||||
|
Map<String, dynamic> data, {
|
||||||
|
String? albumTypeFallback,
|
||||||
|
int? totalTracksFallback,
|
||||||
|
}) {
|
||||||
int durationMs = 0;
|
int durationMs = 0;
|
||||||
final durationValue = data['duration_ms'];
|
final durationValue = data['duration_ms'];
|
||||||
if (durationValue is int) {
|
if (durationValue is int) {
|
||||||
@@ -4403,7 +4445,17 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
|
albumType:
|
||||||
|
normalizeOptionalString(data['album_type']?.toString()) ??
|
||||||
|
albumTypeFallback ??
|
||||||
|
_albumType,
|
||||||
|
totalTracks:
|
||||||
|
data['total_tracks'] as int? ??
|
||||||
|
totalTracksFallback ??
|
||||||
|
_albumTotalTracks,
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
source: widget.extensionId,
|
source: widget.extensionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4562,7 +4614,10 @@ class _ExtensionPlaylistScreenState
|
|||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
|
totalTracks: data['total_tracks'] as int?,
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
source: widget.extensionId,
|
source: widget.extensionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4739,7 +4794,10 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
|
totalTracks: data['total_tracks'] as int?,
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
source: (data['provider_id'] ?? widget.extensionId).toString(),
|
source: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1631,7 +1631,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
try {
|
try {
|
||||||
await PlatformBridge.safDelete(item.filePath);
|
await PlatformBridge.safDelete(item.filePath);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
await localDb.deleteByPath(item.filePath);
|
await localDb.replaceWithConvertedItem(
|
||||||
|
item: item,
|
||||||
|
newFilePath: safUri,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1643,8 +1648,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular file: just remove old entry, rescan will find the new one
|
await localDb.replaceWithConvertedItem(
|
||||||
await localDb.deleteByPath(item.filePath);
|
item: item,
|
||||||
|
newFilePath: newPath,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
successCount++;
|
successCount++;
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
if (playlistId != null) {
|
if (playlistId != null) {
|
||||||
if (playlistId.startsWith('tidal:')) return 'tidal';
|
if (playlistId.startsWith('tidal:')) return 'tidal';
|
||||||
if (playlistId.startsWith('qobuz:')) return 'qobuz';
|
if (playlistId.startsWith('qobuz:')) return 'qobuz';
|
||||||
if (playlistId.startsWith('deezer:')) return 'deezer';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final source = _tracks.firstOrNull?.source;
|
final source = _tracks.firstOrNull?.source;
|
||||||
@@ -72,7 +71,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
final trackId = _tracks.firstOrNull?.id ?? '';
|
final trackId = _tracks.firstOrNull?.id ?? '';
|
||||||
if (trackId.startsWith('tidal:')) return 'tidal';
|
if (trackId.startsWith('tidal:')) return 'tidal';
|
||||||
if (trackId.startsWith('qobuz:')) return 'qobuz';
|
if (trackId.startsWith('qobuz:')) return 'qobuz';
|
||||||
if (trackId.startsWith('deezer:')) return 'deezer';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +162,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
|
totalTracks: data['total_tracks'] as int?,
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+145
-21
@@ -2963,15 +2963,23 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _navigateToHistoryMetadataScreen(
|
Future<void> _navigateToHistoryMetadataScreen(
|
||||||
DownloadHistoryItem item,
|
DownloadHistoryItem item, {
|
||||||
) async {
|
List<DownloadHistoryItem>? navigationItems,
|
||||||
|
int? navigationIndex,
|
||||||
|
}) async {
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
_precacheCover(item.coverUrl);
|
_precacheCover(item.coverUrl);
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final result = await navigator.push(
|
final result = await navigator.push(
|
||||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
slidePageRoute<bool>(
|
||||||
|
page: TrackMetadataScreen(
|
||||||
|
item: item,
|
||||||
|
historyNavigationItems: navigationItems,
|
||||||
|
navigationIndex: navigationIndex,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
@@ -2988,11 +2996,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToLocalMetadataScreen(LocalLibraryItem item) {
|
void _navigateToLocalMetadataScreen(
|
||||||
|
LocalLibraryItem item, {
|
||||||
|
List<LocalLibraryItem>? navigationItems,
|
||||||
|
int? navigationIndex,
|
||||||
|
}) {
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
slidePageRoute<void>(page: TrackMetadataScreen(localItem: item)),
|
slidePageRoute<void>(
|
||||||
|
page: TrackMetadataScreen(
|
||||||
|
localItem: item,
|
||||||
|
localNavigationItems: navigationItems,
|
||||||
|
navigationIndex: navigationIndex,
|
||||||
|
),
|
||||||
|
),
|
||||||
).then((_) => _searchFocusNode.unfocus());
|
).then((_) => _searchFocusNode.unfocus());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4227,6 +4245,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final filteredUnifiedItems = filterData.filteredUnifiedItems;
|
final filteredUnifiedItems = filterData.filteredUnifiedItems;
|
||||||
final totalTrackCount = filterData.totalTrackCount;
|
final totalTrackCount = filterData.totalTrackCount;
|
||||||
final totalAlbumCount = filterData.totalAlbumCount;
|
final totalAlbumCount = filterData.totalAlbumCount;
|
||||||
|
final downloadedNavigationItems = <DownloadHistoryItem>[];
|
||||||
|
final downloadedNavigationIndexByUnifiedId = <String, int>{};
|
||||||
|
final localNavigationItems = <LocalLibraryItem>[];
|
||||||
|
final localNavigationIndexByUnifiedId = <String, int>{};
|
||||||
|
|
||||||
|
for (final item in filteredUnifiedItems) {
|
||||||
|
final historyItem = item.historyItem;
|
||||||
|
if (historyItem != null) {
|
||||||
|
downloadedNavigationIndexByUnifiedId[item.id] =
|
||||||
|
downloadedNavigationItems.length;
|
||||||
|
downloadedNavigationItems.add(historyItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
final localItem = item.localItem;
|
||||||
|
if (localItem != null) {
|
||||||
|
localNavigationIndexByUnifiedId[item.id] = localNavigationItems.length;
|
||||||
|
localNavigationItems.add(localItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -4419,12 +4456,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
downloadedNavigationItems:
|
||||||
|
downloadedNavigationItems,
|
||||||
|
downloadedNavigationIndex:
|
||||||
|
downloadedNavigationIndexByUnifiedId[item.id],
|
||||||
|
localNavigationItems: localNavigationItems,
|
||||||
|
localNavigationIndex:
|
||||||
|
localNavigationIndexByUnifiedId[item.id],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _buildUnifiedGridItem(
|
child: _buildUnifiedGridItem(
|
||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
downloadedNavigationItems:
|
||||||
|
downloadedNavigationItems,
|
||||||
|
downloadedNavigationIndex:
|
||||||
|
downloadedNavigationIndexByUnifiedId[item.id],
|
||||||
|
localNavigationItems: localNavigationItems,
|
||||||
|
localNavigationIndex:
|
||||||
|
localNavigationIndexByUnifiedId[item.id],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -4472,12 +4523,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
downloadedNavigationItems:
|
||||||
|
downloadedNavigationItems,
|
||||||
|
downloadedNavigationIndex:
|
||||||
|
downloadedNavigationIndexByUnifiedId[item.id],
|
||||||
|
localNavigationItems: localNavigationItems,
|
||||||
|
localNavigationIndex:
|
||||||
|
localNavigationIndexByUnifiedId[item.id],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _buildUnifiedLibraryItem(
|
child: _buildUnifiedLibraryItem(
|
||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
downloadedNavigationItems: downloadedNavigationItems,
|
||||||
|
downloadedNavigationIndex:
|
||||||
|
downloadedNavigationIndexByUnifiedId[item.id],
|
||||||
|
localNavigationItems: localNavigationItems,
|
||||||
|
localNavigationIndex:
|
||||||
|
localNavigationIndexByUnifiedId[item.id],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -4540,6 +4604,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
downloadedNavigationItems: downloadedNavigationItems,
|
||||||
|
downloadedNavigationIndex:
|
||||||
|
downloadedNavigationIndexByUnifiedId[item.id],
|
||||||
|
localNavigationItems: localNavigationItems,
|
||||||
|
localNavigationIndex:
|
||||||
|
localNavigationIndexByUnifiedId[item.id],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: filteredUnifiedItems.length),
|
}, childCount: filteredUnifiedItems.length),
|
||||||
@@ -4554,6 +4624,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
downloadedNavigationItems: downloadedNavigationItems,
|
||||||
|
downloadedNavigationIndex:
|
||||||
|
downloadedNavigationIndexByUnifiedId[item.id],
|
||||||
|
localNavigationItems: localNavigationItems,
|
||||||
|
localNavigationIndex:
|
||||||
|
localNavigationIndexByUnifiedId[item.id],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: filteredUnifiedItems.length),
|
}, childCount: filteredUnifiedItems.length),
|
||||||
@@ -5853,13 +5929,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final baseName = dotIdx > 0
|
final baseName = dotIdx > 0
|
||||||
? oldFileName.substring(0, dotIdx)
|
? oldFileName.substring(0, dotIdx)
|
||||||
: oldFileName;
|
: oldFileName;
|
||||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
String newExt;
|
||||||
? '.opus'
|
String mimeType;
|
||||||
: '.mp3';
|
switch (targetFormat.toLowerCase()) {
|
||||||
|
case 'opus':
|
||||||
|
newExt = '.opus';
|
||||||
|
mimeType = 'audio/opus';
|
||||||
|
break;
|
||||||
|
case 'alac':
|
||||||
|
newExt = '.m4a';
|
||||||
|
mimeType = 'audio/mp4';
|
||||||
|
break;
|
||||||
|
case 'flac':
|
||||||
|
newExt = '.flac';
|
||||||
|
mimeType = 'audio/flac';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newExt = '.mp3';
|
||||||
|
mimeType = 'audio/mpeg';
|
||||||
|
break;
|
||||||
|
}
|
||||||
final newFileName = '$baseName$newExt';
|
final newFileName = '$baseName$newExt';
|
||||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
|
||||||
? 'audio/opus'
|
|
||||||
: 'audio/mpeg';
|
|
||||||
|
|
||||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
treeUri: treeUri,
|
treeUri: treeUri,
|
||||||
@@ -5884,7 +5974,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
try {
|
try {
|
||||||
await PlatformBridge.safDelete(item.filePath);
|
await PlatformBridge.safDelete(item.filePath);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||||
|
item: item.localItem!,
|
||||||
|
newFilePath: safUri,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -5903,7 +5998,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
clearAudioSpecs: true,
|
clearAudioSpecs: true,
|
||||||
);
|
);
|
||||||
} else if (item.localItem != null) {
|
} else if (item.localItem != null) {
|
||||||
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||||
|
item: item.localItem!,
|
||||||
|
newFilePath: newPath,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
successCount++;
|
successCount++;
|
||||||
@@ -6585,8 +6685,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
Widget _buildUnifiedLibraryItem(
|
Widget _buildUnifiedLibraryItem(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
UnifiedLibraryItem item,
|
UnifiedLibraryItem item,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme, {
|
||||||
) {
|
required List<DownloadHistoryItem> downloadedNavigationItems,
|
||||||
|
required int? downloadedNavigationIndex,
|
||||||
|
required List<LocalLibraryItem> localNavigationItems,
|
||||||
|
required int? localNavigationIndex,
|
||||||
|
}) {
|
||||||
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
||||||
final isSelected = _selectedIds.contains(item.id);
|
final isSelected = _selectedIds.contains(item.id);
|
||||||
final date = item.addedAt;
|
final date = item.addedAt;
|
||||||
@@ -6616,9 +6720,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
onTap: _isSelectionMode
|
onTap: _isSelectionMode
|
||||||
? () => _toggleSelection(item.id)
|
? () => _toggleSelection(item.id)
|
||||||
: isDownloaded
|
: isDownloaded
|
||||||
? () => _navigateToHistoryMetadataScreen(item.historyItem!)
|
? () => _navigateToHistoryMetadataScreen(
|
||||||
|
item.historyItem!,
|
||||||
|
navigationItems: downloadedNavigationItems,
|
||||||
|
navigationIndex: downloadedNavigationIndex,
|
||||||
|
)
|
||||||
: item.localItem != null
|
: item.localItem != null
|
||||||
? () => _navigateToLocalMetadataScreen(item.localItem!)
|
? () => _navigateToLocalMetadataScreen(
|
||||||
|
item.localItem!,
|
||||||
|
navigationItems: localNavigationItems,
|
||||||
|
navigationIndex: localNavigationIndex,
|
||||||
|
)
|
||||||
: () => _openFile(
|
: () => _openFile(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
title: item.trackName,
|
title: item.trackName,
|
||||||
@@ -6792,8 +6904,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
Widget _buildUnifiedGridItem(
|
Widget _buildUnifiedGridItem(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
UnifiedLibraryItem item,
|
UnifiedLibraryItem item,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme, {
|
||||||
) {
|
required List<DownloadHistoryItem> downloadedNavigationItems,
|
||||||
|
required int? downloadedNavigationIndex,
|
||||||
|
required List<LocalLibraryItem> localNavigationItems,
|
||||||
|
required int? localNavigationIndex,
|
||||||
|
}) {
|
||||||
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
final fileExistsListenable = _fileExistsListenable(item.filePath);
|
||||||
final isSelected = _selectedIds.contains(item.id);
|
final isSelected = _selectedIds.contains(item.id);
|
||||||
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
||||||
@@ -6802,9 +6918,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
onTap: _isSelectionMode
|
onTap: _isSelectionMode
|
||||||
? () => _toggleSelection(item.id)
|
? () => _toggleSelection(item.id)
|
||||||
: isDownloaded
|
: isDownloaded
|
||||||
? () => _navigateToHistoryMetadataScreen(item.historyItem!)
|
? () => _navigateToHistoryMetadataScreen(
|
||||||
|
item.historyItem!,
|
||||||
|
navigationItems: downloadedNavigationItems,
|
||||||
|
navigationIndex: downloadedNavigationIndex,
|
||||||
|
)
|
||||||
: item.localItem != null
|
: item.localItem != null
|
||||||
? () => _navigateToLocalMetadataScreen(item.localItem!)
|
? () => _navigateToLocalMetadataScreen(
|
||||||
|
item.localItem!,
|
||||||
|
navigationItems: localNavigationItems,
|
||||||
|
navigationIndex: localNavigationIndex,
|
||||||
|
)
|
||||||
: () => _openFile(
|
: () => _openFile(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
title: item.trackName,
|
title: item.trackName,
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class DownloadFallbackExtensionsPage extends ConsumerStatefulWidget {
|
||||||
|
const DownloadFallbackExtensionsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<DownloadFallbackExtensionsPage> createState() =>
|
||||||
|
_DownloadFallbackExtensionsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadFallbackExtensionsPageState
|
||||||
|
extends ConsumerState<DownloadFallbackExtensionsPage> {
|
||||||
|
late List<Extension> _extensions;
|
||||||
|
late Set<String> _selectedExtensionIds;
|
||||||
|
bool _hasChanges = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadExtensions() {
|
||||||
|
final extState = ref.read(extensionProvider);
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
|
_extensions = extState.extensions
|
||||||
|
.where(
|
||||||
|
(extension) => extension.enabled && extension.hasDownloadProvider,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final savedIds = settings.downloadFallbackExtensionIds;
|
||||||
|
if (savedIds == null) {
|
||||||
|
_selectedExtensionIds = _extensions
|
||||||
|
.map((extension) => extension.id)
|
||||||
|
.toSet();
|
||||||
|
} else {
|
||||||
|
final allowedIds = _extensions.map((extension) => extension.id).toSet();
|
||||||
|
_selectedExtensionIds = savedIds
|
||||||
|
.where((extensionId) => allowedIds.contains(extensionId))
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_hasChanges,
|
||||||
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
|
if (didPop) return;
|
||||||
|
final shouldPop = await _confirmDiscard(context);
|
||||||
|
if (shouldPop && context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 120 + topPadding,
|
||||||
|
collapsedHeight: kToolbarHeight,
|
||||||
|
floating: false,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
leading: IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () async {
|
||||||
|
if (_hasChanges) {
|
||||||
|
final shouldPop = await _confirmDiscard(context);
|
||||||
|
if (shouldPop && context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_hasChanges)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saveChanges,
|
||||||
|
child: Text(context.l10n.dialogSave),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxHeight = 120 + topPadding;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final expandRatio =
|
||||||
|
((constraints.maxHeight - minHeight) /
|
||||||
|
(maxHeight - minHeight))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
final leftPadding = 56 - (32 * expandRatio);
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding: EdgeInsets.only(
|
||||||
|
left: leftPadding,
|
||||||
|
bottom: 16,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
context.l10n.extensionsFallbackTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.providerPriorityFallbackExtensionsDescription,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_extensions.isEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.4,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.extensionsNoDownloadProvider,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_extensions.isNotEmpty)
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
children: List.generate(_extensions.length, (index) {
|
||||||
|
final extension = _extensions[index];
|
||||||
|
final isSelected = _selectedExtensionIds.contains(
|
||||||
|
extension.id,
|
||||||
|
);
|
||||||
|
return SettingsSwitchItem(
|
||||||
|
icon: Icons.extension_rounded,
|
||||||
|
title: extension.displayName,
|
||||||
|
subtitle: extension.id,
|
||||||
|
value: isSelected,
|
||||||
|
showDivider: index != _extensions.length - 1,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_selectedExtensionIds.add(extension.id);
|
||||||
|
} else {
|
||||||
|
_selectedExtensionIds.remove(extension.id);
|
||||||
|
}
|
||||||
|
_hasChanges = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_extensions.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.providerPriorityFallbackExtensionsHint,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(context.l10n.dialogDiscardChanges),
|
||||||
|
content: Text(context.l10n.dialogUnsavedChanges),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(context.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text(context.l10n.dialogDiscard),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveChanges() {
|
||||||
|
final allExtensionIds = _extensions
|
||||||
|
.map((extension) => extension.id)
|
||||||
|
.toList();
|
||||||
|
final selectedExtensionIds = allExtensionIds
|
||||||
|
.where(_selectedExtensionIds.contains)
|
||||||
|
.toList();
|
||||||
|
final fallbackExtensionIds =
|
||||||
|
selectedExtensionIds.length == allExtensionIds.length
|
||||||
|
? null
|
||||||
|
: selectedExtensionIds;
|
||||||
|
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDownloadFallbackExtensionIds(fallbackExtensionIds);
|
||||||
|
setState(() {
|
||||||
|
_hasChanges = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
static const _builtInServices = ['tidal', 'qobuz', 'deezer'];
|
static const _builtInServices = ['tidal', 'qobuz'];
|
||||||
static const _songLinkRegions = [
|
static const _songLinkRegions = [
|
||||||
'AD',
|
'AD',
|
||||||
'AE',
|
'AE',
|
||||||
@@ -2053,7 +2053,7 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
final builtInServiceIds = ['tidal', 'qobuz', 'deezer'];
|
final builtInServiceIds = ['tidal', 'qobuz'];
|
||||||
|
|
||||||
final extensionProviders = extState.extensions
|
final extensionProviders = extState.extensions
|
||||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import 'package:spotiflac_android/models/settings.dart';
|
|||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/explore_provider.dart';
|
import 'package:spotiflac_android/providers/explore_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
|
||||||
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
|
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_DownloadPriorityItem(),
|
_DownloadPriorityItem(),
|
||||||
|
_DownloadFallbackItem(),
|
||||||
_MetadataPriorityItem(),
|
_MetadataPriorityItem(),
|
||||||
_SearchProviderSelector(),
|
_SearchProviderSelector(),
|
||||||
_HomeFeedProviderSelector(),
|
_HomeFeedProviderSelector(),
|
||||||
@@ -588,6 +590,73 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DownloadFallbackItem extends ConsumerWidget {
|
||||||
|
const _DownloadFallbackItem();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final extState = ref.watch(extensionProvider);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final hasDownloadExtensions = extState.extensions.any(
|
||||||
|
(e) => e.enabled && e.hasDownloadProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: hasDownloadExtensions
|
||||||
|
? () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => const DownloadFallbackExtensionsPage(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.alt_route,
|
||||||
|
color: hasDownloadExtensions
|
||||||
|
? colorScheme.onSurfaceVariant
|
||||||
|
: colorScheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.l10n.extensionsFallbackTitle,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: hasDownloadExtensions ? null : colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
hasDownloadExtensions
|
||||||
|
? context.l10n.extensionsFallbackSubtitle
|
||||||
|
: context.l10n.extensionsNoDownloadProvider,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: hasDownloadExtensions
|
||||||
|
? colorScheme.onSurfaceVariant
|
||||||
|
: colorScheme.outline,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SearchProviderSelector extends ConsumerWidget {
|
class _SearchProviderSelector extends ConsumerWidget {
|
||||||
const _SearchProviderSelector();
|
const _SearchProviderSelector();
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
|||||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||||
|
|
||||||
final _log = AppLogger('TrackMetadata');
|
final _log = AppLogger('TrackMetadata');
|
||||||
@@ -41,12 +42,35 @@ class _EmbeddedCoverPreviewCacheEntry {
|
|||||||
class TrackMetadataScreen extends ConsumerStatefulWidget {
|
class TrackMetadataScreen extends ConsumerStatefulWidget {
|
||||||
final DownloadHistoryItem? item;
|
final DownloadHistoryItem? item;
|
||||||
final LocalLibraryItem? localItem;
|
final LocalLibraryItem? localItem;
|
||||||
|
final List<DownloadHistoryItem>? historyNavigationItems;
|
||||||
|
final List<LocalLibraryItem>? localNavigationItems;
|
||||||
|
final int? navigationIndex;
|
||||||
|
|
||||||
const TrackMetadataScreen({super.key, this.item, this.localItem})
|
const TrackMetadataScreen({
|
||||||
: assert(
|
super.key,
|
||||||
item != null || localItem != null,
|
this.item,
|
||||||
'Either item or localItem must be provided',
|
this.localItem,
|
||||||
);
|
this.historyNavigationItems,
|
||||||
|
this.localNavigationItems,
|
||||||
|
this.navigationIndex,
|
||||||
|
}) : assert(
|
||||||
|
item != null || localItem != null,
|
||||||
|
'Either item or localItem must be provided',
|
||||||
|
),
|
||||||
|
assert(
|
||||||
|
historyNavigationItems == null || localNavigationItems == null,
|
||||||
|
'Provide only one navigation list type',
|
||||||
|
),
|
||||||
|
assert(
|
||||||
|
navigationIndex == null ||
|
||||||
|
((historyNavigationItems != null &&
|
||||||
|
navigationIndex >= 0 &&
|
||||||
|
navigationIndex < historyNavigationItems.length) ||
|
||||||
|
(localNavigationItems != null &&
|
||||||
|
navigationIndex >= 0 &&
|
||||||
|
navigationIndex < localNavigationItems.length)),
|
||||||
|
'navigationIndex must be within the provided navigation list',
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<TrackMetadataScreen> createState() =>
|
ConsumerState<TrackMetadataScreen> createState() =>
|
||||||
@@ -74,6 +98,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
bool _isConverting = false;
|
bool _isConverting = false;
|
||||||
bool _hasMetadataChanges = false;
|
bool _hasMetadataChanges = false;
|
||||||
bool _hasLoadedResolvedAudioMetadata = false;
|
bool _hasLoadedResolvedAudioMetadata = false;
|
||||||
|
bool _isTrackSwipeNavigationInFlight = false;
|
||||||
Map<String, dynamic>? _editedMetadata;
|
Map<String, dynamic>? _editedMetadata;
|
||||||
String? _embeddedCoverPreviewPath;
|
String? _embeddedCoverPreviewPath;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
@@ -252,7 +277,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
if (mounted &&
|
if (mounted &&
|
||||||
exists &&
|
exists &&
|
||||||
!_isLocalItem &&
|
!_isCueVirtualTrack &&
|
||||||
!_hasLoadedResolvedAudioMetadata) {
|
!_hasLoadedResolvedAudioMetadata) {
|
||||||
unawaited(_refreshResolvedAudioMetadataFromFile());
|
unawaited(_refreshResolvedAudioMetadataFromFile());
|
||||||
}
|
}
|
||||||
@@ -291,8 +316,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshResolvedAudioMetadataFromFile() async {
|
Future<void> _refreshResolvedAudioMetadataFromFile() async {
|
||||||
if (_isLocalItem ||
|
if ((_isLocalItem && _localLibraryItem == null) ||
|
||||||
_downloadItem == null ||
|
(!_isLocalItem && _downloadItem == null) ||
|
||||||
|
_isCueVirtualTrack ||
|
||||||
_hasLoadedResolvedAudioMetadata) {
|
_hasLoadedResolvedAudioMetadata) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -326,8 +352,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
// Resolve label/copyright from file when the model doesn't carry them
|
// Resolve label/copyright from file when the model doesn't carry them
|
||||||
// (e.g. local library items, or download history items without these fields).
|
// (e.g. local library items, or download history items without these fields).
|
||||||
|
final resolvedTrackNumber = _readPositiveInt(metadata['track_number']);
|
||||||
|
final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']);
|
||||||
|
final resolvedDiscNumber = _readPositiveInt(metadata['disc_number']);
|
||||||
|
final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']);
|
||||||
|
final resolvedComposer = metadata['composer']?.toString();
|
||||||
final resolvedLabel = metadata['label']?.toString();
|
final resolvedLabel = metadata['label']?.toString();
|
||||||
final resolvedCopyright = metadata['copyright']?.toString();
|
final resolvedCopyright = metadata['copyright']?.toString();
|
||||||
|
final needsTrackNumber =
|
||||||
|
resolvedTrackNumber != null &&
|
||||||
|
resolvedTrackNumber > 0 &&
|
||||||
|
trackNumber == null;
|
||||||
|
final needsTotalTracks =
|
||||||
|
resolvedTotalTracks != null &&
|
||||||
|
resolvedTotalTracks > 0 &&
|
||||||
|
totalTracks == null;
|
||||||
|
final needsDiscNumber =
|
||||||
|
resolvedDiscNumber != null &&
|
||||||
|
resolvedDiscNumber > 0 &&
|
||||||
|
discNumber == null;
|
||||||
|
final needsTotalDiscs =
|
||||||
|
resolvedTotalDiscs != null &&
|
||||||
|
resolvedTotalDiscs > 0 &&
|
||||||
|
totalDiscs == null;
|
||||||
|
final needsComposer =
|
||||||
|
resolvedComposer != null &&
|
||||||
|
resolvedComposer.isNotEmpty &&
|
||||||
|
(composer == null || composer!.isEmpty);
|
||||||
final needsLabel =
|
final needsLabel =
|
||||||
resolvedLabel != null &&
|
resolvedLabel != null &&
|
||||||
resolvedLabel.isNotEmpty &&
|
resolvedLabel.isNotEmpty &&
|
||||||
@@ -338,14 +389,25 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
(copyright == null || copyright!.isEmpty);
|
(copyright == null || copyright!.isEmpty);
|
||||||
|
|
||||||
final shouldPersistResolvedAudioMetadata =
|
final shouldPersistResolvedAudioMetadata =
|
||||||
resolvedBitDepth != null ||
|
!_isLocalItem &&
|
||||||
resolvedSampleRate != null ||
|
(resolvedBitDepth != null ||
|
||||||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null);
|
resolvedSampleRate != null ||
|
||||||
|
needsTrackNumber ||
|
||||||
|
needsTotalTracks ||
|
||||||
|
needsDiscNumber ||
|
||||||
|
needsTotalDiscs ||
|
||||||
|
needsComposer ||
|
||||||
|
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
|
||||||
|
|
||||||
if ((resolvedBitDepth != null ||
|
if ((resolvedBitDepth != null ||
|
||||||
resolvedSampleRate != null ||
|
resolvedSampleRate != null ||
|
||||||
needsAlbum ||
|
needsAlbum ||
|
||||||
needsDuration ||
|
needsDuration ||
|
||||||
|
needsTrackNumber ||
|
||||||
|
needsTotalTracks ||
|
||||||
|
needsDiscNumber ||
|
||||||
|
needsTotalDiscs ||
|
||||||
|
needsComposer ||
|
||||||
needsLabel ||
|
needsLabel ||
|
||||||
needsCopyright ||
|
needsCopyright ||
|
||||||
isPlaceholderQualityLabel(_quality)) &&
|
isPlaceholderQualityLabel(_quality)) &&
|
||||||
@@ -359,6 +421,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
|
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
|
||||||
if (needsAlbum) 'album': resolvedAlbum,
|
if (needsAlbum) 'album': resolvedAlbum,
|
||||||
if (needsDuration) 'duration': resolvedDuration,
|
if (needsDuration) 'duration': resolvedDuration,
|
||||||
|
if (needsTrackNumber) 'track_number': resolvedTrackNumber,
|
||||||
|
if (needsTotalTracks) 'total_tracks': resolvedTotalTracks,
|
||||||
|
if (needsDiscNumber) 'disc_number': resolvedDiscNumber,
|
||||||
|
if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs,
|
||||||
|
if (needsComposer) 'composer': resolvedComposer,
|
||||||
if (needsLabel) 'label': resolvedLabel,
|
if (needsLabel) 'label': resolvedLabel,
|
||||||
if (needsCopyright) 'copyright': resolvedCopyright,
|
if (needsCopyright) 'copyright': resolvedCopyright,
|
||||||
};
|
};
|
||||||
@@ -373,6 +440,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
quality: resolvedQuality,
|
quality: resolvedQuality,
|
||||||
bitDepth: resolvedBitDepth,
|
bitDepth: resolvedBitDepth,
|
||||||
sampleRate: resolvedSampleRate,
|
sampleRate: resolvedSampleRate,
|
||||||
|
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
|
||||||
|
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
|
||||||
|
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
|
||||||
|
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
|
||||||
|
composer: needsComposer ? resolvedComposer : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -445,6 +517,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
bool get _isLocalItem => widget.localItem != null;
|
bool get _isLocalItem => widget.localItem != null;
|
||||||
DownloadHistoryItem? get _downloadItem => widget.item;
|
DownloadHistoryItem? get _downloadItem => widget.item;
|
||||||
LocalLibraryItem? get _localLibraryItem => widget.localItem;
|
LocalLibraryItem? get _localLibraryItem => widget.localItem;
|
||||||
|
bool get _hasHistoryNavigation =>
|
||||||
|
widget.historyNavigationItems != null && widget.navigationIndex != null;
|
||||||
|
bool get _hasLocalNavigation =>
|
||||||
|
widget.localNavigationItems != null && widget.navigationIndex != null;
|
||||||
|
bool get _hasTrackSwipeNavigation =>
|
||||||
|
_hasHistoryNavigation || _hasLocalNavigation;
|
||||||
|
int? get _navigationIndex => widget.navigationIndex;
|
||||||
|
int get _navigationLength =>
|
||||||
|
widget.historyNavigationItems?.length ??
|
||||||
|
widget.localNavigationItems?.length ??
|
||||||
|
0;
|
||||||
|
|
||||||
String get _itemId =>
|
String get _itemId =>
|
||||||
_isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
|
_isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
|
||||||
@@ -480,6 +563,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
: _downloadItem!.trackNumber;
|
: _downloadItem!.trackNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? get totalTracks =>
|
||||||
|
_readPositiveInt(_editedMetadata?['total_tracks']) ??
|
||||||
|
(_isLocalItem
|
||||||
|
? _localLibraryItem!.totalTracks
|
||||||
|
: _downloadItem!.totalTracks);
|
||||||
|
|
||||||
int? get discNumber {
|
int? get discNumber {
|
||||||
final edited = _editedMetadata?['disc_number'];
|
final edited = _editedMetadata?['disc_number'];
|
||||||
if (edited != null) {
|
if (edited != null) {
|
||||||
@@ -491,6 +580,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
: _downloadItem!.discNumber;
|
: _downloadItem!.discNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? get totalDiscs =>
|
||||||
|
_readPositiveInt(_editedMetadata?['total_discs']) ??
|
||||||
|
(_isLocalItem
|
||||||
|
? _localLibraryItem!.totalDiscs
|
||||||
|
: _downloadItem!.totalDiscs);
|
||||||
|
|
||||||
String? get releaseDate =>
|
String? get releaseDate =>
|
||||||
_editedMetadata?['date']?.toString() ??
|
_editedMetadata?['date']?.toString() ??
|
||||||
(_isLocalItem
|
(_isLocalItem
|
||||||
@@ -517,10 +612,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
(_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre);
|
(_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre);
|
||||||
String? get label =>
|
String? get label =>
|
||||||
_editedMetadata?['label']?.toString() ??
|
_editedMetadata?['label']?.toString() ??
|
||||||
(_isLocalItem ? null : _downloadItem!.label);
|
(_isLocalItem ? _localLibraryItem!.label : _downloadItem!.label);
|
||||||
String? get copyright =>
|
String? get copyright =>
|
||||||
_editedMetadata?['copyright']?.toString() ??
|
_editedMetadata?['copyright']?.toString() ??
|
||||||
(_isLocalItem ? null : _downloadItem!.copyright);
|
(_isLocalItem ? _localLibraryItem!.copyright : _downloadItem!.copyright);
|
||||||
|
String? get composer =>
|
||||||
|
_editedMetadata?['composer']?.toString() ??
|
||||||
|
(_isLocalItem ? _localLibraryItem!.composer : null);
|
||||||
int? get duration =>
|
int? get duration =>
|
||||||
_readPositiveInt(_editedMetadata?['duration']) ??
|
_readPositiveInt(_editedMetadata?['duration']) ??
|
||||||
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
|
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
|
||||||
@@ -743,118 +841,165 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
Navigator.pop(context, _hasMetadataChanges ? true : null);
|
Navigator.pop(context, _hasMetadataChanges ? true : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleHorizontalDragEnd(DragEndDetails details) {
|
||||||
|
final velocity = details.primaryVelocity;
|
||||||
|
if (velocity == null || velocity.abs() < 350) return;
|
||||||
|
if (velocity < 0) {
|
||||||
|
unawaited(_navigateToAdjacentTrack(1));
|
||||||
|
} else {
|
||||||
|
unawaited(_navigateToAdjacentTrack(-1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _navigateToAdjacentTrack(int offset) async {
|
||||||
|
if (_isTrackSwipeNavigationInFlight || !_hasTrackSwipeNavigation) return;
|
||||||
|
final currentIndex = _navigationIndex;
|
||||||
|
if (currentIndex == null) return;
|
||||||
|
final targetIndex = currentIndex + offset;
|
||||||
|
if (targetIndex < 0 || targetIndex >= _navigationLength) return;
|
||||||
|
|
||||||
|
_isTrackSwipeNavigationInFlight = true;
|
||||||
|
final result = await Navigator.of(context).push<bool>(
|
||||||
|
adjacentHorizontalPageRoute<bool>(
|
||||||
|
page: _buildSiblingTrackScreen(targetIndex),
|
||||||
|
fromRight: offset > 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context, result == true || _hasMetadataChanges ? true : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackMetadataScreen _buildSiblingTrackScreen(int targetIndex) {
|
||||||
|
if (_hasHistoryNavigation) {
|
||||||
|
return TrackMetadataScreen(
|
||||||
|
item: widget.historyNavigationItems![targetIndex],
|
||||||
|
historyNavigationItems: widget.historyNavigationItems,
|
||||||
|
navigationIndex: targetIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return TrackMetadataScreen(
|
||||||
|
localItem: widget.localNavigationItems![targetIndex],
|
||||||
|
localNavigationItems: widget.localNavigationItems,
|
||||||
|
navigationIndex: targetIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
final expandedHeight = _calculateExpandedHeight(context);
|
||||||
|
|
||||||
return Scaffold(
|
return GestureDetector(
|
||||||
body: CustomScrollView(
|
behavior: HitTestBehavior.translucent,
|
||||||
controller: _scrollController,
|
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
SliverAppBar(
|
body: CustomScrollView(
|
||||||
expandedHeight: expandedHeight,
|
controller: _scrollController,
|
||||||
pinned: true,
|
slivers: [
|
||||||
stretch: true,
|
SliverAppBar(
|
||||||
backgroundColor: colorScheme.surface,
|
expandedHeight: expandedHeight,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
title: AnimatedOpacity(
|
stretch: true,
|
||||||
duration: const Duration(milliseconds: 200),
|
backgroundColor: colorScheme.surface,
|
||||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
surfaceTintColor: Colors.transparent,
|
||||||
child: Text(
|
title: AnimatedOpacity(
|
||||||
trackName,
|
duration: const Duration(milliseconds: 200),
|
||||||
style: TextStyle(
|
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||||
color: colorScheme.onSurface,
|
child: Text(
|
||||||
fontWeight: FontWeight.w600,
|
trackName,
|
||||||
fontSize: 16,
|
style: TextStyle(
|
||||||
),
|
color: colorScheme.onSurface,
|
||||||
maxLines: 1,
|
fontWeight: FontWeight.w600,
|
||||||
overflow: TextOverflow.ellipsis,
|
fontSize: 16,
|
||||||
),
|
|
||||||
),
|
|
||||||
flexibleSpace: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final collapseRatio =
|
|
||||||
(constraints.maxHeight - kToolbarHeight) /
|
|
||||||
(expandedHeight - kToolbarHeight);
|
|
||||||
final showContent = collapseRatio > 0.3;
|
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
|
||||||
collapseMode: CollapseMode.pin,
|
|
||||||
background: _buildHeaderBackground(
|
|
||||||
context,
|
|
||||||
colorScheme,
|
|
||||||
expandedHeight,
|
|
||||||
showContent,
|
|
||||||
),
|
),
|
||||||
stretchModes: const [StretchMode.zoomBackground],
|
maxLines: 1,
|
||||||
);
|
overflow: TextOverflow.ellipsis,
|
||||||
},
|
|
||||||
),
|
|
||||||
leading: IconButton(
|
|
||||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
|
||||||
icon: Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.4),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.arrow_back, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
onPressed: _popWithMetadataResult,
|
flexibleSpace: LayoutBuilder(
|
||||||
),
|
builder: (context, constraints) {
|
||||||
actions: [
|
final collapseRatio =
|
||||||
IconButton(
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
(expandedHeight - kToolbarHeight);
|
||||||
|
final showContent = collapseRatio > 0.3;
|
||||||
|
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
collapseMode: CollapseMode.pin,
|
||||||
|
background: _buildHeaderBackground(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
expandedHeight,
|
||||||
|
showContent,
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.4),
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.more_vert, color: Colors.white),
|
child: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
),
|
),
|
||||||
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
|
onPressed: _popWithMetadataResult,
|
||||||
),
|
),
|
||||||
],
|
actions: [
|
||||||
),
|
IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
||||||
SliverToBoxAdapter(
|
icon: Container(
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(8),
|
||||||
padding: const EdgeInsets.all(16),
|
decoration: BoxDecoration(
|
||||||
child: Column(
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
shape: BoxShape.circle,
|
||||||
children: [
|
),
|
||||||
_buildMetadataCard(context, colorScheme, _fileSize),
|
child: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
_buildFileInfoCard(
|
|
||||||
context,
|
|
||||||
colorScheme,
|
|
||||||
_fileExists,
|
|
||||||
_fileSize,
|
|
||||||
),
|
),
|
||||||
|
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildMetadataCard(context, colorScheme, _fileSize),
|
||||||
|
|
||||||
_buildLyricsCard(context, colorScheme),
|
|
||||||
|
|
||||||
if (_fileExists) ...[
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AudioAnalysisCard(filePath: _filePath),
|
|
||||||
|
_buildFileInfoCard(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
_fileExists,
|
||||||
|
_fileSize,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildLyricsCard(context, colorScheme),
|
||||||
|
|
||||||
|
if (_fileExists) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
AudioAnalysisCard(filePath: _filePath),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1255,8 +1400,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_MetadataItem(context.l10n.trackAlbum, albumName),
|
_MetadataItem(context.l10n.trackAlbum, albumName),
|
||||||
if (trackNumber != null && trackNumber! > 0)
|
if (trackNumber != null && trackNumber! > 0)
|
||||||
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
|
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
|
||||||
|
if (totalTracks != null && totalTracks! > 0)
|
||||||
|
_MetadataItem('Track Total', totalTracks.toString()),
|
||||||
if (discNumber != null && discNumber! > 0)
|
if (discNumber != null && discNumber! > 0)
|
||||||
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
|
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
|
||||||
|
if (totalDiscs != null && totalDiscs! > 0)
|
||||||
|
_MetadataItem('Disc Total', totalDiscs.toString()),
|
||||||
if (duration != null)
|
if (duration != null)
|
||||||
_MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)),
|
_MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)),
|
||||||
if (audioQualityStr != null)
|
if (audioQualityStr != null)
|
||||||
@@ -1269,6 +1418,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_MetadataItem(context.l10n.trackLabel, label!),
|
_MetadataItem(context.l10n.trackLabel, label!),
|
||||||
if (copyright != null && copyright!.isNotEmpty)
|
if (copyright != null && copyright!.isNotEmpty)
|
||||||
_MetadataItem(context.l10n.trackCopyright, copyright!),
|
_MetadataItem(context.l10n.trackCopyright, copyright!),
|
||||||
|
if (composer != null && composer!.isNotEmpty)
|
||||||
|
_MetadataItem('Composer', composer!),
|
||||||
if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
|
if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -2525,12 +2676,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
'album_name': albumName,
|
'album_name': albumName,
|
||||||
'album_artist': albumArtist ?? artistName,
|
'album_artist': albumArtist ?? artistName,
|
||||||
'track_number': trackNumber ?? 0,
|
'track_number': trackNumber ?? 0,
|
||||||
|
'total_tracks': totalTracks ?? 0,
|
||||||
'disc_number': discNumber ?? 0,
|
'disc_number': discNumber ?? 0,
|
||||||
|
'total_discs': totalDiscs ?? 0,
|
||||||
'release_date': releaseDate ?? '',
|
'release_date': releaseDate ?? '',
|
||||||
'isrc': isrc ?? '',
|
'isrc': isrc ?? '',
|
||||||
'genre': genre ?? '',
|
'genre': genre ?? '',
|
||||||
'label': label ?? '',
|
'label': label ?? '',
|
||||||
'copyright': copyright ?? '',
|
'copyright': copyright ?? '',
|
||||||
|
'composer': composer ?? '',
|
||||||
'duration_ms': durationMs,
|
'duration_ms': durationMs,
|
||||||
'search_online': true,
|
'search_online': true,
|
||||||
};
|
};
|
||||||
@@ -2548,11 +2702,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
'album_artist': enriched['album_artist'] ?? albumArtist,
|
'album_artist': enriched['album_artist'] ?? albumArtist,
|
||||||
'date': enriched['release_date'] ?? releaseDate,
|
'date': enriched['release_date'] ?? releaseDate,
|
||||||
'track_number': enriched['track_number'] ?? trackNumber,
|
'track_number': enriched['track_number'] ?? trackNumber,
|
||||||
|
'total_tracks': enriched['total_tracks'] ?? totalTracks,
|
||||||
'disc_number': enriched['disc_number'] ?? discNumber,
|
'disc_number': enriched['disc_number'] ?? discNumber,
|
||||||
|
'total_discs': enriched['total_discs'] ?? totalDiscs,
|
||||||
'isrc': enriched['isrc'] ?? isrc,
|
'isrc': enriched['isrc'] ?? isrc,
|
||||||
'genre': enriched['genre'] ?? genre,
|
'genre': enriched['genre'] ?? genre,
|
||||||
'label': enriched['label'] ?? label,
|
'label': enriched['label'] ?? label,
|
||||||
'copyright': enriched['copyright'] ?? copyright,
|
'copyright': enriched['copyright'] ?? copyright,
|
||||||
|
'composer': enriched['composer'] ?? composer,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2721,9 +2878,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
albumArtist: normalizedOrNull(albumArtist),
|
albumArtist: normalizedOrNull(albumArtist),
|
||||||
isrc: normalizedOrNull(isrc),
|
isrc: normalizedOrNull(isrc),
|
||||||
trackNumber: trackNumber,
|
trackNumber: trackNumber,
|
||||||
|
totalTracks: totalTracks,
|
||||||
discNumber: discNumber,
|
discNumber: discNumber,
|
||||||
|
totalDiscs: totalDiscs,
|
||||||
releaseDate: normalizedOrNull(releaseDate),
|
releaseDate: normalizedOrNull(releaseDate),
|
||||||
genre: normalizedOrNull(genre),
|
genre: normalizedOrNull(genre),
|
||||||
|
composer: normalizedOrNull(composer),
|
||||||
label: normalizedOrNull(label),
|
label: normalizedOrNull(label),
|
||||||
copyright: normalizedOrNull(copyright),
|
copyright: normalizedOrNull(copyright),
|
||||||
);
|
);
|
||||||
@@ -2989,19 +3149,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, String> _buildFallbackMetadata() {
|
Map<String, String> _buildFallbackMetadata() {
|
||||||
|
String formatIndexTag(int number, int? total) {
|
||||||
|
if (total != null && total > 0) {
|
||||||
|
return '$number/$total';
|
||||||
|
}
|
||||||
|
return number.toString();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'TITLE': trackName,
|
'TITLE': trackName,
|
||||||
'ARTIST': artistName,
|
'ARTIST': artistName,
|
||||||
'ALBUM': albumName,
|
'ALBUM': albumName,
|
||||||
if (albumArtist != null && albumArtist!.isNotEmpty)
|
if (albumArtist != null && albumArtist!.isNotEmpty)
|
||||||
'ALBUMARTIST': albumArtist!,
|
'ALBUMARTIST': albumArtist!,
|
||||||
if (trackNumber != null) 'TRACKNUMBER': trackNumber.toString(),
|
if (trackNumber != null)
|
||||||
if (discNumber != null) 'DISCNUMBER': discNumber.toString(),
|
'TRACKNUMBER': formatIndexTag(trackNumber!, totalTracks),
|
||||||
|
if (discNumber != null)
|
||||||
|
'DISCNUMBER': formatIndexTag(discNumber!, totalDiscs),
|
||||||
if (releaseDate != null && releaseDate!.isNotEmpty) 'DATE': releaseDate!,
|
if (releaseDate != null && releaseDate!.isNotEmpty) 'DATE': releaseDate!,
|
||||||
if (isrc != null && isrc!.isNotEmpty) 'ISRC': isrc!,
|
if (isrc != null && isrc!.isNotEmpty) 'ISRC': isrc!,
|
||||||
if (genre != null && genre!.isNotEmpty) 'GENRE': genre!,
|
if (genre != null && genre!.isNotEmpty) 'GENRE': genre!,
|
||||||
if (label != null && label!.isNotEmpty) 'LABEL': label!,
|
if (label != null && label!.isNotEmpty) 'LABEL': label!,
|
||||||
if (copyright != null && copyright!.isNotEmpty) 'COPYRIGHT': copyright!,
|
if (copyright != null && copyright!.isNotEmpty) 'COPYRIGHT': copyright!,
|
||||||
|
if (composer != null && composer!.isNotEmpty) 'COMPOSER': composer!,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3029,12 +3199,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
put('UNSYNCEDLYRICS', source['lyrics']);
|
put('UNSYNCEDLYRICS', source['lyrics']);
|
||||||
|
|
||||||
final trackNumber = source['track_number'];
|
final trackNumber = source['track_number'];
|
||||||
|
final totalTracks = source['total_tracks'];
|
||||||
if (trackNumber != null && trackNumber.toString() != '0') {
|
if (trackNumber != null && trackNumber.toString() != '0') {
|
||||||
put('TRACKNUMBER', trackNumber);
|
final trackTag =
|
||||||
|
totalTracks != null &&
|
||||||
|
totalTracks.toString().isNotEmpty &&
|
||||||
|
totalTracks.toString() != '0'
|
||||||
|
? '${trackNumber.toString()}/${totalTracks.toString()}'
|
||||||
|
: trackNumber;
|
||||||
|
put('TRACKNUMBER', trackTag);
|
||||||
}
|
}
|
||||||
final discNumber = source['disc_number'];
|
final discNumber = source['disc_number'];
|
||||||
|
final totalDiscs = source['total_discs'];
|
||||||
if (discNumber != null && discNumber.toString() != '0') {
|
if (discNumber != null && discNumber.toString() != '0') {
|
||||||
put('DISCNUMBER', discNumber);
|
final discTag =
|
||||||
|
totalDiscs != null &&
|
||||||
|
totalDiscs.toString().isNotEmpty &&
|
||||||
|
totalDiscs.toString() != '0'
|
||||||
|
? '${discNumber.toString()}/${totalDiscs.toString()}'
|
||||||
|
: discNumber;
|
||||||
|
put('DISCNUMBER', discTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapped;
|
return mapped;
|
||||||
@@ -3859,8 +4043,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate);
|
final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate);
|
||||||
|
|
||||||
if (isSaf) {
|
if (isSaf) {
|
||||||
final treeUri = _downloadItem?.downloadTreeUri;
|
String? treeUri;
|
||||||
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
String relativeDir = '';
|
||||||
|
String oldFileName = '';
|
||||||
|
if (_isLocalItem) {
|
||||||
|
final uri = Uri.parse(cleanFilePath);
|
||||||
|
final pathSegments = uri.pathSegments;
|
||||||
|
final treeIdx = pathSegments.indexOf('tree');
|
||||||
|
final docIdx = pathSegments.indexOf('document');
|
||||||
|
if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) {
|
||||||
|
final treeId = pathSegments[treeIdx + 1];
|
||||||
|
treeUri =
|
||||||
|
'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}';
|
||||||
|
}
|
||||||
|
if (docIdx >= 0 && docIdx + 1 < pathSegments.length) {
|
||||||
|
final docPath = Uri.decodeFull(pathSegments[docIdx + 1]);
|
||||||
|
final slashIdx = docPath.lastIndexOf('/');
|
||||||
|
if (slashIdx >= 0) {
|
||||||
|
oldFileName = docPath.substring(slashIdx + 1);
|
||||||
|
final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length
|
||||||
|
? Uri.decodeFull(pathSegments[treeIdx + 1])
|
||||||
|
: '';
|
||||||
|
if (treeId.isNotEmpty && docPath.startsWith(treeId)) {
|
||||||
|
final afterTree = docPath.substring(treeId.length);
|
||||||
|
final trimmed = afterTree.startsWith('/')
|
||||||
|
? afterTree.substring(1)
|
||||||
|
: afterTree;
|
||||||
|
final lastSlash = trimmed.lastIndexOf('/');
|
||||||
|
relativeDir = lastSlash >= 0
|
||||||
|
? trimmed.substring(0, lastSlash)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oldFileName = docPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
treeUri = _downloadItem?.downloadTreeUri;
|
||||||
|
relativeDir = _downloadItem?.safRelativeDir ?? '';
|
||||||
|
oldFileName =
|
||||||
|
(_downloadItem?.safFileName != null &&
|
||||||
|
_downloadItem!.safFileName!.isNotEmpty)
|
||||||
|
? _downloadItem!.safFileName!
|
||||||
|
: _extractFileNameFromPathOrUri(cleanFilePath);
|
||||||
|
}
|
||||||
if (treeUri == null || treeUri.isEmpty) {
|
if (treeUri == null || treeUri.isEmpty) {
|
||||||
try {
|
try {
|
||||||
await File(newPath).delete();
|
await File(newPath).delete();
|
||||||
@@ -3879,11 +4105,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final oldFileName =
|
|
||||||
(_downloadItem?.safFileName != null &&
|
|
||||||
_downloadItem!.safFileName!.isNotEmpty)
|
|
||||||
? _downloadItem!.safFileName!
|
|
||||||
: _extractFileNameFromPathOrUri(cleanFilePath);
|
|
||||||
final dotIdx = oldFileName.lastIndexOf('.');
|
final dotIdx = oldFileName.lastIndexOf('.');
|
||||||
final baseName = dotIdx > 0
|
final baseName = dotIdx > 0
|
||||||
? oldFileName.substring(0, dotIdx)
|
? oldFileName.substring(0, dotIdx)
|
||||||
@@ -3952,6 +4173,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
clearAudioSpecs: true,
|
clearAudioSpecs: true,
|
||||||
);
|
);
|
||||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||||
|
} else {
|
||||||
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||||
|
item: _localLibraryItem!,
|
||||||
|
newFilePath: safUri,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
);
|
||||||
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3971,6 +4200,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
clearAudioSpecs: true,
|
clearAudioSpecs: true,
|
||||||
);
|
);
|
||||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||||
|
} else {
|
||||||
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||||
|
item: _localLibraryItem!,
|
||||||
|
newFilePath: newPath,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
);
|
||||||
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4021,13 +4258,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
'date': val('date', releaseDate),
|
'date': val('date', releaseDate),
|
||||||
'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '')
|
'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '')
|
||||||
.toString(),
|
.toString(),
|
||||||
|
'total_tracks': (fileMetadata?['total_tracks'] ?? totalTracks ?? '')
|
||||||
|
.toString(),
|
||||||
'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '')
|
'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '')
|
||||||
.toString(),
|
.toString(),
|
||||||
|
'total_discs': (fileMetadata?['total_discs'] ?? totalDiscs ?? '')
|
||||||
|
.toString(),
|
||||||
'genre': val('genre', genre),
|
'genre': val('genre', genre),
|
||||||
'isrc': val('isrc', isrc),
|
'isrc': val('isrc', isrc),
|
||||||
'label': val('label', label),
|
'label': val('label', label),
|
||||||
'copyright': val('copyright', copyright),
|
'copyright': val('copyright', copyright),
|
||||||
'composer': fileMetadata?['composer']?.toString() ?? '',
|
'composer': val('composer', composer),
|
||||||
'comment': fileMetadata?['comment']?.toString() ?? '',
|
'comment': fileMetadata?['comment']?.toString() ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4314,11 +4555,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
'album_artist': 'album_artist',
|
'album_artist': 'album_artist',
|
||||||
'date': 'date',
|
'date': 'date',
|
||||||
'track_number': 'track_number',
|
'track_number': 'track_number',
|
||||||
|
'total_tracks': 'total_tracks',
|
||||||
'disc_number': 'disc_number',
|
'disc_number': 'disc_number',
|
||||||
|
'total_discs': 'total_discs',
|
||||||
'genre': 'genre',
|
'genre': 'genre',
|
||||||
'isrc': 'isrc',
|
'isrc': 'isrc',
|
||||||
'label': 'label',
|
'label': 'label',
|
||||||
'copyright': 'copyright',
|
'copyright': 'copyright',
|
||||||
|
'composer': 'composer',
|
||||||
'cover': 'cover',
|
'cover': 'cover',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4328,7 +4572,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
late final TextEditingController _albumArtistCtrl;
|
late final TextEditingController _albumArtistCtrl;
|
||||||
late final TextEditingController _dateCtrl;
|
late final TextEditingController _dateCtrl;
|
||||||
late final TextEditingController _trackNumCtrl;
|
late final TextEditingController _trackNumCtrl;
|
||||||
|
late final TextEditingController _trackTotalCtrl;
|
||||||
late final TextEditingController _discNumCtrl;
|
late final TextEditingController _discNumCtrl;
|
||||||
|
late final TextEditingController _discTotalCtrl;
|
||||||
late final TextEditingController _genreCtrl;
|
late final TextEditingController _genreCtrl;
|
||||||
late final TextEditingController _isrcCtrl;
|
late final TextEditingController _isrcCtrl;
|
||||||
late final TextEditingController _labelCtrl;
|
late final TextEditingController _labelCtrl;
|
||||||
@@ -4516,8 +4762,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
return l10n.editMetadataFieldDate;
|
return l10n.editMetadataFieldDate;
|
||||||
case 'track_number':
|
case 'track_number':
|
||||||
return l10n.editMetadataFieldTrackNum;
|
return l10n.editMetadataFieldTrackNum;
|
||||||
|
case 'total_tracks':
|
||||||
|
return 'Track Total';
|
||||||
case 'disc_number':
|
case 'disc_number':
|
||||||
return l10n.editMetadataFieldDiscNum;
|
return l10n.editMetadataFieldDiscNum;
|
||||||
|
case 'total_discs':
|
||||||
|
return 'Disc Total';
|
||||||
case 'genre':
|
case 'genre':
|
||||||
return l10n.editMetadataFieldGenre;
|
return l10n.editMetadataFieldGenre;
|
||||||
case 'isrc':
|
case 'isrc':
|
||||||
@@ -4526,6 +4776,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
return l10n.editMetadataFieldLabel;
|
return l10n.editMetadataFieldLabel;
|
||||||
case 'copyright':
|
case 'copyright':
|
||||||
return l10n.editMetadataFieldCopyright;
|
return l10n.editMetadataFieldCopyright;
|
||||||
|
case 'composer':
|
||||||
|
return 'Composer';
|
||||||
case 'cover':
|
case 'cover':
|
||||||
return l10n.editMetadataFieldCover;
|
return l10n.editMetadataFieldCover;
|
||||||
default:
|
default:
|
||||||
@@ -4547,8 +4799,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
return _dateCtrl;
|
return _dateCtrl;
|
||||||
case 'track_number':
|
case 'track_number':
|
||||||
return _trackNumCtrl;
|
return _trackNumCtrl;
|
||||||
|
case 'total_tracks':
|
||||||
|
return _trackTotalCtrl;
|
||||||
case 'disc_number':
|
case 'disc_number':
|
||||||
return _discNumCtrl;
|
return _discNumCtrl;
|
||||||
|
case 'total_discs':
|
||||||
|
return _discTotalCtrl;
|
||||||
case 'genre':
|
case 'genre':
|
||||||
return _genreCtrl;
|
return _genreCtrl;
|
||||||
case 'isrc':
|
case 'isrc':
|
||||||
@@ -4557,6 +4813,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
return _labelCtrl;
|
return _labelCtrl;
|
||||||
case 'copyright':
|
case 'copyright':
|
||||||
return _copyrightCtrl;
|
return _copyrightCtrl;
|
||||||
|
case 'composer':
|
||||||
|
return _composerCtrl;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -4722,11 +4980,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
put('album_artist', track['album_artist']);
|
put('album_artist', track['album_artist']);
|
||||||
put('date', track['release_date']);
|
put('date', track['release_date']);
|
||||||
put('track_number', track['track_number']);
|
put('track_number', track['track_number']);
|
||||||
|
put('total_tracks', track['total_tracks']);
|
||||||
put('disc_number', track['disc_number']);
|
put('disc_number', track['disc_number']);
|
||||||
|
put('total_discs', track['total_discs']);
|
||||||
put('isrc', track['isrc']);
|
put('isrc', track['isrc']);
|
||||||
put('genre', track['genre']);
|
put('genre', track['genre']);
|
||||||
put('label', track['label']);
|
put('label', track['label']);
|
||||||
put('copyright', track['copyright']);
|
put('copyright', track['copyright']);
|
||||||
|
put('composer', track['composer']);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers(
|
Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers(
|
||||||
@@ -4927,8 +5188,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
|
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
|
||||||
'date': (selectedBest['release_date'] ?? '').toString(),
|
'date': (selectedBest['release_date'] ?? '').toString(),
|
||||||
'track_number': (selectedBest['track_number'] ?? '').toString(),
|
'track_number': (selectedBest['track_number'] ?? '').toString(),
|
||||||
|
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
|
||||||
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
|
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
|
||||||
|
'total_discs': (selectedBest['total_discs'] ?? '').toString(),
|
||||||
'isrc': (selectedBest['isrc'] ?? '').toString(),
|
'isrc': (selectedBest['isrc'] ?? '').toString(),
|
||||||
|
'composer': (selectedBest['composer'] ?? '').toString(),
|
||||||
};
|
};
|
||||||
_mergeOnlineTrackData(enriched, selectedBest);
|
_mergeOnlineTrackData(enriched, selectedBest);
|
||||||
|
|
||||||
@@ -4937,7 +5201,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
final needsExtended =
|
final needsExtended =
|
||||||
_autoFillFields.contains('genre') ||
|
_autoFillFields.contains('genre') ||
|
||||||
_autoFillFields.contains('label') ||
|
_autoFillFields.contains('label') ||
|
||||||
_autoFillFields.contains('copyright');
|
_autoFillFields.contains('copyright') ||
|
||||||
|
_autoFillFields.contains('composer');
|
||||||
|
|
||||||
final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest);
|
final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest);
|
||||||
|
|
||||||
@@ -5099,7 +5364,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
_albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? '');
|
_albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? '');
|
||||||
_dateCtrl = TextEditingController(text: v['date'] ?? '');
|
_dateCtrl = TextEditingController(text: v['date'] ?? '');
|
||||||
_trackNumCtrl = TextEditingController(text: v['track_number'] ?? '');
|
_trackNumCtrl = TextEditingController(text: v['track_number'] ?? '');
|
||||||
|
_trackTotalCtrl = TextEditingController(text: v['total_tracks'] ?? '');
|
||||||
_discNumCtrl = TextEditingController(text: v['disc_number'] ?? '');
|
_discNumCtrl = TextEditingController(text: v['disc_number'] ?? '');
|
||||||
|
_discTotalCtrl = TextEditingController(text: v['total_discs'] ?? '');
|
||||||
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
|
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
|
||||||
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
|
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
|
||||||
_labelCtrl = TextEditingController(text: v['label'] ?? '');
|
_labelCtrl = TextEditingController(text: v['label'] ?? '');
|
||||||
@@ -5119,7 +5386,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
_albumArtistCtrl.dispose();
|
_albumArtistCtrl.dispose();
|
||||||
_dateCtrl.dispose();
|
_dateCtrl.dispose();
|
||||||
_trackNumCtrl.dispose();
|
_trackNumCtrl.dispose();
|
||||||
|
_trackTotalCtrl.dispose();
|
||||||
_discNumCtrl.dispose();
|
_discNumCtrl.dispose();
|
||||||
|
_discTotalCtrl.dispose();
|
||||||
_genreCtrl.dispose();
|
_genreCtrl.dispose();
|
||||||
_isrcCtrl.dispose();
|
_isrcCtrl.dispose();
|
||||||
_labelCtrl.dispose();
|
_labelCtrl.dispose();
|
||||||
@@ -5139,7 +5408,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
'album_artist': _albumArtistCtrl.text,
|
'album_artist': _albumArtistCtrl.text,
|
||||||
'date': _dateCtrl.text,
|
'date': _dateCtrl.text,
|
||||||
'track_number': _trackNumCtrl.text,
|
'track_number': _trackNumCtrl.text,
|
||||||
|
'track_total': _trackTotalCtrl.text,
|
||||||
'disc_number': _discNumCtrl.text,
|
'disc_number': _discNumCtrl.text,
|
||||||
|
'disc_total': _discTotalCtrl.text,
|
||||||
'genre': _genreCtrl.text,
|
'genre': _genreCtrl.text,
|
||||||
'isrc': _isrcCtrl.text,
|
'isrc': _isrcCtrl.text,
|
||||||
'label': _labelCtrl.text,
|
'label': _labelCtrl.text,
|
||||||
@@ -5191,12 +5462,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
'TRACKNUMBER':
|
'TRACKNUMBER':
|
||||||
(metadata['track_number']?.isNotEmpty == true &&
|
(metadata['track_number']?.isNotEmpty == true &&
|
||||||
metadata['track_number'] != '0')
|
metadata['track_number'] != '0')
|
||||||
? metadata['track_number']!
|
? (metadata['track_total']?.isNotEmpty == true &&
|
||||||
|
metadata['track_total'] != '0'
|
||||||
|
? '${metadata['track_number']}/${metadata['track_total']}'
|
||||||
|
: metadata['track_number']!)
|
||||||
: '',
|
: '',
|
||||||
'DISCNUMBER':
|
'DISCNUMBER':
|
||||||
(metadata['disc_number']?.isNotEmpty == true &&
|
(metadata['disc_number']?.isNotEmpty == true &&
|
||||||
metadata['disc_number'] != '0')
|
metadata['disc_number'] != '0')
|
||||||
? metadata['disc_number']!
|
? (metadata['disc_total']?.isNotEmpty == true &&
|
||||||
|
metadata['disc_total'] != '0'
|
||||||
|
? '${metadata['disc_number']}/${metadata['disc_total']}'
|
||||||
|
: metadata['disc_number']!)
|
||||||
: '',
|
: '',
|
||||||
'GENRE': metadata['genre'] ?? '',
|
'GENRE': metadata['genre'] ?? '',
|
||||||
'ISRC': metadata['isrc'] ?? '',
|
'ISRC': metadata['isrc'] ?? '',
|
||||||
@@ -5409,6 +5686,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _field(
|
||||||
|
'Track Total',
|
||||||
|
_trackTotalCtrl,
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _field(
|
child: _field(
|
||||||
'Disc #',
|
'Disc #',
|
||||||
@@ -5416,6 +5705,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
keyboard: TextInputType.number,
|
keyboard: TextInputType.number,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _field(
|
||||||
|
'Disc Total',
|
||||||
|
_discTotalCtrl,
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_field('Genre', _genreCtrl),
|
_field('Genre', _genreCtrl),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class DownloadRequestPayload {
|
|||||||
final int trackNumber;
|
final int trackNumber;
|
||||||
final int discNumber;
|
final int discNumber;
|
||||||
final int totalTracks;
|
final int totalTracks;
|
||||||
|
final int totalDiscs;
|
||||||
final String releaseDate;
|
final String releaseDate;
|
||||||
final String itemId;
|
final String itemId;
|
||||||
final int durationMs;
|
final int durationMs;
|
||||||
@@ -24,6 +25,7 @@ class DownloadRequestPayload {
|
|||||||
final String genre;
|
final String genre;
|
||||||
final String label;
|
final String label;
|
||||||
final String copyright;
|
final String copyright;
|
||||||
|
final String composer;
|
||||||
final String tidalId;
|
final String tidalId;
|
||||||
final String qobuzId;
|
final String qobuzId;
|
||||||
final String deezerId;
|
final String deezerId;
|
||||||
@@ -56,6 +58,7 @@ class DownloadRequestPayload {
|
|||||||
this.trackNumber = 0,
|
this.trackNumber = 0,
|
||||||
this.discNumber = 0,
|
this.discNumber = 0,
|
||||||
this.totalTracks = 1,
|
this.totalTracks = 1,
|
||||||
|
this.totalDiscs = 0,
|
||||||
this.releaseDate = '',
|
this.releaseDate = '',
|
||||||
this.itemId = '',
|
this.itemId = '',
|
||||||
this.durationMs = 0,
|
this.durationMs = 0,
|
||||||
@@ -63,6 +66,7 @@ class DownloadRequestPayload {
|
|||||||
this.genre = '',
|
this.genre = '',
|
||||||
this.label = '',
|
this.label = '',
|
||||||
this.copyright = '',
|
this.copyright = '',
|
||||||
|
this.composer = '',
|
||||||
this.tidalId = '',
|
this.tidalId = '',
|
||||||
this.qobuzId = '',
|
this.qobuzId = '',
|
||||||
this.deezerId = '',
|
this.deezerId = '',
|
||||||
@@ -97,6 +101,7 @@ class DownloadRequestPayload {
|
|||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
'disc_number': discNumber,
|
'disc_number': discNumber,
|
||||||
'total_tracks': totalTracks,
|
'total_tracks': totalTracks,
|
||||||
|
'total_discs': totalDiscs,
|
||||||
'release_date': releaseDate,
|
'release_date': releaseDate,
|
||||||
'item_id': itemId,
|
'item_id': itemId,
|
||||||
'duration_ms': durationMs,
|
'duration_ms': durationMs,
|
||||||
@@ -104,6 +109,7 @@ class DownloadRequestPayload {
|
|||||||
'genre': genre,
|
'genre': genre,
|
||||||
'label': label,
|
'label': label,
|
||||||
'copyright': copyright,
|
'copyright': copyright,
|
||||||
|
'composer': composer,
|
||||||
'tidal_id': tidalId,
|
'tidal_id': tidalId,
|
||||||
'qobuz_id': qobuzId,
|
'qobuz_id': qobuzId,
|
||||||
'deezer_id': deezerId,
|
'deezer_id': deezerId,
|
||||||
@@ -142,6 +148,7 @@ class DownloadRequestPayload {
|
|||||||
trackNumber: trackNumber,
|
trackNumber: trackNumber,
|
||||||
discNumber: discNumber,
|
discNumber: discNumber,
|
||||||
totalTracks: totalTracks,
|
totalTracks: totalTracks,
|
||||||
|
totalDiscs: totalDiscs,
|
||||||
releaseDate: releaseDate,
|
releaseDate: releaseDate,
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
@@ -149,6 +156,7 @@ class DownloadRequestPayload {
|
|||||||
genre: genre,
|
genre: genre,
|
||||||
label: label,
|
label: label,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
|
composer: composer,
|
||||||
tidalId: tidalId,
|
tidalId: tidalId,
|
||||||
qobuzId: qobuzId,
|
qobuzId: qobuzId,
|
||||||
deezerId: deezerId,
|
deezerId: deezerId,
|
||||||
|
|||||||
@@ -13,6 +13,95 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
final _log = AppLogger('FFmpeg');
|
||||||
|
|
||||||
|
class DownloadDecryptionDescriptor {
|
||||||
|
final String strategy;
|
||||||
|
final String key;
|
||||||
|
final String? iv;
|
||||||
|
final String? inputFormat;
|
||||||
|
final String? outputExtension;
|
||||||
|
final Map<String, dynamic> options;
|
||||||
|
|
||||||
|
const DownloadDecryptionDescriptor({
|
||||||
|
required this.strategy,
|
||||||
|
required this.key,
|
||||||
|
this.iv,
|
||||||
|
this.inputFormat,
|
||||||
|
this.outputExtension,
|
||||||
|
this.options = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DownloadDecryptionDescriptor.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawOptions = json['options'];
|
||||||
|
return DownloadDecryptionDescriptor(
|
||||||
|
strategy: (json['strategy'] as String? ?? '').trim(),
|
||||||
|
key: (json['key'] as String? ?? '').trim(),
|
||||||
|
iv: (json['iv'] as String?)?.trim(),
|
||||||
|
inputFormat: (json['input_format'] as String?)?.trim(),
|
||||||
|
outputExtension: (json['output_extension'] as String?)?.trim(),
|
||||||
|
options: rawOptions is Map
|
||||||
|
? Map<String, dynamic>.from(rawOptions)
|
||||||
|
: const {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{'strategy': strategy, 'key': key};
|
||||||
|
if (iv != null && iv!.isNotEmpty) {
|
||||||
|
json['iv'] = iv;
|
||||||
|
}
|
||||||
|
if (inputFormat != null && inputFormat!.isNotEmpty) {
|
||||||
|
json['input_format'] = inputFormat;
|
||||||
|
}
|
||||||
|
if (outputExtension != null && outputExtension!.isNotEmpty) {
|
||||||
|
json['output_extension'] = outputExtension;
|
||||||
|
}
|
||||||
|
if (options.isNotEmpty) {
|
||||||
|
json['options'] = options;
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
static DownloadDecryptionDescriptor? fromDownloadResult(
|
||||||
|
Map<String, dynamic> result,
|
||||||
|
) {
|
||||||
|
final rawDecryption = result['decryption'];
|
||||||
|
if (rawDecryption is Map) {
|
||||||
|
final descriptor = DownloadDecryptionDescriptor.fromJson(
|
||||||
|
Map<String, dynamic>.from(rawDecryption),
|
||||||
|
);
|
||||||
|
if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' &&
|
||||||
|
descriptor.key.isNotEmpty) {
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final legacyKey = (result['decryption_key'] as String?)?.trim() ?? '';
|
||||||
|
if (legacyKey.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadDecryptionDescriptor(
|
||||||
|
strategy: 'ffmpeg.mov_key',
|
||||||
|
key: legacyKey,
|
||||||
|
inputFormat: 'mov',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get normalizedStrategy {
|
||||||
|
switch (strategy.trim().toLowerCase()) {
|
||||||
|
case '':
|
||||||
|
case 'ffmpeg.mov_key':
|
||||||
|
case 'ffmpeg_mov_key':
|
||||||
|
case 'mov_decryption_key':
|
||||||
|
case 'mp4_decryption_key':
|
||||||
|
case 'ffmpeg.mp4_decryption_key':
|
||||||
|
return 'ffmpeg.mov_key';
|
||||||
|
default:
|
||||||
|
return strategy.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class FFmpegService {
|
class FFmpegService {
|
||||||
static const int _commandLogPreviewLength = 300;
|
static const int _commandLogPreviewLength = 300;
|
||||||
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
|
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
|
||||||
@@ -22,6 +111,7 @@ class FFmpegService {
|
|||||||
static const Duration _liveTunnelStabilizationDelay = Duration(
|
static const Duration _liveTunnelStabilizationDelay = Duration(
|
||||||
milliseconds: 900,
|
milliseconds: 900,
|
||||||
);
|
);
|
||||||
|
static const String _genericMovKeyDecryptionStrategy = 'ffmpeg.mov_key';
|
||||||
static int _tempEmbedCounter = 0;
|
static int _tempEmbedCounter = 0;
|
||||||
static FFmpegSession? _activeLiveDecryptSession;
|
static FFmpegSession? _activeLiveDecryptSession;
|
||||||
static String? _activeLiveDecryptUrl;
|
static String? _activeLiveDecryptUrl;
|
||||||
@@ -216,12 +306,56 @@ class FFmpegService {
|
|||||||
required String decryptionKey,
|
required String decryptionKey,
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final trimmedKey = decryptionKey.trim();
|
return decryptWithDescriptor(
|
||||||
if (trimmedKey.isEmpty) return inputPath;
|
inputPath: inputPath,
|
||||||
|
descriptor: DownloadDecryptionDescriptor(
|
||||||
|
strategy: _genericMovKeyDecryptionStrategy,
|
||||||
|
key: decryptionKey,
|
||||||
|
inputFormat: 'mov',
|
||||||
|
),
|
||||||
|
deleteOriginal: deleteOriginal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Encrypted streams are commonly MP4 container with FLAC audio.
|
static Future<String?> decryptWithDescriptor({
|
||||||
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
|
required String inputPath,
|
||||||
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
|
required DownloadDecryptionDescriptor descriptor,
|
||||||
|
bool deleteOriginal = true,
|
||||||
|
}) async {
|
||||||
|
final key = descriptor.key.trim();
|
||||||
|
|
||||||
|
switch (descriptor.normalizedStrategy) {
|
||||||
|
case _genericMovKeyDecryptionStrategy:
|
||||||
|
if (key.isEmpty) {
|
||||||
|
return inputPath;
|
||||||
|
}
|
||||||
|
return _decryptMovKeyFile(
|
||||||
|
inputPath: inputPath,
|
||||||
|
decryptionKey: key,
|
||||||
|
inputFormat: descriptor.inputFormat,
|
||||||
|
outputExtension: descriptor.outputExtension,
|
||||||
|
deleteOriginal: deleteOriginal,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
_log.e(
|
||||||
|
'Unsupported download decryption strategy: ${descriptor.strategy}',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _resolvePreferredDecryptionExtension(
|
||||||
|
String inputPath,
|
||||||
|
String? requestedExtension,
|
||||||
|
) {
|
||||||
|
final trimmedRequested = (requestedExtension ?? '').trim();
|
||||||
|
if (trimmedRequested.isNotEmpty) {
|
||||||
|
return trimmedRequested.startsWith('.')
|
||||||
|
? trimmedRequested
|
||||||
|
: '.$trimmedRequested';
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputPath.toLowerCase().endsWith('.m4a')
|
||||||
? '.flac'
|
? '.flac'
|
||||||
: inputPath.toLowerCase().endsWith('.flac')
|
: inputPath.toLowerCase().endsWith('.flac')
|
||||||
? '.flac'
|
? '.flac'
|
||||||
@@ -230,7 +364,23 @@ class FFmpegService {
|
|||||||
: inputPath.toLowerCase().endsWith('.opus')
|
: inputPath.toLowerCase().endsWith('.opus')
|
||||||
? '.opus'
|
? '.opus'
|
||||||
: '.flac';
|
: '.flac';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _decryptMovKeyFile({
|
||||||
|
required String inputPath,
|
||||||
|
required String decryptionKey,
|
||||||
|
String? inputFormat,
|
||||||
|
String? outputExtension,
|
||||||
|
bool deleteOriginal = true,
|
||||||
|
}) async {
|
||||||
|
final preferredExt = _resolvePreferredDecryptionExtension(
|
||||||
|
inputPath,
|
||||||
|
outputExtension,
|
||||||
|
);
|
||||||
var tempOutput = _buildOutputPath(inputPath, preferredExt);
|
var tempOutput = _buildOutputPath(inputPath, preferredExt);
|
||||||
|
final demuxerFormat = (inputFormat ?? '').trim().isNotEmpty
|
||||||
|
? inputFormat!.trim()
|
||||||
|
: 'mov';
|
||||||
|
|
||||||
String buildDecryptCommand(
|
String buildDecryptCommand(
|
||||||
String outputPath, {
|
String outputPath, {
|
||||||
@@ -241,10 +391,10 @@ class FFmpegService {
|
|||||||
// Force MOV demuxer: -decryption_key is only supported by the MOV/MP4
|
// Force MOV demuxer: -decryption_key is only supported by the MOV/MP4
|
||||||
// demuxer. The input may carry a .flac extension (SAF mode) while actually
|
// demuxer. The input may carry a .flac extension (SAF mode) while actually
|
||||||
// containing an encrypted M4A stream, so we must override auto-detection.
|
// containing an encrypted M4A stream, so we must override auto-detection.
|
||||||
return '-v error -decryption_key "$key" -f mov -i "$inputPath" $audioMap-c copy "$outputPath" -y';
|
return '-v error -decryption_key "$key" -f $demuxerFormat -i "$inputPath" $audioMap-c copy "$outputPath" -y';
|
||||||
}
|
}
|
||||||
|
|
||||||
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
|
final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey);
|
||||||
if (keyCandidates.isEmpty) {
|
if (keyCandidates.isEmpty) {
|
||||||
_log.e('No usable decryption key candidates');
|
_log.e('No usable decryption key candidates');
|
||||||
return null;
|
return null;
|
||||||
@@ -1311,28 +1461,38 @@ class FFmpegService {
|
|||||||
cmdBuffer.write('-v error -hide_banner ');
|
cmdBuffer.write('-v error -hide_banner ');
|
||||||
cmdBuffer.write('-i "$m4aPath" ');
|
cmdBuffer.write('-i "$m4aPath" ');
|
||||||
|
|
||||||
final hasCover = coverPath != null && await File(coverPath).exists();
|
final normalizedCoverPath = coverPath?.trim();
|
||||||
|
final hasCover =
|
||||||
|
normalizedCoverPath != null &&
|
||||||
|
normalizedCoverPath.isNotEmpty &&
|
||||||
|
await File(normalizedCoverPath).exists();
|
||||||
if (hasCover) {
|
if (hasCover) {
|
||||||
cmdBuffer.write('-i "$coverPath" ');
|
cmdBuffer.write('-i "$normalizedCoverPath" ');
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdBuffer.write('-map 0:a ');
|
final preserveExistingStreams = preserveMetadata && !hasCover;
|
||||||
|
if (preserveExistingStreams) {
|
||||||
|
// When no replacement cover is provided, preserve all input streams so
|
||||||
|
// the existing attached artwork is not dropped during the metadata rewrite.
|
||||||
|
cmdBuffer.write('-map 0 -c copy ');
|
||||||
|
} else {
|
||||||
|
cmdBuffer.write('-map 0:a -c:a copy ');
|
||||||
|
}
|
||||||
cmdBuffer.write(
|
cmdBuffer.write(
|
||||||
preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ',
|
preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ',
|
||||||
);
|
);
|
||||||
|
|
||||||
// For M4A/MP4, cover art is mapped as a video stream and stored in the
|
// For M4A cover replacements, mark the image as an attached picture so the
|
||||||
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
|
// mp4 muxer writes a proper covr atom instead of a generic MJPEG video track.
|
||||||
// flag is only valid for Matroska/WebM containers and must NOT be used here.
|
// Force the mp4 muxer because the default ipod muxer (auto-selected for .m4a)
|
||||||
// Force the mp4 muxer when cover art is present because the default ipod
|
// does not register a codec tag for mjpeg on FFmpeg 8.0+.
|
||||||
// muxer (auto-selected for .m4a) does not register a codec tag for mjpeg,
|
|
||||||
// causing "codec not currently supported in container" on FFmpeg 8.0+.
|
|
||||||
if (hasCover) {
|
if (hasCover) {
|
||||||
cmdBuffer.write('-map 1:v -c:v copy -f mp4 ');
|
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
|
cmdBuffer.write('-f mp4 ');
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdBuffer.write('-c:a copy ');
|
|
||||||
|
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
final m4aMetadata = _convertToM4aTags(metadata);
|
final m4aMetadata = _convertToM4aTags(metadata);
|
||||||
for (final entry in m4aMetadata.entries) {
|
for (final entry in m4aMetadata.entries) {
|
||||||
@@ -1760,9 +1920,13 @@ class FFmpegService {
|
|||||||
if (value != '0') vorbis['DISCNUMBER'] = value;
|
if (value != '0') vorbis['DISCNUMBER'] = value;
|
||||||
break;
|
break;
|
||||||
case 'DATE':
|
case 'DATE':
|
||||||
case 'YEAR':
|
|
||||||
vorbis['DATE'] = value;
|
vorbis['DATE'] = value;
|
||||||
break;
|
break;
|
||||||
|
case 'YEAR':
|
||||||
|
if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) {
|
||||||
|
vorbis['DATE'] = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'GENRE':
|
case 'GENRE':
|
||||||
vorbis['GENRE'] = value;
|
vorbis['GENRE'] = value;
|
||||||
break;
|
break;
|
||||||
@@ -1921,9 +2085,13 @@ class FFmpegService {
|
|||||||
m4aMap['disc'] = value;
|
m4aMap['disc'] = value;
|
||||||
break;
|
break;
|
||||||
case 'DATE':
|
case 'DATE':
|
||||||
case 'YEAR':
|
|
||||||
m4aMap['date'] = value;
|
m4aMap['date'] = value;
|
||||||
break;
|
break;
|
||||||
|
case 'YEAR':
|
||||||
|
if (!m4aMap.containsKey('date') || m4aMap['date']!.isEmpty) {
|
||||||
|
m4aMap['date'] = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'GENRE':
|
case 'GENRE':
|
||||||
m4aMap['genre'] = value;
|
m4aMap['genre'] = value;
|
||||||
break;
|
break;
|
||||||
@@ -2004,9 +2172,13 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'DATE':
|
case 'DATE':
|
||||||
case 'YEAR':
|
|
||||||
id3Map['date'] = value;
|
id3Map['date'] = value;
|
||||||
break;
|
break;
|
||||||
|
case 'YEAR':
|
||||||
|
if (!id3Map.containsKey('date') || id3Map['date']!.isEmpty) {
|
||||||
|
id3Map['date'] = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'ISRC':
|
case 'ISRC':
|
||||||
id3Map['TSRC'] = value;
|
id3Map['TSRC'] = value;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class HistoryDatabase {
|
|||||||
|
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 3,
|
version: 5,
|
||||||
onConfigure: (db) async {
|
onConfigure: (db) async {
|
||||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||||
await db.execute('PRAGMA synchronous = NORMAL');
|
await db.execute('PRAGMA synchronous = NORMAL');
|
||||||
@@ -63,13 +63,16 @@ class HistoryDatabase {
|
|||||||
isrc TEXT,
|
isrc TEXT,
|
||||||
spotify_id TEXT,
|
spotify_id TEXT,
|
||||||
track_number INTEGER,
|
track_number INTEGER,
|
||||||
|
total_tracks INTEGER,
|
||||||
disc_number INTEGER,
|
disc_number INTEGER,
|
||||||
|
total_discs INTEGER,
|
||||||
duration INTEGER,
|
duration INTEGER,
|
||||||
release_date TEXT,
|
release_date TEXT,
|
||||||
quality TEXT,
|
quality TEXT,
|
||||||
bit_depth INTEGER,
|
bit_depth INTEGER,
|
||||||
sample_rate INTEGER,
|
sample_rate INTEGER,
|
||||||
genre TEXT,
|
genre TEXT,
|
||||||
|
composer TEXT,
|
||||||
label TEXT,
|
label TEXT,
|
||||||
copyright TEXT
|
copyright TEXT
|
||||||
)
|
)
|
||||||
@@ -98,6 +101,31 @@ class HistoryDatabase {
|
|||||||
if (oldVersion < 3) {
|
if (oldVersion < 3) {
|
||||||
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
final columns = await db.rawQuery('PRAGMA table_info(history)');
|
||||||
|
final hasComposer = columns.any(
|
||||||
|
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'composer',
|
||||||
|
);
|
||||||
|
if (!hasComposer) {
|
||||||
|
await db.execute('ALTER TABLE history ADD COLUMN composer TEXT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldVersion < 5) {
|
||||||
|
final columns = await db.rawQuery('PRAGMA table_info(history)');
|
||||||
|
final hasTotalTracks = columns.any(
|
||||||
|
(row) =>
|
||||||
|
(row['name']?.toString().toLowerCase() ?? '') == 'total_tracks',
|
||||||
|
);
|
||||||
|
final hasTotalDiscs = columns.any(
|
||||||
|
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'total_discs',
|
||||||
|
);
|
||||||
|
if (!hasTotalTracks) {
|
||||||
|
await db.execute('ALTER TABLE history ADD COLUMN total_tracks INTEGER');
|
||||||
|
}
|
||||||
|
if (!hasTotalDiscs) {
|
||||||
|
await db.execute('ALTER TABLE history ADD COLUMN total_discs INTEGER');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final _iosContainerPattern = RegExp(
|
static final _iosContainerPattern = RegExp(
|
||||||
@@ -258,13 +286,16 @@ class HistoryDatabase {
|
|||||||
'isrc': json['isrc'],
|
'isrc': json['isrc'],
|
||||||
'spotify_id': json['spotifyId'],
|
'spotify_id': json['spotifyId'],
|
||||||
'track_number': json['trackNumber'],
|
'track_number': json['trackNumber'],
|
||||||
|
'total_tracks': json['totalTracks'],
|
||||||
'disc_number': json['discNumber'],
|
'disc_number': json['discNumber'],
|
||||||
|
'total_discs': json['totalDiscs'],
|
||||||
'duration': json['duration'],
|
'duration': json['duration'],
|
||||||
'release_date': json['releaseDate'],
|
'release_date': json['releaseDate'],
|
||||||
'quality': json['quality'],
|
'quality': json['quality'],
|
||||||
'bit_depth': json['bitDepth'],
|
'bit_depth': json['bitDepth'],
|
||||||
'sample_rate': json['sampleRate'],
|
'sample_rate': json['sampleRate'],
|
||||||
'genre': json['genre'],
|
'genre': json['genre'],
|
||||||
|
'composer': json['composer'],
|
||||||
'label': json['label'],
|
'label': json['label'],
|
||||||
'copyright': json['copyright'],
|
'copyright': json['copyright'],
|
||||||
};
|
};
|
||||||
@@ -289,13 +320,16 @@ class HistoryDatabase {
|
|||||||
'isrc': row['isrc'],
|
'isrc': row['isrc'],
|
||||||
'spotifyId': row['spotify_id'],
|
'spotifyId': row['spotify_id'],
|
||||||
'trackNumber': row['track_number'],
|
'trackNumber': row['track_number'],
|
||||||
|
'totalTracks': row['total_tracks'],
|
||||||
'discNumber': row['disc_number'],
|
'discNumber': row['disc_number'],
|
||||||
|
'totalDiscs': row['total_discs'],
|
||||||
'duration': row['duration'],
|
'duration': row['duration'],
|
||||||
'releaseDate': row['release_date'],
|
'releaseDate': row['release_date'],
|
||||||
'quality': row['quality'],
|
'quality': row['quality'],
|
||||||
'bitDepth': row['bit_depth'],
|
'bitDepth': row['bit_depth'],
|
||||||
'sampleRate': row['sample_rate'],
|
'sampleRate': row['sample_rate'],
|
||||||
'genre': row['genre'],
|
'genre': row['genre'],
|
||||||
|
'composer': row['composer'],
|
||||||
'label': row['label'],
|
'label': row['label'],
|
||||||
'copyright': row['copyright'],
|
'copyright': row['copyright'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,13 +20,18 @@ class LocalLibraryItem {
|
|||||||
final int? fileModTime;
|
final int? fileModTime;
|
||||||
final String? isrc;
|
final String? isrc;
|
||||||
final int? trackNumber;
|
final int? trackNumber;
|
||||||
|
final int? totalTracks;
|
||||||
final int? discNumber;
|
final int? discNumber;
|
||||||
|
final int? totalDiscs;
|
||||||
final int? duration;
|
final int? duration;
|
||||||
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 int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
|
||||||
final String? genre;
|
final String? genre;
|
||||||
|
final String? composer;
|
||||||
|
final String? label;
|
||||||
|
final String? copyright;
|
||||||
final String? format; // flac, mp3, opus, m4a
|
final String? format; // flac, mp3, opus, m4a
|
||||||
|
|
||||||
const LocalLibraryItem({
|
const LocalLibraryItem({
|
||||||
@@ -41,13 +46,18 @@ class LocalLibraryItem {
|
|||||||
this.fileModTime,
|
this.fileModTime,
|
||||||
this.isrc,
|
this.isrc,
|
||||||
this.trackNumber,
|
this.trackNumber,
|
||||||
|
this.totalTracks,
|
||||||
this.discNumber,
|
this.discNumber,
|
||||||
|
this.totalDiscs,
|
||||||
this.duration,
|
this.duration,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
this.bitDepth,
|
this.bitDepth,
|
||||||
this.sampleRate,
|
this.sampleRate,
|
||||||
this.bitrate,
|
this.bitrate,
|
||||||
this.genre,
|
this.genre,
|
||||||
|
this.composer,
|
||||||
|
this.label,
|
||||||
|
this.copyright,
|
||||||
this.format,
|
this.format,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,13 +73,18 @@ class LocalLibraryItem {
|
|||||||
'fileModTime': fileModTime,
|
'fileModTime': fileModTime,
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'trackNumber': trackNumber,
|
'trackNumber': trackNumber,
|
||||||
|
'totalTracks': totalTracks,
|
||||||
'discNumber': discNumber,
|
'discNumber': discNumber,
|
||||||
|
'totalDiscs': totalDiscs,
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'releaseDate': releaseDate,
|
'releaseDate': releaseDate,
|
||||||
'bitDepth': bitDepth,
|
'bitDepth': bitDepth,
|
||||||
'sampleRate': sampleRate,
|
'sampleRate': sampleRate,
|
||||||
'bitrate': bitrate,
|
'bitrate': bitrate,
|
||||||
'genre': genre,
|
'genre': genre,
|
||||||
|
'composer': composer,
|
||||||
|
'label': label,
|
||||||
|
'copyright': copyright,
|
||||||
'format': format,
|
'format': format,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,14 +100,19 @@ class LocalLibraryItem {
|
|||||||
scannedAt: DateTime.parse(json['scannedAt'] as String),
|
scannedAt: DateTime.parse(json['scannedAt'] as String),
|
||||||
fileModTime: (json['fileModTime'] as num?)?.toInt(),
|
fileModTime: (json['fileModTime'] as num?)?.toInt(),
|
||||||
isrc: json['isrc'] as String?,
|
isrc: json['isrc'] as String?,
|
||||||
trackNumber: json['trackNumber'] as int?,
|
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||||
discNumber: json['discNumber'] as int?,
|
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||||
duration: json['duration'] as int?,
|
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||||
|
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
|
||||||
|
duration: (json['duration'] as num?)?.toInt(),
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
bitDepth: json['bitDepth'] as int?,
|
bitDepth: (json['bitDepth'] as num?)?.toInt(),
|
||||||
sampleRate: json['sampleRate'] as int?,
|
sampleRate: (json['sampleRate'] as num?)?.toInt(),
|
||||||
bitrate: (json['bitrate'] as num?)?.toInt(),
|
bitrate: (json['bitrate'] as num?)?.toInt(),
|
||||||
genre: json['genre'] as String?,
|
genre: json['genre'] as String?,
|
||||||
|
composer: json['composer'] as String?,
|
||||||
|
label: json['label'] as String?,
|
||||||
|
copyright: json['copyright'] as String?,
|
||||||
format: json['format'] as String?,
|
format: json['format'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,7 +142,7 @@ class LibraryDatabase {
|
|||||||
|
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 4,
|
version: 6,
|
||||||
onConfigure: (db) async {
|
onConfigure: (db) async {
|
||||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||||
await db.execute('PRAGMA synchronous = NORMAL');
|
await db.execute('PRAGMA synchronous = NORMAL');
|
||||||
@@ -148,13 +168,18 @@ class LibraryDatabase {
|
|||||||
file_mod_time INTEGER,
|
file_mod_time INTEGER,
|
||||||
isrc TEXT,
|
isrc TEXT,
|
||||||
track_number INTEGER,
|
track_number INTEGER,
|
||||||
|
total_tracks INTEGER,
|
||||||
disc_number INTEGER,
|
disc_number INTEGER,
|
||||||
|
total_discs INTEGER,
|
||||||
duration INTEGER,
|
duration INTEGER,
|
||||||
release_date TEXT,
|
release_date TEXT,
|
||||||
bit_depth INTEGER,
|
bit_depth INTEGER,
|
||||||
sample_rate INTEGER,
|
sample_rate INTEGER,
|
||||||
bitrate INTEGER,
|
bitrate INTEGER,
|
||||||
genre TEXT,
|
genre TEXT,
|
||||||
|
composer TEXT,
|
||||||
|
label TEXT,
|
||||||
|
copyright TEXT,
|
||||||
format TEXT
|
format TEXT
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
@@ -190,6 +215,19 @@ class LibraryDatabase {
|
|||||||
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
|
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
|
||||||
_log.i('Added bitrate column for lossy format quality');
|
_log.i('Added bitrate column for lossy format quality');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 5) {
|
||||||
|
await db.execute('ALTER TABLE library ADD COLUMN label TEXT');
|
||||||
|
await db.execute('ALTER TABLE library ADD COLUMN copyright TEXT');
|
||||||
|
_log.i('Added label/copyright columns');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
await db.execute('ALTER TABLE library ADD COLUMN total_tracks INTEGER');
|
||||||
|
await db.execute('ALTER TABLE library ADD COLUMN total_discs INTEGER');
|
||||||
|
await db.execute('ALTER TABLE library ADD COLUMN composer TEXT');
|
||||||
|
_log.i('Added total_tracks/total_discs/composer columns');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||||
@@ -205,13 +243,18 @@ class LibraryDatabase {
|
|||||||
'file_mod_time': json['fileModTime'],
|
'file_mod_time': json['fileModTime'],
|
||||||
'isrc': json['isrc'],
|
'isrc': json['isrc'],
|
||||||
'track_number': json['trackNumber'],
|
'track_number': json['trackNumber'],
|
||||||
|
'total_tracks': json['totalTracks'],
|
||||||
'disc_number': json['discNumber'],
|
'disc_number': json['discNumber'],
|
||||||
|
'total_discs': json['totalDiscs'],
|
||||||
'duration': json['duration'],
|
'duration': json['duration'],
|
||||||
'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'],
|
'bitrate': json['bitrate'],
|
||||||
'genre': json['genre'],
|
'genre': json['genre'],
|
||||||
|
'composer': json['composer'],
|
||||||
|
'label': json['label'],
|
||||||
|
'copyright': json['copyright'],
|
||||||
'format': json['format'],
|
'format': json['format'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -229,13 +272,18 @@ class LibraryDatabase {
|
|||||||
'fileModTime': row['file_mod_time'],
|
'fileModTime': row['file_mod_time'],
|
||||||
'isrc': row['isrc'],
|
'isrc': row['isrc'],
|
||||||
'trackNumber': row['track_number'],
|
'trackNumber': row['track_number'],
|
||||||
|
'totalTracks': row['total_tracks'],
|
||||||
'discNumber': row['disc_number'],
|
'discNumber': row['disc_number'],
|
||||||
|
'totalDiscs': row['total_discs'],
|
||||||
'duration': row['duration'],
|
'duration': row['duration'],
|
||||||
'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'],
|
'bitrate': row['bitrate'],
|
||||||
'genre': row['genre'],
|
'genre': row['genre'],
|
||||||
|
'composer': row['composer'],
|
||||||
|
'label': row['label'],
|
||||||
|
'copyright': row['copyright'],
|
||||||
'format': row['format'],
|
'format': row['format'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -383,6 +431,45 @@ class LibraryDatabase {
|
|||||||
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
|
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> replaceWithConvertedItem({
|
||||||
|
required LocalLibraryItem item,
|
||||||
|
required String newFilePath,
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
}) async {
|
||||||
|
final db = await database;
|
||||||
|
final stat = await fileStat(newFilePath);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final normalizedFormat = _normalizeConvertedFormat(targetFormat);
|
||||||
|
final updated = item.toJson()
|
||||||
|
..['id'] = _generateLibraryId(newFilePath)
|
||||||
|
..['filePath'] = newFilePath
|
||||||
|
..['scannedAt'] = now.toIso8601String()
|
||||||
|
..['fileModTime'] = stat?.modified?.millisecondsSinceEpoch
|
||||||
|
..['format'] = normalizedFormat
|
||||||
|
..['bitrate'] = _convertedBitrate(
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalizedFormat == 'mp3' || normalizedFormat == 'opus') {
|
||||||
|
updated['bitDepth'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await txn.delete(
|
||||||
|
'library',
|
||||||
|
where: 'id = ? OR file_path = ?',
|
||||||
|
whereArgs: [item.id, item.filePath],
|
||||||
|
);
|
||||||
|
await txn.insert(
|
||||||
|
'library',
|
||||||
|
_jsonToDbRow(updated),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> delete(String id) async {
|
Future<void> delete(String id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('library', where: 'id = ?', whereArgs: [id]);
|
await db.delete('library', where: 'id = ?', whereArgs: [id]);
|
||||||
@@ -554,4 +641,43 @@ class LibraryDatabase {
|
|||||||
}
|
}
|
||||||
return totalDeleted;
|
return totalDeleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _normalizeConvertedFormat(String targetFormat) {
|
||||||
|
switch (targetFormat.trim().toLowerCase()) {
|
||||||
|
case 'alac':
|
||||||
|
return 'm4a';
|
||||||
|
case 'flac':
|
||||||
|
return 'flac';
|
||||||
|
case 'opus':
|
||||||
|
return 'opus';
|
||||||
|
default:
|
||||||
|
return 'mp3';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _convertedBitrate({
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
}) {
|
||||||
|
switch (targetFormat.trim().toLowerCase()) {
|
||||||
|
case 'mp3':
|
||||||
|
case 'opus':
|
||||||
|
final match = RegExp(r'(\d+)').firstMatch(bitrate);
|
||||||
|
return match != null ? int.tryParse(match.group(1)!) : null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _generateLibraryId(String filePath) {
|
||||||
|
return 'lib_${_hashString(filePath).toRadixString(16)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
int _hashString(String input) {
|
||||||
|
var hash = 5381;
|
||||||
|
for (final codeUnit in input.codeUnits) {
|
||||||
|
hash = (((hash << 5) + hash) + codeUnit) & 0xffffffff;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,8 +142,10 @@ class LocalTrackRedownloadService {
|
|||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
|
totalDiscs: data['total_discs'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
totalTracks: data['total_tracks'] as int?,
|
totalTracks: data['total_tracks'] as int?,
|
||||||
|
composer: data['composer']?.toString(),
|
||||||
source: data['source']?.toString() ?? data['provider_id']?.toString(),
|
source: data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||||
albumType: data['album_type']?.toString(),
|
albumType: data['album_type']?.toString(),
|
||||||
itemType: itemType,
|
itemType: itemType,
|
||||||
|
|||||||
@@ -781,6 +781,15 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as String).toList();
|
return list.map((e) => e as String).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> setDownloadFallbackExtensionIds(
|
||||||
|
List<String>? extensionIds,
|
||||||
|
) async {
|
||||||
|
_log.d('setDownloadFallbackExtensionIds: $extensionIds');
|
||||||
|
await _channel.invokeMethod('setDownloadFallbackExtensionIds', {
|
||||||
|
'extension_ids': extensionIds == null ? '' : jsonEncode(extensionIds),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> setMetadataProviderPriority(
|
static Future<void> setMetadataProviderPriority(
|
||||||
List<String> providerIds,
|
List<String> providerIds,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -100,12 +100,28 @@ void mergePlatformMetadataForTagEmbed({
|
|||||||
put('UNSYNCEDLYRICS', source['lyrics']);
|
put('UNSYNCEDLYRICS', source['lyrics']);
|
||||||
|
|
||||||
final trackNumber = source['track_number'];
|
final trackNumber = source['track_number'];
|
||||||
|
final totalTracks = source['total_tracks'];
|
||||||
if (trackNumber != null && trackNumber.toString() != '0') {
|
if (trackNumber != null && trackNumber.toString() != '0') {
|
||||||
put('TRACKNUMBER', trackNumber);
|
put(
|
||||||
|
'TRACKNUMBER',
|
||||||
|
totalTracks != null &&
|
||||||
|
totalTracks.toString().isNotEmpty &&
|
||||||
|
totalTracks.toString() != '0'
|
||||||
|
? '${trackNumber.toString()}/${totalTracks.toString()}'
|
||||||
|
: trackNumber,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final discNumber = source['disc_number'];
|
final discNumber = source['disc_number'];
|
||||||
|
final totalDiscs = source['total_discs'];
|
||||||
if (discNumber != null && discNumber.toString() != '0') {
|
if (discNumber != null && discNumber.toString() != '0') {
|
||||||
put('DISCNUMBER', discNumber);
|
put(
|
||||||
|
'DISCNUMBER',
|
||||||
|
totalDiscs != null &&
|
||||||
|
totalDiscs.toString().isNotEmpty &&
|
||||||
|
totalDiscs.toString() != '0'
|
||||||
|
? '${discNumber.toString()}/${totalDiscs.toString()}'
|
||||||
|
: discNumber,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,35 @@ Route<T> slidePageRoute<T>({required Widget page}) {
|
|||||||
return MaterialPageRoute<T>(builder: (context) => page);
|
return MaterialPageRoute<T>(builder: (context) => page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A directional horizontal transition for adjacent content, such as moving
|
||||||
|
/// between next/previous items within the same detail context.
|
||||||
|
Route<T> adjacentHorizontalPageRoute<T>({
|
||||||
|
required Widget page,
|
||||||
|
required bool fromRight,
|
||||||
|
}) {
|
||||||
|
final begin = Offset(fromRight ? 0.22 : -0.22, 0);
|
||||||
|
return PageRouteBuilder<T>(
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
transitionDuration: const Duration(milliseconds: 240),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 220),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
final curved = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
|
||||||
|
return SlideTransition(
|
||||||
|
position: Tween<Offset>(begin: begin, end: Offset.zero).animate(curved),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: Tween<double>(begin: 0.92, end: 1.0).animate(curved),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A shimmer effect widget that can wrap skeleton placeholders.
|
/// A shimmer effect widget that can wrap skeleton placeholders.
|
||||||
class ShimmerLoading extends StatefulWidget {
|
class ShimmerLoading extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|||||||
@@ -64,17 +64,6 @@ const _builtInServices = [
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
BuiltInService(
|
|
||||||
id: 'deezer',
|
|
||||||
label: 'Deezer',
|
|
||||||
qualityOptions: [
|
|
||||||
QualityOption(
|
|
||||||
id: 'FLAC',
|
|
||||||
label: 'FLAC Best Quality',
|
|
||||||
description: 'Up to 24-bit / 48kHz+',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||||
@@ -138,6 +127,18 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
} else {
|
} else {
|
||||||
_selectedService = ref.read(settingsProvider).defaultService;
|
_selectedService = ref.read(settingsProvider).defaultService;
|
||||||
}
|
}
|
||||||
|
if (!_builtInServices.any((service) => service.id == _selectedService)) {
|
||||||
|
final extensionState = ref.read(extensionProvider);
|
||||||
|
final hasMatchingExtension = extensionState.extensions.any(
|
||||||
|
(ext) =>
|
||||||
|
ext.enabled &&
|
||||||
|
ext.hasDownloadProvider &&
|
||||||
|
ext.id == _selectedService,
|
||||||
|
);
|
||||||
|
if (!hasMatchingExtension) {
|
||||||
|
_selectedService = 'tidal';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<QualityOption> _getQualityOptions() {
|
List<QualityOption> _getQualityOptions() {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 4.2.0+121
|
version: 4.2.2+123
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user