Compare commits

...

14 Commits

83 changed files with 4501 additions and 1355 deletions
+6 -1
View File
@@ -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:
+15
View File
@@ -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"
}
} }
} }
-18
View File
@@ -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
View File
@@ -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{}{}
} }
+27 -11
View File
@@ -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":
+8
View File
@@ -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, "."),
} }
+7
View File
@@ -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),
-444
View File
@@ -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
View File
@@ -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
View File
@@ -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"])
}
}
+39 -39
View File
@@ -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()
+339 -93
View File
@@ -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))
} }
} }
+183 -2
View File
@@ -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)
+13 -9
View File
@@ -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)
+10 -10
View File
@@ -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,
+359
View File
@@ -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)
}
+185
View File
@@ -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())
}
}
+3 -3
View File
@@ -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,
+208 -9
View File
@@ -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,
+9 -9
View File
@@ -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)
+3 -3
View File
@@ -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("")
} }
+7 -7
View File
@@ -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 = {
+23 -23
View File
@@ -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)
} }
+7 -7
View File
@@ -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,
}, },
} }
+20 -20
View File
@@ -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()
+1 -1
View File
@@ -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 {
+10 -10
View File
@@ -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",
+2 -2
View File
@@ -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,
+30
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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 {
+18
View File
@@ -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
+37 -2
View File
@@ -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)
}
}
+9
View File
@@ -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
+7
View File
@@ -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]
+2 -2
View File
@@ -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.
+30
View File
@@ -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:
+18
View File
@@ -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';
+18
View File
@@ -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';
+18
View File
@@ -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';
+18
View File
@@ -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';
+18
View File
@@ -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';
+18
View File
@@ -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';
+18
View File
@@ -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 => 'ダウンロードプロバイダーの拡張はありません';
+18
View File
@@ -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';
+18
View File
@@ -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';
+18
View File
@@ -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';
+18
View File
@@ -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 =>
'Нет расширений с провайдером загрузки'; 'Нет расширений с провайдером загрузки';
+18
View File
@@ -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';
+18
View File
@@ -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';
+20
View File
@@ -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"
+20
View File
@@ -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"
+7
View File
@@ -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),
+5
View File
@@ -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,
+4
View File
@@ -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,
}); });
+4
View File
@@ -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
+2 -2
View File
@@ -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);
+53 -4
View File
@@ -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();
+6 -2
View File
@@ -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,
); );
} }
+79 -20
View File
@@ -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;
} }
+11 -3
View File
@@ -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,
), ),
), ),
); );
+34 -5
View File
@@ -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),
+66 -8
View File
@@ -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(),
); );
} }
+12 -3
View File
@@ -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++;
+3 -2
View File
@@ -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
View File
@@ -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)
+70 -1
View File
@@ -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();
+414 -117
View File
@@ -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,
+194 -22
View File
@@ -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;
+35 -1
View File
@@ -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'],
}; };
+132 -6
View File
@@ -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,
+9
View File
@@ -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 {
+18 -2
View File
@@ -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,
);
} }
} }
+29
View File
@@ -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;
+12 -11
View File
@@ -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
View File
@@ -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