Compare commits

...

7 Commits

50 changed files with 1172 additions and 376 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">
+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),
+2
View File
@@ -369,10 +369,12 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
TrackNumber: req.TrackNumber, TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
ISRC: req.ISRC, ISRC: req.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
+126 -34
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"`
@@ -88,11 +90,14 @@ type DownloadResponse struct {
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"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
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"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"` LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"` DecryptionKey string `json:"decryption_key,omitempty"`
@@ -107,12 +112,15 @@ 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
} }
@@ -130,11 +138,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"`
@@ -183,9 +194,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,6 +230,9 @@ 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
}
} }
} }
@@ -225,6 +245,11 @@ 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,
} }
} }
@@ -256,13 +281,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 +395,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 +564,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,12 +588,15 @@ 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,
} }
@@ -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 != "" {
@@ -2237,7 +2302,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
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)
@@ -2379,7 +2444,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
} }
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 +2459,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 {
@@ -2408,7 +2476,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
} }
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 +2491,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 +2542,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 +2556,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 +2577,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 +2600,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 +2632,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)
} }
@@ -2636,12 +2707,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 +2727,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 +2774,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 +2916,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 +2931,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 +2946,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 +2963,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 +2981,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 +3001,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 +3024,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 +3055,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 +3077,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 +3170,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 +3194,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 +3203,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 +3216,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 +3246,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 +3279,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 +3350,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 +3380,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 +3390,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 +3464,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 +3485,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 +3513,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 +3542,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 +3557,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 +3723,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
+44
View File
@@ -224,3 +224,47 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
} }
} }
} }
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
req := reEnrichRequest{}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
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.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()
+88 -52
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 {
@@ -113,19 +116,19 @@ type ExtDownloadResult struct {
DecryptionKey string `json:"decryption_key,omitempty"` DecryptionKey string `json:"decryption_key,omitempty"`
} }
type ExtensionProviderWrapper struct { type extensionProviderWrapper struct {
extension *LoadedExtension 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 +137,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 +197,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 +246,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 +298,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 +353,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 +415,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 +463,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 +513,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)
} }
@@ -601,40 +604,40 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID
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
} }
@@ -775,7 +778,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 +788,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 +830,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 +916,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)
@@ -965,7 +971,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 +981,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 +1050,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 +1098,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 +1155,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
@@ -1346,7 +1376,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 {
@@ -1594,12 +1624,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)
@@ -1624,7 +1657,7 @@ func buildOutputPath(req DownloadRequest) string {
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) return strings.TrimSpace(req.OutputPath)
} }
@@ -1644,12 +1677,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 +1703,7 @@ 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 (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 +1785,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 +1871,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 +1942,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 +2005,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 +2075,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 +2118,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 +2138,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 +2196,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 +2264,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 +2362,14 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
return response, nil return response, nil
} }
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" { if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
+1 -1
View File
@@ -51,7 +51,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)
+9 -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()
+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,
+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,
+9 -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,7 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { 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 +386,7 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { 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 +459,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 +507,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 {
+9
View File
@@ -1030,6 +1030,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 +1042,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),
@@ -2793,10 +2800,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
+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
+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.1';
static const String buildNumber = '121'; static const String buildNumber = '122';
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.
+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,
}; };
+79 -10
View File
@@ -3251,11 +3251,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isrc: backendIsrc ?? baseTrack.isrc, isrc: backendIsrc ?? baseTrack.isrc,
trackNumber: backendTrackNum ?? baseTrack.trackNumber, trackNumber: backendTrackNum ?? baseTrack.trackNumber,
discNumber: backendDiscNum ?? baseTrack.discNumber, discNumber: backendDiscNum ?? baseTrack.discNumber,
totalDiscs: baseTrack.totalDiscs,
releaseDate: backendYear ?? baseTrack.releaseDate, releaseDate: backendYear ?? baseTrack.releaseDate,
deezerId: baseTrack.deezerId, deezerId: baseTrack.deezerId,
availability: baseTrack.availability, availability: baseTrack.availability,
albumType: baseTrack.albumType, albumType: baseTrack.albumType,
totalTracks: baseTrack.totalTracks, totalTracks: baseTrack.totalTracks,
composer: baseTrack.composer,
source: baseTrack.source, source: baseTrack.source,
); );
} }
@@ -3329,17 +3331,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ARTIST': track.artistName, 'ARTIST': track.artistName,
'ALBUM': track.albumName, 'ALBUM': track.albumName,
}; };
String formatIndexTag(int number, int? total) {
if (total != null && total > 0) {
return '$number/$total';
}
return number.toString();
}
final albumArtist = _resolveAlbumArtistForMetadata(track, settings); final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist; metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null && track.trackNumber! > 0) { if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString(); final trackTag = formatIndexTag(track.trackNumber!, track.totalTracks);
if (isFlac || isMp3) metadata['TRACK'] = track.trackNumber.toString(); metadata['TRACKNUMBER'] = trackTag;
if (isFlac || isMp3) metadata['TRACK'] = trackTag;
} }
if (track.discNumber != null && track.discNumber! > 0) { if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString(); final discTag = formatIndexTag(track.discNumber!, track.totalDiscs);
if (isFlac || isMp3) metadata['DISC'] = track.discNumber.toString(); metadata['DISCNUMBER'] = discTag;
if (isFlac || isMp3) metadata['DISC'] = discTag;
} }
if (track.releaseDate != null) { if (track.releaseDate != null) {
metadata['DATE'] = track.releaseDate!; metadata['DATE'] = track.releaseDate!;
@@ -3353,6 +3363,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (copyright != null && copyright.isNotEmpty) { if (copyright != null && copyright.isNotEmpty) {
metadata['COPYRIGHT'] = copyright; metadata['COPYRIGHT'] = copyright;
} }
if (track.composer != null && track.composer!.isNotEmpty) {
metadata['COMPOSER'] = track.composer!;
}
// Lyrics // Lyrics
final lyricsMode = settings.lyricsMode; final lyricsMode = settings.lyricsMode;
@@ -3875,7 +3888,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(trackToDownload.isrc == null || (trackToDownload.isrc == null ||
trackToDownload.isrc!.isEmpty || trackToDownload.isrc!.isEmpty ||
trackToDownload.trackNumber == null || trackToDownload.trackNumber == null ||
trackToDownload.trackNumber == 0); trackToDownload.trackNumber == 0 ||
trackToDownload.totalTracks == null ||
trackToDownload.totalTracks == 0 ||
(trackToDownload.composer == null ||
trackToDownload.composer!.isEmpty));
if (needsEnrichment) { if (needsEnrichment) {
try { try {
@@ -3901,6 +3918,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Track data keys: ${data.keys.toList()}'); _log.d('Track data keys: ${data.keys.toList()}');
_log.d('ISRC from API: ${data['isrc']}'); _log.d('ISRC from API: ${data['isrc']}');
_log.d('album_type from API: ${data['album_type']}'); _log.d('album_type from API: ${data['album_type']}');
final enrichedTotalTracks = _parsePositiveInt(
data['total_tracks'],
);
final enrichedTotalDiscs = _parsePositiveInt(data['total_discs']);
final enrichedComposer = normalizeOptionalString(
data['composer']?.toString(),
);
trackToDownload = Track( trackToDownload = Track(
id: (data['spotify_id'] as String?) ?? trackToDownload.id, id: (data['spotify_id'] as String?) ?? trackToDownload.id,
name: (data['name'] as String?) ?? trackToDownload.name, name: (data['name'] as String?) ?? trackToDownload.name,
@@ -3923,14 +3947,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc, isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
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: enrichedTotalDiscs ?? trackToDownload.totalDiscs,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
deezerId: rawId, deezerId: rawId,
availability: trackToDownload.availability, availability: trackToDownload.availability,
albumType: albumType:
(data['album_type'] as String?) ?? (data['album_type'] as String?) ??
trackToDownload.albumType, trackToDownload.albumType,
totalTracks: totalTracks: enrichedTotalTracks ?? trackToDownload.totalTracks,
data['total_tracks'] as int? ?? trackToDownload.totalTracks, composer: enrichedComposer ?? trackToDownload.composer,
source: trackToDownload.source, source: trackToDownload.source,
); );
_log.d( _log.d(
@@ -4101,7 +4126,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackData['release_date'] as String?, trackData['release_date'] as String?,
); );
final provTrackNum = trackData['track_number'] as int?; final provTrackNum = trackData['track_number'] as int?;
final provTotalTracks = trackData['total_tracks'] as int?;
final provDiscNum = trackData['disc_number'] as int?; final provDiscNum = trackData['disc_number'] as int?;
final provTotalDiscs = trackData['total_discs'] as int?;
final provComposer = normalizeOptionalString(
trackData['composer'] as String?,
);
trackToDownload = Track( trackToDownload = Track(
id: trackToDownload.id, id: trackToDownload.id,
@@ -4124,11 +4154,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackToDownload.discNumber! > 0) trackToDownload.discNumber! > 0)
? trackToDownload.discNumber ? trackToDownload.discNumber
: provDiscNum, : provDiscNum,
totalDiscs:
(trackToDownload.totalDiscs != null &&
trackToDownload.totalDiscs! > 0)
? trackToDownload.totalDiscs
: provTotalDiscs,
releaseDate: trackToDownload.releaseDate ?? provReleaseDate, releaseDate: trackToDownload.releaseDate ?? provReleaseDate,
deezerId: trackToDownload.deezerId, deezerId: trackToDownload.deezerId,
availability: trackToDownload.availability, availability: trackToDownload.availability,
albumType: trackToDownload.albumType, albumType: trackToDownload.albumType,
totalTracks: trackToDownload.totalTracks, totalTracks:
(trackToDownload.totalTracks != null &&
trackToDownload.totalTracks! > 0)
? trackToDownload.totalTracks
: provTotalTracks,
composer: trackToDownload.composer ?? provComposer,
source: trackToDownload.source, source: trackToDownload.source,
); );
@@ -4198,7 +4238,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackData['isrc'] as String?, trackData['isrc'] as String?,
); );
final deezerTrackNum = trackData['track_number'] as int?; final deezerTrackNum = trackData['track_number'] as int?;
final deezerTotalTracks = trackData['total_tracks'] as int?;
final deezerDiscNum = trackData['disc_number'] as int?; final deezerDiscNum = trackData['disc_number'] as int?;
final deezerTotalDiscs = trackData['total_discs'] as int?;
final deezerComposer = normalizeOptionalString(
trackData['composer'] as String?,
);
final needsEnrich = final needsEnrich =
(trackToDownload.releaseDate == null && (trackToDownload.releaseDate == null &&
@@ -4210,10 +4255,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackToDownload.trackNumber! <= 0) && trackToDownload.trackNumber! <= 0) &&
deezerTrackNum != null && deezerTrackNum != null &&
deezerTrackNum > 0) || deezerTrackNum > 0) ||
((trackToDownload.totalTracks == null ||
trackToDownload.totalTracks! <= 0) &&
deezerTotalTracks != null &&
deezerTotalTracks > 0) ||
((trackToDownload.discNumber == null || ((trackToDownload.discNumber == null ||
trackToDownload.discNumber! <= 0) && trackToDownload.discNumber! <= 0) &&
deezerDiscNum != null && deezerDiscNum != null &&
deezerDiscNum > 0); deezerDiscNum > 0) ||
((trackToDownload.totalDiscs == null ||
trackToDownload.totalDiscs! <= 0) &&
deezerTotalDiscs != null &&
deezerTotalDiscs > 0) ||
((trackToDownload.composer == null ||
trackToDownload.composer!.isEmpty) &&
deezerComposer != null);
if (needsEnrich) { if (needsEnrich) {
trackToDownload = Track( trackToDownload = Track(
@@ -4239,11 +4295,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackToDownload.discNumber! > 0) trackToDownload.discNumber! > 0)
? trackToDownload.discNumber ? trackToDownload.discNumber
: deezerDiscNum, : deezerDiscNum,
totalDiscs:
(trackToDownload.totalDiscs != null &&
trackToDownload.totalDiscs! > 0)
? trackToDownload.totalDiscs
: deezerTotalDiscs,
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate, releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
deezerId: deezerTrackId, deezerId: deezerTrackId,
availability: trackToDownload.availability, availability: trackToDownload.availability,
albumType: trackToDownload.albumType, albumType: trackToDownload.albumType,
totalTracks: trackToDownload.totalTracks, totalTracks:
(trackToDownload.totalTracks != null &&
trackToDownload.totalTracks! > 0)
? trackToDownload.totalTracks
: deezerTotalTracks,
composer: trackToDownload.composer ?? deezerComposer,
source: trackToDownload.source, source: trackToDownload.source,
); );
_log.d( _log.d(
@@ -4390,6 +4456,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
metadataEmbeddingEnabled && settings.maxQualityCover, metadataEmbeddingEnabled && settings.maxQualityCover,
trackNumber: normalizedTrackNumber, trackNumber: normalizedTrackNumber,
discNumber: normalizedDiscNumber, discNumber: normalizedDiscNumber,
totalTracks: trackToDownload.totalTracks ?? 0,
totalDiscs: trackToDownload.totalDiscs ?? 0,
releaseDate: trackToDownload.releaseDate ?? '', releaseDate: trackToDownload.releaseDate ?? '',
itemId: item.id, itemId: item.id,
durationMs: trackToDownload.duration, durationMs: trackToDownload.duration,
@@ -4397,6 +4465,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
genre: genre ?? '', genre: genre ?? '',
label: label ?? '', label: label ?? '',
copyright: copyright ?? '', copyright: copyright ?? '',
composer: trackToDownload.composer ?? '',
qobuzId: payloadQobuzId, qobuzId: payloadQobuzId,
tidalId: payloadTidalId, tidalId: payloadTidalId,
deezerId: deezerTrackId ?? '', deezerId: deezerTrackId ?? '',
+4
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: data['album_type'] as String?,
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: data['album_type']?.toString(),
composer: data['composer']?.toString(),
itemType: itemType, itemType: itemType,
); );
} }
+2
View File
@@ -299,9 +299,11 @@ 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: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
); );
} }
+4
View File
@@ -410,9 +410,11 @@ 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: 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,
); );
} }
@@ -1129,9 +1131,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(),
); );
} }
+10
View File
@@ -1840,6 +1840,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,
@@ -4403,7 +4404,10 @@ 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(),
totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
source: widget.extensionId, source: widget.extensionId,
); );
} }
@@ -4562,7 +4566,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 +4746,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
View File
@@ -164,7 +164,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(),
); );
} }
+32 -8
View File
@@ -5853,13 +5853,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 +5898,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 +5922,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++;
+206 -23
View File
@@ -252,7 +252,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
if (mounted && if (mounted &&
exists && exists &&
!_isLocalItem && !_isCueVirtualTrack &&
!_hasLoadedResolvedAudioMetadata) { !_hasLoadedResolvedAudioMetadata) {
unawaited(_refreshResolvedAudioMetadataFromFile()); unawaited(_refreshResolvedAudioMetadataFromFile());
} }
@@ -291,8 +291,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 +327,23 @@ 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 resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']);
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 needsTotalTracks =
resolvedTotalTracks != null &&
resolvedTotalTracks > 0 &&
totalTracks == 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 +354,18 @@ 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 ||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
if ((resolvedBitDepth != null || if ((resolvedBitDepth != null ||
resolvedSampleRate != null || resolvedSampleRate != null ||
needsAlbum || needsAlbum ||
needsDuration || needsDuration ||
needsTotalTracks ||
needsTotalDiscs ||
needsComposer ||
needsLabel || needsLabel ||
needsCopyright || needsCopyright ||
isPlaceholderQualityLabel(_quality)) && isPlaceholderQualityLabel(_quality)) &&
@@ -359,6 +379,9 @@ 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 (needsTotalTracks) 'total_tracks': resolvedTotalTracks,
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,
}; };
@@ -480,6 +503,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
: _downloadItem!.trackNumber; : _downloadItem!.trackNumber;
} }
int? get totalTracks =>
_readPositiveInt(_editedMetadata?['total_tracks']) ??
(_isLocalItem ? _localLibraryItem!.totalTracks : null);
int? get discNumber { int? get discNumber {
final edited = _editedMetadata?['disc_number']; final edited = _editedMetadata?['disc_number'];
if (edited != null) { if (edited != null) {
@@ -491,6 +518,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
: _downloadItem!.discNumber; : _downloadItem!.discNumber;
} }
int? get totalDiscs =>
_readPositiveInt(_editedMetadata?['total_discs']) ??
(_isLocalItem ? _localLibraryItem!.totalDiscs : null);
String? get releaseDate => String? get releaseDate =>
_editedMetadata?['date']?.toString() ?? _editedMetadata?['date']?.toString() ??
(_isLocalItem (_isLocalItem
@@ -517,10 +548,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);
@@ -1255,8 +1289,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 +1307,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 +2565,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 +2591,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,
}; };
}); });
} }
@@ -2989,19 +3035,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 +3085,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 +3929,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 +3991,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 +4059,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 +4086,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 +4144,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 +4441,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 +4458,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 +4648,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 +4662,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 +4685,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 +4699,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 +4866,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 +5074,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 +5087,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 +5250,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 +5272,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 +5294,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 +5348,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 +5572,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 +5591,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,
+37 -15
View File
@@ -1311,28 +1311,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 +1770,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 +1935,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 +2022,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;
+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,
+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,
);
} }
} }
+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.1+122
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0