mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e530ffbc3 | |||
| 14f6776fdc | |||
| da1c6e9171 | |||
| 9c3e934395 | |||
| 15d2c3b465 | |||
| 8aaa6d5cbe | |||
| 9158d0228d |
@@ -164,13 +164,18 @@ jobs:
|
||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
|
||||
@@ -57,6 +57,18 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
getByName("profile") {
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
release {
|
||||
// For local builds: use release signing if key.properties exists
|
||||
// For CI builds: APK is signed by GitHub Action after build
|
||||
@@ -71,6 +83,9 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,24 +94,6 @@
|
||||
android:exported="false"
|
||||
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 -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
|
||||
+12
-10
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -367,12 +366,9 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
||||
case "DATE":
|
||||
metadata.Date = value
|
||||
case "TRACK", "TRACKNUMBER":
|
||||
// APE track format can be "3" or "3/12"
|
||||
trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
||||
metadata.TrackNumber = trackNum
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISC", "DISCNUMBER":
|
||||
discNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
||||
metadata.DiscNumber = discNum
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
@@ -425,10 +421,10 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
||||
addItem("Year", metadata.Year)
|
||||
}
|
||||
if metadata.TrackNumber > 0 {
|
||||
addItem("Track", strconv.Itoa(metadata.TrackNumber))
|
||||
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||
}
|
||||
if metadata.DiscNumber > 0 {
|
||||
addItem("Disc", strconv.Itoa(metadata.DiscNumber))
|
||||
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||
}
|
||||
addItem("ISRC", metadata.ISRC)
|
||||
addItem("Lyrics", metadata.Lyrics)
|
||||
@@ -453,7 +449,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
"artist": "ARTIST",
|
||||
"album": "ALBUM",
|
||||
"album_artist": "ALBUM ARTIST",
|
||||
"date": "YEAR",
|
||||
"date": "DATE",
|
||||
"genre": "GENRE",
|
||||
"track_number": "TRACK",
|
||||
"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
|
||||
// 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,
|
||||
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||
if _, present := fields["date"]; present {
|
||||
@@ -484,9 +480,15 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
if _, present := fields["disc_number"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["disc_total"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_number"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_total"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["album_artist"]; present {
|
||||
result["ALBUMARTIST"] = struct{}{}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ type AudioMetadata struct {
|
||||
Year string
|
||||
Date string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
Lyrics string
|
||||
Label string
|
||||
@@ -173,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
case "TCO":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "TPA":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "TCM":
|
||||
metadata.Composer = value
|
||||
case "TPB":
|
||||
@@ -292,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
case "TCON":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRCK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "TPOS":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "TSRC":
|
||||
metadata.ISRC = value
|
||||
case "TCOM":
|
||||
@@ -580,14 +582,28 @@ func cleanGenre(genre string) string {
|
||||
}
|
||||
|
||||
func parseTrackNumber(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.Index(s, "/"); idx > 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
num, _ := strconv.Atoi(s)
|
||||
num, _ := parseIndexPair(s)
|
||||
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 {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
@@ -1037,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
case "GENRE":
|
||||
metadata.Genre = value
|
||||
case "TRACKNUMBER", "TRACK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISCNUMBER", "DISC":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "COMPOSER":
|
||||
|
||||
@@ -513,6 +513,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
album = "Unknown Album"
|
||||
}
|
||||
|
||||
composer := track.Composer
|
||||
if composer == "" {
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
var duration int
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextStart := sheet.Tracks[i+1].StartTime
|
||||
@@ -539,12 +544,15 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
ScannedAt: scanTime,
|
||||
ISRC: track.ISRC,
|
||||
TrackNumber: track.Number,
|
||||
TotalTracks: len(sheet.Tracks),
|
||||
DiscNumber: 1,
|
||||
TotalDiscs: 1,
|
||||
Duration: duration,
|
||||
ReleaseDate: sheet.Date,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Genre: sheet.Genre,
|
||||
Composer: composer,
|
||||
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||
}
|
||||
|
||||
|
||||
@@ -630,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
}
|
||||
|
||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||
totalDiscs := 0
|
||||
for _, track := range allTracks {
|
||||
if track.DiskNumber > totalDiscs {
|
||||
totalDiscs = track.DiskNumber
|
||||
}
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||
albumType := album.RecordType
|
||||
@@ -658,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
TrackNumber: trackNum,
|
||||
TotalTracks: album.NbTracks,
|
||||
DiscNumber: track.DiskNumber,
|
||||
TotalDiscs: totalDiscs,
|
||||
ExternalURL: track.Link,
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||
|
||||
@@ -369,10 +369,12 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
|
||||
+126
-34
@@ -55,6 +55,7 @@ type DownloadRequest struct {
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ItemID string `json:"item_id"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
@@ -62,6 +63,7 @@ type DownloadRequest struct {
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
@@ -88,11 +90,14 @@ type DownloadResponse struct {
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_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"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
@@ -107,12 +112,15 @@ type DownloadResult struct {
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
CoverURL string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
}
|
||||
@@ -130,11 +138,14 @@ type reEnrichRequest struct {
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ISRC string `json:"isrc"`
|
||||
Genre string `json:"genre"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Composer string `json:"composer"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
SearchOnline bool `json:"search_online"`
|
||||
UpdateFields []string `json:"update_fields,omitempty"`
|
||||
@@ -183,9 +194,15 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
if track.TrackNumber > 0 {
|
||||
req.TrackNumber = track.TrackNumber
|
||||
}
|
||||
if track.TotalTracks > 0 {
|
||||
req.TotalTracks = track.TotalTracks
|
||||
}
|
||||
if track.DiscNumber > 0 {
|
||||
req.DiscNumber = track.DiscNumber
|
||||
}
|
||||
if track.TotalDiscs > 0 {
|
||||
req.TotalDiscs = track.TotalDiscs
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
if track.ReleaseDate != "" {
|
||||
@@ -213,6 +230,9 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
if 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,
|
||||
DurationMS: int(req.DurationMs),
|
||||
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 != "" {
|
||||
metadata["COPYRIGHT"] = req.Copyright
|
||||
}
|
||||
if req.Composer != "" {
|
||||
metadata["COMPOSER"] = req.Composer
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
if req.TrackNumber > 0 {
|
||||
metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
|
||||
metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks)
|
||||
}
|
||||
if req.DiscNumber > 0 {
|
||||
metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
|
||||
metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs)
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
@@ -367,11 +395,14 @@ func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrac
|
||||
Images: track.Images,
|
||||
ReleaseDate: track.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: track.TotalTracks,
|
||||
DiscNumber: track.DiscNumber,
|
||||
TotalDiscs: track.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
ProviderID: providerID,
|
||||
DeezerID: deezerID,
|
||||
SpotifyID: track.SpotifyID,
|
||||
Composer: track.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,6 +564,11 @@ func buildDownloadSuccessResponse(
|
||||
copyright = req.Copyright
|
||||
}
|
||||
|
||||
composer := result.Composer
|
||||
if composer == "" {
|
||||
composer = req.Composer
|
||||
}
|
||||
|
||||
coverURL := strings.TrimSpace(result.CoverURL)
|
||||
if coverURL == "" {
|
||||
coverURL = strings.TrimSpace(req.CoverURL)
|
||||
@@ -552,12 +588,15 @@ func buildDownloadSuccessResponse(
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ReleaseDate: releaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: discNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: isrc,
|
||||
CoverURL: coverURL,
|
||||
Genre: genre,
|
||||
Label: label,
|
||||
Copyright: copyright,
|
||||
Composer: composer,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
}
|
||||
@@ -1005,7 +1044,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
"album_artist": "",
|
||||
"date": "",
|
||||
"track_number": 0,
|
||||
"total_tracks": 0,
|
||||
"disc_number": 0,
|
||||
"total_discs": 0,
|
||||
"isrc": "",
|
||||
"lyrics": "",
|
||||
"genre": "",
|
||||
@@ -1033,7 +1074,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["date"] = oggMeta.Year
|
||||
}
|
||||
result["track_number"] = oggMeta.TrackNumber
|
||||
result["total_tracks"] = oggMeta.TotalTracks
|
||||
result["disc_number"] = oggMeta.DiscNumber
|
||||
result["total_discs"] = oggMeta.TotalDiscs
|
||||
result["isrc"] = oggMeta.ISRC
|
||||
result["lyrics"] = oggMeta.Lyrics
|
||||
result["genre"] = oggMeta.Genre
|
||||
@@ -1054,7 +1097,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["album_artist"] = metadata.AlbumArtist
|
||||
result["date"] = metadata.Date
|
||||
result["track_number"] = metadata.TrackNumber
|
||||
result["total_tracks"] = metadata.TotalTracks
|
||||
result["disc_number"] = metadata.DiscNumber
|
||||
result["total_discs"] = metadata.TotalDiscs
|
||||
result["isrc"] = metadata.ISRC
|
||||
result["lyrics"] = metadata.Lyrics
|
||||
result["genre"] = metadata.Genre
|
||||
@@ -1088,7 +1133,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["date"] = meta.Year
|
||||
}
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["total_tracks"] = meta.TotalTracks
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["total_discs"] = meta.TotalDiscs
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
@@ -1118,7 +1165,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["date"] = meta.Year
|
||||
}
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["total_tracks"] = meta.TotalTracks
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["total_discs"] = meta.TotalDiscs
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
@@ -1149,7 +1198,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["date"] = meta.Year
|
||||
}
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["total_tracks"] = meta.TotalTracks
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["total_discs"] = meta.TotalDiscs
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
@@ -1182,7 +1233,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["date"] = meta.Year
|
||||
}
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["total_tracks"] = meta.TotalTracks
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["total_discs"] = meta.TotalDiscs
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
@@ -1281,13 +1334,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
// APE/WV/MPC: write APEv2 tags natively
|
||||
if isApeFile {
|
||||
trackNum := 0
|
||||
totalTracks := 0
|
||||
discNum := 0
|
||||
totalDiscs := 0
|
||||
if v, ok := fields["track_number"]; ok && v != "" {
|
||||
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 != "" {
|
||||
fmt.Sscanf(v, "%d", &discNum)
|
||||
}
|
||||
if v, ok := fields["disc_total"]; ok && v != "" {
|
||||
fmt.Sscanf(v, "%d", &totalDiscs)
|
||||
}
|
||||
|
||||
meta := &AudioMetadata{
|
||||
Title: fields["title"],
|
||||
@@ -1296,7 +1357,9 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
AlbumArtist: fields["album_artist"],
|
||||
Date: fields["date"],
|
||||
TrackNumber: trackNum,
|
||||
TotalTracks: totalTracks,
|
||||
DiscNumber: discNum,
|
||||
TotalDiscs: totalDiscs,
|
||||
ISRC: fields["isrc"],
|
||||
Genre: fields["genre"],
|
||||
Label: fields["label"],
|
||||
@@ -1930,11 +1993,13 @@ func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"external_urls": track.ExternalURL,
|
||||
"isrc": track.ISRC,
|
||||
"album_id": track.AlbumID,
|
||||
"artist_id": track.ArtistID,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
|
||||
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||
@@ -2237,7 +2302,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
|
||||
deezerClient := GetDeezerClient()
|
||||
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
||||
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||
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") {
|
||||
enrichedMeta["track_number"] = req.TrackNumber
|
||||
enrichedMeta["total_tracks"] = req.TotalTracks
|
||||
enrichedMeta["disc_number"] = req.DiscNumber
|
||||
enrichedMeta["total_discs"] = req.TotalDiscs
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
enrichedMeta["release_date"] = req.ReleaseDate
|
||||
@@ -2392,6 +2459,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
enrichedMeta["genre"] = req.Genre
|
||||
enrichedMeta["label"] = req.Label
|
||||
enrichedMeta["copyright"] = req.Copyright
|
||||
enrichedMeta["composer"] = req.Composer
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
@@ -2408,7 +2476,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
metadata.TrackNumber = req.TrackNumber
|
||||
metadata.TotalTracks = req.TotalTracks
|
||||
metadata.DiscNumber = req.DiscNumber
|
||||
metadata.TotalDiscs = req.TotalDiscs
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
metadata.Date = req.ReleaseDate
|
||||
@@ -2421,6 +2491,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
metadata.Genre = req.Genre
|
||||
metadata.Label = req.Label
|
||||
metadata.Copyright = req.Copyright
|
||||
metadata.Composer = req.Composer
|
||||
}
|
||||
|
||||
if len(coverDataBytes) > 0 {
|
||||
@@ -2471,7 +2542,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
}
|
||||
|
||||
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2485,7 +2556,7 @@ func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||
}
|
||||
|
||||
func LoadExtensionsFromDir(dirPath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
|
||||
|
||||
result := map[string]interface{}{
|
||||
@@ -2506,7 +2577,7 @@ func LoadExtensionsFromDir(dirPath string) (string, error) {
|
||||
}
|
||||
|
||||
func LoadExtensionFromPath(filePath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.LoadExtensionFromFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -2529,17 +2600,17 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
||||
}
|
||||
|
||||
func UnloadExtensionByID(extensionID string) error {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
return manager.UnloadExtension(extensionID)
|
||||
}
|
||||
|
||||
func RemoveExtensionByID(extensionID string) error {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
return manager.RemoveExtension(extensionID)
|
||||
}
|
||||
|
||||
func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.UpgradeExtension(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -2561,17 +2632,17 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||
}
|
||||
|
||||
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
return manager.CheckExtensionUpgradeJSON(filePath)
|
||||
}
|
||||
|
||||
func GetInstalledExtensions() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
return manager.GetInstalledExtensionsJSON()
|
||||
}
|
||||
|
||||
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
return manager.SetExtensionEnabled(extensionID, enabled)
|
||||
}
|
||||
|
||||
@@ -2636,12 +2707,12 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
return manager.InitializeExtension(extensionID, settings)
|
||||
}
|
||||
|
||||
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithExtensions(query, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -2656,7 +2727,7 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
||||
}
|
||||
|
||||
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -2703,12 +2774,12 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||
}
|
||||
|
||||
func CleanupExtensions() {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
manager.UnloadAllExtensions()
|
||||
}
|
||||
|
||||
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
result, err := manager.InvokeAction(extensionID, actionName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -2845,7 +2916,7 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
||||
}
|
||||
|
||||
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != 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)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
enrichedTrack, err := provider.EnrichTrack(&track)
|
||||
if err != nil {
|
||||
return trackJSON, nil
|
||||
@@ -2875,7 +2946,7 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
||||
}
|
||||
|
||||
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -2910,11 +2981,14 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2927,7 +3001,7 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
}
|
||||
|
||||
func GetSearchProvidersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
providers := manager.GetSearchProviders()
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(providers))
|
||||
@@ -2950,7 +3024,7 @@ func GetSearchProvidersJSON() (string, error) {
|
||||
}
|
||||
|
||||
func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
resultWithID, err := manager.HandleURLWithExtension(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -2981,9 +3055,12 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"images": result.Track.ResolvedCoverURL(),
|
||||
"release_date": result.Track.ReleaseDate,
|
||||
"track_number": result.Track.TrackNumber,
|
||||
"total_tracks": result.Track.TotalTracks,
|
||||
"disc_number": result.Track.DiscNumber,
|
||||
"total_discs": result.Track.TotalDiscs,
|
||||
"isrc": result.Track.ISRC,
|
||||
"provider_id": result.Track.ProviderID,
|
||||
"composer": result.Track.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3000,11 +3077,14 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
}
|
||||
response["tracks"] = tracks
|
||||
@@ -3090,10 +3170,13 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"spotify_id": track.SpotifyID,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
}
|
||||
artistResponse["top_tracks"] = topTracks
|
||||
@@ -3111,7 +3194,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
}
|
||||
|
||||
func FindURLHandlerJSON(url string) string {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
handler := manager.FindURLHandler(url)
|
||||
if handler == nil {
|
||||
return ""
|
||||
@@ -3120,7 +3203,7 @@ func FindURLHandlerJSON(url string) string {
|
||||
}
|
||||
|
||||
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -3133,7 +3216,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
album, err := provider.GetAlbum(albumID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -3163,11 +3246,14 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
"cover_url": trackCover,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": trackNum,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"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) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -3264,11 +3350,14 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
||||
"cover_url": trackCover,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"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) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -3301,7 +3390,7 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
artist, err := provider.GetArtist(artistID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -3375,10 +3464,13 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"spotify_id": track.SpotifyID,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
}
|
||||
response["top_tracks"] = topTracks
|
||||
@@ -3393,7 +3485,7 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
}
|
||||
|
||||
func GetURLHandlersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
handlers := manager.GetURLHandlers()
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -3450,7 +3542,7 @@ func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
result, err := manager.RunPostProcessingV2(input, metadata)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -3465,7 +3557,7 @@ func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
||||
}
|
||||
|
||||
func GetPostProcessingProvidersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
providers := manager.GetPostProcessingProviders()
|
||||
|
||||
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) {
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
type LoadedExtension struct {
|
||||
type loadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
runtime *extensionRuntime
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
@@ -73,7 +73,7 @@ func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||
return filtered
|
||||
}
|
||||
|
||||
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
|
||||
if ext.VM == nil || ext.runtime == nil {
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
@@ -100,14 +100,14 @@ func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||
func (ext *loadedExtension) ensureRuntimeReady() error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
return ensureRuntimeReadyLocked(ext, true)
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
ext.VMMu.Lock()
|
||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||
ext.VMMu.Unlock()
|
||||
@@ -116,28 +116,28 @@ func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
return ext.VM, nil
|
||||
}
|
||||
|
||||
type ExtensionManager struct {
|
||||
type extensionManager struct {
|
||||
mu sync.RWMutex
|
||||
extensions map[string]*LoadedExtension
|
||||
extensions map[string]*loadedExtension
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var (
|
||||
globalExtManager *ExtensionManager
|
||||
globalExtManager *extensionManager
|
||||
globalExtManagerOnce sync.Once
|
||||
)
|
||||
|
||||
func GetExtensionManager() *ExtensionManager {
|
||||
func getExtensionManager() *extensionManager {
|
||||
globalExtManagerOnce.Do(func() {
|
||||
globalExtManager = &ExtensionManager{
|
||||
extensions: make(map[string]*LoadedExtension),
|
||||
globalExtManager = &extensionManager{
|
||||
extensions: make(map[string]*loadedExtension),
|
||||
}
|
||||
})
|
||||
return globalExtManager
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -154,7 +154,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
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") {
|
||||
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)
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: false, // New extensions start disabled
|
||||
@@ -292,7 +292,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func initializeVMLocked(ext *LoadedExtension) error {
|
||||
func initializeVMLocked(ext *loadedExtension) error {
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.initialized = false
|
||||
@@ -305,7 +305,7 @@ func initializeVMLocked(ext *LoadedExtension) error {
|
||||
return fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
ext.runtime = runtime
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
@@ -342,14 +342,14 @@ func initializeVMLocked(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *LoadedExtension,
|
||||
ext *loadedExtension,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
@@ -405,7 +405,7 @@ func initializeExtensionWithSettingsLocked(
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupLocked(ext *LoadedExtension) error {
|
||||
func runCleanupLocked(ext *loadedExtension) error {
|
||||
if ext.VM != nil {
|
||||
script := `
|
||||
(function() {
|
||||
@@ -446,7 +446,7 @@ func runCleanupLocked(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *LoadedExtension) {
|
||||
func teardownVMLocked(ext *loadedExtension) {
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
@@ -461,7 +461,7 @@ func teardownVMLocked(ext *LoadedExtension) {
|
||||
ext.initialized = false
|
||||
}
|
||||
|
||||
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
func validateExtensionLoad(ext *loadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
@@ -472,7 +472,7 @@ func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -491,7 +491,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -502,18 +502,18 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||
func (m *extensionManager) GetAllExtensions() []*loadedExtension {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]*LoadedExtension, 0, len(m.extensions))
|
||||
result := make([]*loadedExtension, 0, len(m.extensions))
|
||||
for _, ext := range m.extensions {
|
||||
result = append(result, ext)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -547,7 +547,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||
func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||
var loaded []string
|
||||
var errors []error
|
||||
|
||||
@@ -585,7 +585,7 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
return loaded, errors
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
|
||||
m.mu.Lock()
|
||||
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)
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: false, // Will be restored from settings store
|
||||
@@ -643,7 +643,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
ext, err := m.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -663,7 +663,7 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
}
|
||||
|
||||
// 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") {
|
||||
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,
|
||||
Manifest: newManifest,
|
||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||
@@ -812,7 +812,7 @@ type ExtensionUpgradeInfo struct {
|
||||
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") {
|
||||
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
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -885,7 +885,7 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
extensions := m.GetAllExtensions()
|
||||
|
||||
type ExtensionInfo struct {
|
||||
@@ -982,7 +982,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
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()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -1000,7 +1000,7 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
func (m *extensionManager) CleanupExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -1022,7 +1022,7 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
func (m *extensionManager) UnloadAllExtensions() {
|
||||
m.mu.Lock()
|
||||
extensionIDs := make([]string, 0, len(m.extensions))
|
||||
for id := range m.extensions {
|
||||
@@ -1037,7 +1037,7 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
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()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ type ExtTrackMetadata struct {
|
||||
Images string `json:"images,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ItemType string `json:"item_type,omitempty"`
|
||||
@@ -41,6 +43,7 @@ type ExtTrackMetadata struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
}
|
||||
|
||||
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||
@@ -113,19 +116,19 @@ type ExtDownloadResult struct {
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionProviderWrapper struct {
|
||||
extension *LoadedExtension
|
||||
type extensionProviderWrapper struct {
|
||||
extension *loadedExtension
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
|
||||
return &ExtensionProviderWrapper{
|
||||
func newExtensionProviderWrapper(ext *loadedExtension) *extensionProviderWrapper {
|
||||
return &extensionProviderWrapper{
|
||||
extension: ext,
|
||||
vm: ext.VM,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||
func (p *extensionProviderWrapper) lockReadyVM() error {
|
||||
vm, err := p.extension.lockReadyVM()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -134,7 +137,7 @@ func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
||||
func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
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
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
||||
func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
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
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||
func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
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
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||
func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return track, nil
|
||||
}
|
||||
@@ -412,7 +415,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
||||
func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
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
|
||||
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetMetadataProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetDownloadProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
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()
|
||||
if len(providers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
|
||||
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
|
||||
providerByID := make(map[string]*extensionProviderWrapper, len(providers))
|
||||
orderedProviders := make([]*extensionProviderWrapper, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
providerByID[provider.extension.ID] = provider
|
||||
}
|
||||
@@ -775,7 +778,9 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
|
||||
Images: track.Images,
|
||||
ReleaseDate: track.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: track.TotalTracks,
|
||||
DiscNumber: track.DiscNumber,
|
||||
TotalDiscs: track.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
ProviderID: providerID,
|
||||
SpotifyID: prefixedID,
|
||||
@@ -783,6 +788,7 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
|
||||
TidalID: tidalID,
|
||||
QobuzID: qobuzID,
|
||||
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()
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
extensionProviders := make(map[string]*ExtensionProviderWrapper)
|
||||
extensionProviders := make(map[string]*extensionProviderWrapper)
|
||||
if includeExtensions {
|
||||
for _, provider := range m.GetMetadataProviders() {
|
||||
extensionProviders[provider.extension.ID] = provider
|
||||
@@ -910,7 +916,7 @@ func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit
|
||||
|
||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||
priority := GetProviderPriority()
|
||||
extManager := GetExtensionManager()
|
||||
extManager := getExtensionManager()
|
||||
strictMode := !req.UseFallback
|
||||
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() {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
trackMeta := &ExtTrackMetadata{
|
||||
ID: req.SpotifyID,
|
||||
Name: req.TrackName,
|
||||
@@ -975,8 +981,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
ISRC: req.ISRC,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ProviderID: req.Source,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
||||
@@ -1041,10 +1050,22 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", 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 {
|
||||
GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", 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 {
|
||||
req.TrackNumber = track.TrackNumber
|
||||
}
|
||||
if track.TotalTracks > 0 && req.TotalTracks == 0 {
|
||||
req.TotalTracks = track.TotalTracks
|
||||
}
|
||||
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||
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 == "" {
|
||||
req.CoverURL = track.CoverURL
|
||||
}
|
||||
@@ -1125,7 +1155,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
trackID := req.SpotifyID
|
||||
|
||||
@@ -1346,7 +1376,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
continue
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
|
||||
if err != nil || !availability.Available {
|
||||
@@ -1594,12 +1624,15 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"total_tracks": req.TotalTracks,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"total_discs": req.TotalDiscs,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
"composer": req.Composer,
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
@@ -1624,7 +1657,7 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
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) != "" {
|
||||
return strings.TrimSpace(req.OutputPath)
|
||||
}
|
||||
@@ -1644,12 +1677,15 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"total_tracks": req.TotalTracks,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"total_discs": req.TotalDiscs,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
"composer": req.Composer,
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
@@ -1667,7 +1703,7 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
|
||||
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() {
|
||||
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"`
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||
if !p.extension.Manifest.HasURLHandler() {
|
||||
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"`
|
||||
}
|
||||
|
||||
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() {
|
||||
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
|
||||
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetSearchProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetURLHandlers() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
|
||||
func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
|
||||
return NewExtensionProviderWrapper(ext)
|
||||
return newExtensionProviderWrapper(ext)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -2082,7 +2118,7 @@ type ExtURLHandleResultWithExtID struct {
|
||||
ExtensionID string
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
||||
func (m *extensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
||||
handler := m.FindURLHandler(url)
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("no extension found to handle URL: %s", url)
|
||||
@@ -2102,20 +2138,20 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetPostProcessingProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
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()
|
||||
if len(providers) == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
if len(providers) == 0 {
|
||||
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
||||
@@ -2228,7 +2264,7 @@ type ExtLyricsLine struct {
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
||||
func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
var providers []*extensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||
|
||||
@@ -80,7 +80,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
type extensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
@@ -123,10 +123,10 @@ var (
|
||||
privateIPCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
runtime := &ExtensionRuntime{
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
@@ -142,25 +142,25 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
return runtime
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
||||
func (r *extensionRuntime) clearActiveDownloadItemID() {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = ""
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
||||
func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||
r.activeDownloadMu.RLock()
|
||||
defer r.activeDownloadMu.RUnlock()
|
||||
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
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// 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]
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
httpObj := vm.NewObject()
|
||||
|
||||
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
|
||||
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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -111,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
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()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
extensionAuthStateMu.Unlock()
|
||||
@@ -162,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
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()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -178,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
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()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -228,7 +228,7 @@ func generatePKCEChallenge(verifier string) string {
|
||||
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
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
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()
|
||||
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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
|
||||
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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -71,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
func (r *extensionRuntime) validatePath(path string) (string, error) {
|
||||
if !r.manifest.Permissions.File {
|
||||
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
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -17,7 +17,7 @@ type HTTPResponse struct {
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
@@ -49,7 +49,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"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 {
|
||||
return r.vm.ToValue(0.0)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
||||
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 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
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 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"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 {
|
||||
return r.createFetchError("URL is required")
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return responseObj
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
func (r *extensionRuntime) createFetchError(message string) goja.Value {
|
||||
errorObj := r.vm.NewObject()
|
||||
errorObj.Set("ok", false)
|
||||
errorObj.Set("status", 0)
|
||||
@@ -148,7 +148,7 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
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 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
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)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
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 {
|
||||
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 := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
storageFlushRetryDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
func (r *extensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
||||
return dst
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
||||
func (r *extensionRuntime) ensureStorageLoaded() error {
|
||||
r.storageMu.RLock()
|
||||
if r.storageLoaded {
|
||||
r.storageMu.RUnlock()
|
||||
@@ -74,7 +74,7 @@ func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
return cloneInterfaceMap(r.storageCache), nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
if r.storageClosed {
|
||||
return
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -106,13 +106,13 @@ func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}
|
||||
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
|
||||
func (r *extensionRuntime) flushStorageDirtyAsync() {
|
||||
if err := r.flushStorageDirty(); err != nil {
|
||||
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirty() error {
|
||||
func (r *extensionRuntime) flushStorageDirty() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageTimer = nil
|
||||
@@ -140,7 +140,7 @@ func (r *ExtensionRuntime) flushStorageDirty() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageNow() error {
|
||||
func (r *extensionRuntime) flushStorageNow() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageTimer != nil {
|
||||
r.storageTimer.Stop()
|
||||
@@ -157,7 +157,7 @@ func (r *ExtensionRuntime) flushStorageNow() error {
|
||||
return r.persistStorageSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) closeStorageFlusher() {
|
||||
func (r *extensionRuntime) closeStorageFlusher() {
|
||||
r.storageMu.Lock()
|
||||
r.storageClosed = true
|
||||
r.storageDirty = false
|
||||
@@ -168,7 +168,7 @@ func (r *ExtensionRuntime) closeStorageFlusher() {
|
||||
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 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.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 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -254,15 +254,15 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||
func (r *extensionRuntime) getCredentialsPath() string {
|
||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getSaltPath() string {
|
||||
func (r *extensionRuntime) getSaltPath() string {
|
||||
return filepath.Join(r.dataDir, ".cred_salt")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
@@ -282,7 +282,7 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
salt, err := r.getOrCreateSalt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -293,7 +293,7 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
||||
func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
||||
r.credentialsMu.RLock()
|
||||
if r.credentialsLoaded {
|
||||
r.credentialsMu.RUnlock()
|
||||
@@ -340,7 +340,7 @@ func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -350,7 +350,7 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -377,7 +377,7 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -439,7 +439,7 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.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 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
||||
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 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"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()
|
||||
result := runtime.storageSet(goja.FunctionCall{
|
||||
Arguments: []goja.Value{
|
||||
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "storage-test",
|
||||
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||
runtime.RegisterAPIs(goja.New())
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "unload-storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "unload-storage-test",
|
||||
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||
VM: goja.New(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = time.Hour
|
||||
runtime.RegisterAPIs(ext.VM)
|
||||
ext.runtime = runtime
|
||||
|
||||
manager := &ExtensionManager{
|
||||
extensions: map[string]*LoadedExtension{
|
||||
manager := &extensionManager{
|
||||
extensions: map[string]*loadedExtension{
|
||||
ext.ID: ext,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"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 {
|
||||
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)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
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)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
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)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"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
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
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())
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
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)
|
||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||
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)
|
||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||
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)
|
||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
parts := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||
@@ -281,7 +281,7 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
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 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(sanitizeFilename(input))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.Get("gobackend")
|
||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||
gobackendObj = vm.NewObject()
|
||||
|
||||
@@ -295,7 +295,7 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
installed := make(map[string]string) // id -> version
|
||||
|
||||
if manager != nil {
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestIsDomainAllowed(t *testing.T) {
|
||||
|
||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
// Create a mock extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -110,7 +110,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||
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) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -143,7 +143,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
DataDir: tempDir,
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
validPath, err := runtime.validatePath("test.txt")
|
||||
if err != nil {
|
||||
@@ -177,7 +177,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
t.Error("Expected absolute path to be blocked")
|
||||
}
|
||||
|
||||
extNoFile := &LoadedExtension{
|
||||
extNoFile := &loadedExtension{
|
||||
ID: "test-ext-no-file",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext-no-file",
|
||||
@@ -187,7 +187,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
},
|
||||
DataDir: tempDir,
|
||||
}
|
||||
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
||||
runtimeNoFile := newExtensionRuntime(extNoFile)
|
||||
_, err = runtimeNoFile.validatePath("test.txt")
|
||||
if err == nil {
|
||||
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) {
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -203,7 +203,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
|
||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
// Create extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -254,7 +254,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
privateIPs := []string{
|
||||
"http://localhost/admin",
|
||||
|
||||
@@ -53,7 +53,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
IsTimeout: true,
|
||||
}}
|
||||
} 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)}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
case <-time.After(60 * time.Second):
|
||||
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||
// 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{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
|
||||
@@ -24,13 +24,18 @@ type LibraryScanResult struct {
|
||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
TotalTracks int `json:"totalTracks,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
TotalDiscs int `json:"totalDiscs,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
||||
}
|
||||
@@ -365,9 +370,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Genre = metadata.Genre
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err == nil {
|
||||
@@ -397,12 +407,17 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.ReleaseDate = metadata.Date
|
||||
if result.ReleaseDate == "" {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
}
|
||||
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
@@ -427,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
@@ -435,6 +452,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.ISRC = metadata.ISRC
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetMP3Quality(filePath)
|
||||
if err == nil {
|
||||
@@ -464,9 +484,14 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetOggQuality(filePath)
|
||||
if err == nil {
|
||||
@@ -501,13 +526,18 @@ func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
|
||||
@@ -385,8 +385,8 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
|
||||
extManager := GetExtensionManager()
|
||||
var extensionProviders []*ExtensionProviderWrapper
|
||||
extManager := getExtensionManager()
|
||||
var extensionProviders []*extensionProviderWrapper
|
||||
if extManager != nil {
|
||||
extensionProviders = extManager.GetLyricsProviders()
|
||||
}
|
||||
|
||||
+70
-25
@@ -110,6 +110,7 @@ type Metadata struct {
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
Description string
|
||||
Lyrics string
|
||||
@@ -273,23 +274,23 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
|
||||
trackNum := getComment(cmt, "TRACKNUMBER")
|
||||
if trackNum != "" {
|
||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
|
||||
}
|
||||
if metadata.TrackNumber == 0 {
|
||||
trackNum = getComment(cmt, "TRACK")
|
||||
if trackNum != "" {
|
||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
|
||||
}
|
||||
}
|
||||
|
||||
discNum := getComment(cmt, "DISCNUMBER")
|
||||
if discNum != "" {
|
||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
|
||||
}
|
||||
if metadata.DiscNumber == 0 {
|
||||
discNum = getComment(cmt, "DISC")
|
||||
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")
|
||||
}
|
||||
|
||||
// Track/disc numbers: present + empty → clear; present + "0" → clear.
|
||||
if v, ok := fields["track_number"]; ok {
|
||||
trackNum := 0
|
||||
if v != "" {
|
||||
fmt.Sscanf(v, "%d", &trackNum)
|
||||
// Track/disc numbers: present + empty → clear; when only totals are edited,
|
||||
// preserve the current index number and rewrite the combined value.
|
||||
if _, ok := fields["track_number"]; ok || fields["track_total"] != "" || hasMapKey(fields, "track_total") {
|
||||
currentTrackNum, currentTotalTracks := parseIndexPair(getComment(cmt, "TRACKNUMBER"))
|
||||
if currentTrackNum == 0 && currentTotalTracks == 0 {
|
||||
currentTrackNum, currentTotalTracks = parseIndexPair(getComment(cmt, "TRACK"))
|
||||
}
|
||||
if trackNum > 0 {
|
||||
setOrClearComment(cmt, "TRACKNUMBER", strconv.Itoa(trackNum))
|
||||
if v, ok := fields["track_number"]; ok {
|
||||
currentTrackNum = parsePositiveInt(v)
|
||||
}
|
||||
if v, ok := fields["track_total"]; ok {
|
||||
currentTotalTracks = parsePositiveInt(v)
|
||||
}
|
||||
if currentTrackNum > 0 {
|
||||
setOrClearComment(cmt, "TRACKNUMBER", formatIndexValue(currentTrackNum, currentTotalTracks))
|
||||
} else {
|
||||
removeCommentKey(cmt, "TRACKNUMBER")
|
||||
}
|
||||
removeCommentKey(cmt, "TRACK") // alias
|
||||
}
|
||||
if v, ok := fields["disc_number"]; ok {
|
||||
discNum := 0
|
||||
if v != "" {
|
||||
fmt.Sscanf(v, "%d", &discNum)
|
||||
if _, ok := fields["disc_number"]; ok || fields["disc_total"] != "" || hasMapKey(fields, "disc_total") {
|
||||
currentDiscNum, currentTotalDiscs := parseIndexPair(getComment(cmt, "DISCNUMBER"))
|
||||
if currentDiscNum == 0 && currentTotalDiscs == 0 {
|
||||
currentDiscNum, currentTotalDiscs = parseIndexPair(getComment(cmt, "DISC"))
|
||||
}
|
||||
if discNum > 0 {
|
||||
setOrClearComment(cmt, "DISCNUMBER", strconv.Itoa(discNum))
|
||||
if v, ok := fields["disc_number"]; ok {
|
||||
currentDiscNum = parsePositiveInt(v)
|
||||
}
|
||||
if v, ok := fields["disc_total"]; ok {
|
||||
currentTotalDiscs = parsePositiveInt(v)
|
||||
}
|
||||
if currentDiscNum > 0 {
|
||||
setOrClearComment(cmt, "DISCNUMBER", formatIndexValue(currentDiscNum, currentTotalDiscs))
|
||||
} else {
|
||||
removeCommentKey(cmt, "DISCNUMBER")
|
||||
}
|
||||
@@ -478,15 +492,11 @@ func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Me
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||
} else {
|
||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
setComment(cmt, "TRACKNUMBER", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||
}
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
setComment(cmt, "DISCNUMBER", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||
}
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
@@ -953,9 +963,9 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
||||
case "\xa9lyr":
|
||||
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "trkn":
|
||||
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fi.Size())
|
||||
case "disk":
|
||||
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fi.Size())
|
||||
case "----":
|
||||
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
start := parent.offset + parent.headerSize
|
||||
end := parent.offset + parent.size
|
||||
|
||||
@@ -23,11 +23,13 @@ type TrackMetadata struct {
|
||||
TrackNumber int `json:"track_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumTrackMetadata struct {
|
||||
@@ -42,11 +44,13 @@ type AlbumTrackMetadata struct {
|
||||
TrackNumber int `json:"track_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumInfoMetadata struct {
|
||||
|
||||
@@ -1030,6 +1030,7 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
||||
totalDiscs := 0
|
||||
for i := range album.Tracks.Items {
|
||||
track := &album.Tracks.Items[i]
|
||||
track.Album.ID = album.ID
|
||||
@@ -1041,8 +1042,14 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
Large: album.Image.Large,
|
||||
}
|
||||
track.Album.TracksCount = album.TracksCount
|
||||
if track.MediaNumber > totalDiscs {
|
||||
totalDiscs = track.MediaNumber
|
||||
}
|
||||
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
|
||||
}
|
||||
for i := range tracks {
|
||||
tracks[i].TotalDiscs = totalDiscs
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
AlbumInfo: qobuzAlbumToAlbumInfo(album),
|
||||
@@ -2793,10 +2800,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
|
||||
@@ -1012,6 +1012,7 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
||||
totalDiscs := 0
|
||||
for _, item := range itemsModule.PagedList.Items {
|
||||
track := item.Item
|
||||
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.ReleaseDate = headerModule.Album.ReleaseDate
|
||||
track.Album.URL = headerModule.Album.URL
|
||||
if track.VolumeNumber > totalDiscs {
|
||||
totalDiscs = track.VolumeNumber
|
||||
}
|
||||
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
||||
}
|
||||
for i := range tracks {
|
||||
tracks[i].TotalDiscs = totalDiscs
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
|
||||
@@ -2360,10 +2367,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '4.2.0';
|
||||
static const String buildNumber = '121';
|
||||
static const String version = '4.2.1';
|
||||
static const String buildNumber = '122';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
|
||||
@@ -16,12 +16,14 @@ class Track {
|
||||
final int duration;
|
||||
final int? trackNumber;
|
||||
final int? discNumber;
|
||||
final int? totalDiscs;
|
||||
final String? releaseDate;
|
||||
final String? deezerId;
|
||||
final ServiceAvailability? availability;
|
||||
final String? source;
|
||||
final String? albumType;
|
||||
final int? totalTracks;
|
||||
final String? composer;
|
||||
final String? itemType;
|
||||
|
||||
const Track({
|
||||
@@ -37,12 +39,14 @@ class Track {
|
||||
required this.duration,
|
||||
this.trackNumber,
|
||||
this.discNumber,
|
||||
this.totalDiscs,
|
||||
this.releaseDate,
|
||||
this.deezerId,
|
||||
this.availability,
|
||||
this.source,
|
||||
this.albumType,
|
||||
this.totalTracks,
|
||||
this.composer,
|
||||
this.itemType,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
duration: (json['duration'] as num).toInt(),
|
||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
deezerId: json['deezerId'] as String?,
|
||||
availability: json['availability'] == null
|
||||
@@ -29,6 +30,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
source: json['source'] as String?,
|
||||
albumType: json['albumType'] as String?,
|
||||
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||
composer: json['composer'] as String?,
|
||||
itemType: json['itemType'] as String?,
|
||||
);
|
||||
|
||||
@@ -45,12 +47,14 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'duration': instance.duration,
|
||||
'trackNumber': instance.trackNumber,
|
||||
'discNumber': instance.discNumber,
|
||||
'totalDiscs': instance.totalDiscs,
|
||||
'releaseDate': instance.releaseDate,
|
||||
'deezerId': instance.deezerId,
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
'albumType': instance.albumType,
|
||||
'totalTracks': instance.totalTracks,
|
||||
'composer': instance.composer,
|
||||
'itemType': instance.itemType,
|
||||
};
|
||||
|
||||
|
||||
@@ -3251,11 +3251,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
isrc: backendIsrc ?? baseTrack.isrc,
|
||||
trackNumber: backendTrackNum ?? baseTrack.trackNumber,
|
||||
discNumber: backendDiscNum ?? baseTrack.discNumber,
|
||||
totalDiscs: baseTrack.totalDiscs,
|
||||
releaseDate: backendYear ?? baseTrack.releaseDate,
|
||||
deezerId: baseTrack.deezerId,
|
||||
availability: baseTrack.availability,
|
||||
albumType: baseTrack.albumType,
|
||||
totalTracks: baseTrack.totalTracks,
|
||||
composer: baseTrack.composer,
|
||||
source: baseTrack.source,
|
||||
);
|
||||
}
|
||||
@@ -3329,17 +3331,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'ARTIST': track.artistName,
|
||||
'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);
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null && track.trackNumber! > 0) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
if (isFlac || isMp3) metadata['TRACK'] = track.trackNumber.toString();
|
||||
final trackTag = formatIndexTag(track.trackNumber!, track.totalTracks);
|
||||
metadata['TRACKNUMBER'] = trackTag;
|
||||
if (isFlac || isMp3) metadata['TRACK'] = trackTag;
|
||||
}
|
||||
if (track.discNumber != null && track.discNumber! > 0) {
|
||||
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||
if (isFlac || isMp3) metadata['DISC'] = track.discNumber.toString();
|
||||
final discTag = formatIndexTag(track.discNumber!, track.totalDiscs);
|
||||
metadata['DISCNUMBER'] = discTag;
|
||||
if (isFlac || isMp3) metadata['DISC'] = discTag;
|
||||
}
|
||||
if (track.releaseDate != null) {
|
||||
metadata['DATE'] = track.releaseDate!;
|
||||
@@ -3353,6 +3363,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (copyright != null && copyright.isNotEmpty) {
|
||||
metadata['COPYRIGHT'] = copyright;
|
||||
}
|
||||
if (track.composer != null && track.composer!.isNotEmpty) {
|
||||
metadata['COMPOSER'] = track.composer!;
|
||||
}
|
||||
|
||||
// ── Lyrics ──────────────────────────────────────────────────────
|
||||
final lyricsMode = settings.lyricsMode;
|
||||
@@ -3875,7 +3888,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
(trackToDownload.isrc == null ||
|
||||
trackToDownload.isrc!.isEmpty ||
|
||||
trackToDownload.trackNumber == null ||
|
||||
trackToDownload.trackNumber == 0);
|
||||
trackToDownload.trackNumber == 0 ||
|
||||
trackToDownload.totalTracks == null ||
|
||||
trackToDownload.totalTracks == 0 ||
|
||||
(trackToDownload.composer == null ||
|
||||
trackToDownload.composer!.isEmpty));
|
||||
|
||||
if (needsEnrichment) {
|
||||
try {
|
||||
@@ -3901,6 +3918,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.d('Track data keys: ${data.keys.toList()}');
|
||||
_log.d('ISRC from API: ${data['isrc']}');
|
||||
_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(
|
||||
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
||||
name: (data['name'] as String?) ?? trackToDownload.name,
|
||||
@@ -3923,14 +3947,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: enrichedTotalDiscs ?? trackToDownload.totalDiscs,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
deezerId: rawId,
|
||||
availability: trackToDownload.availability,
|
||||
albumType:
|
||||
(data['album_type'] as String?) ??
|
||||
trackToDownload.albumType,
|
||||
totalTracks:
|
||||
data['total_tracks'] as int? ?? trackToDownload.totalTracks,
|
||||
totalTracks: enrichedTotalTracks ?? trackToDownload.totalTracks,
|
||||
composer: enrichedComposer ?? trackToDownload.composer,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
_log.d(
|
||||
@@ -4101,7 +4126,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackData['release_date'] as String?,
|
||||
);
|
||||
final provTrackNum = trackData['track_number'] as int?;
|
||||
final provTotalTracks = trackData['total_tracks'] 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(
|
||||
id: trackToDownload.id,
|
||||
@@ -4124,11 +4154,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackToDownload.discNumber! > 0)
|
||||
? trackToDownload.discNumber
|
||||
: provDiscNum,
|
||||
totalDiscs:
|
||||
(trackToDownload.totalDiscs != null &&
|
||||
trackToDownload.totalDiscs! > 0)
|
||||
? trackToDownload.totalDiscs
|
||||
: provTotalDiscs,
|
||||
releaseDate: trackToDownload.releaseDate ?? provReleaseDate,
|
||||
deezerId: trackToDownload.deezerId,
|
||||
availability: trackToDownload.availability,
|
||||
albumType: trackToDownload.albumType,
|
||||
totalTracks: trackToDownload.totalTracks,
|
||||
totalTracks:
|
||||
(trackToDownload.totalTracks != null &&
|
||||
trackToDownload.totalTracks! > 0)
|
||||
? trackToDownload.totalTracks
|
||||
: provTotalTracks,
|
||||
composer: trackToDownload.composer ?? provComposer,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
|
||||
@@ -4198,7 +4238,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackData['isrc'] as String?,
|
||||
);
|
||||
final deezerTrackNum = trackData['track_number'] as int?;
|
||||
final deezerTotalTracks = trackData['total_tracks'] 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 =
|
||||
(trackToDownload.releaseDate == null &&
|
||||
@@ -4210,10 +4255,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackToDownload.trackNumber! <= 0) &&
|
||||
deezerTrackNum != null &&
|
||||
deezerTrackNum > 0) ||
|
||||
((trackToDownload.totalTracks == null ||
|
||||
trackToDownload.totalTracks! <= 0) &&
|
||||
deezerTotalTracks != null &&
|
||||
deezerTotalTracks > 0) ||
|
||||
((trackToDownload.discNumber == null ||
|
||||
trackToDownload.discNumber! <= 0) &&
|
||||
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) {
|
||||
trackToDownload = Track(
|
||||
@@ -4239,11 +4295,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackToDownload.discNumber! > 0)
|
||||
? trackToDownload.discNumber
|
||||
: deezerDiscNum,
|
||||
totalDiscs:
|
||||
(trackToDownload.totalDiscs != null &&
|
||||
trackToDownload.totalDiscs! > 0)
|
||||
? trackToDownload.totalDiscs
|
||||
: deezerTotalDiscs,
|
||||
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
|
||||
deezerId: deezerTrackId,
|
||||
availability: trackToDownload.availability,
|
||||
albumType: trackToDownload.albumType,
|
||||
totalTracks: trackToDownload.totalTracks,
|
||||
totalTracks:
|
||||
(trackToDownload.totalTracks != null &&
|
||||
trackToDownload.totalTracks! > 0)
|
||||
? trackToDownload.totalTracks
|
||||
: deezerTotalTracks,
|
||||
composer: trackToDownload.composer ?? deezerComposer,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
_log.d(
|
||||
@@ -4390,6 +4456,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
metadataEmbeddingEnabled && settings.maxQualityCover,
|
||||
trackNumber: normalizedTrackNumber,
|
||||
discNumber: normalizedDiscNumber,
|
||||
totalTracks: trackToDownload.totalTracks ?? 0,
|
||||
totalDiscs: trackToDownload.totalDiscs ?? 0,
|
||||
releaseDate: trackToDownload.releaseDate ?? '',
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
@@ -4397,6 +4465,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
genre: genre ?? '',
|
||||
label: label ?? '',
|
||||
copyright: copyright ?? '',
|
||||
composer: trackToDownload.composer ?? '',
|
||||
qobuzId: payloadQobuzId,
|
||||
tidalId: payloadTidalId,
|
||||
deezerId: deezerTrackId ?? '',
|
||||
|
||||
@@ -906,9 +906,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
albumType: data['album_type'] as String?,
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -939,10 +941,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
source: effectiveSource,
|
||||
albumType: data['album_type']?.toString(),
|
||||
composer: data['composer']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,9 +299,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
albumType: data['album_type'] as String?,
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -410,9 +410,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
albumType: data['album_type']?.toString() ?? album?.albumType,
|
||||
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
source: data['provider_id']?.toString() ?? widget.extensionId,
|
||||
);
|
||||
}
|
||||
@@ -1129,9 +1131,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
trackNumber:
|
||||
data['track_position'] as int? ?? data['track_number'] as int?,
|
||||
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: album.releaseDate,
|
||||
albumType: album.albumType,
|
||||
totalTracks: album.totalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1840,6 +1840,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
duration: item.durationMs ~/ 1000,
|
||||
trackNumber: null,
|
||||
discNumber: null,
|
||||
totalDiscs: null,
|
||||
isrc: null,
|
||||
releaseDate: item.releaseDate,
|
||||
coverUrl: item.coverUrl,
|
||||
@@ -4403,7 +4404,10 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
source: widget.extensionId,
|
||||
);
|
||||
}
|
||||
@@ -4562,7 +4566,10 @@ class _ExtensionPlaylistScreenState
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
source: widget.extensionId,
|
||||
);
|
||||
}
|
||||
@@ -4739,7 +4746,10 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
source: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1631,7 +1631,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
await localDb.deleteByPath(item.filePath);
|
||||
await localDb.replaceWithConvertedItem(
|
||||
item: item,
|
||||
newFilePath: safUri,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1643,8 +1648,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
} catch (_) {}
|
||||
}
|
||||
} else {
|
||||
// Regular file: just remove old entry, rescan will find the new one
|
||||
await localDb.deleteByPath(item.filePath);
|
||||
await localDb.replaceWithConvertedItem(
|
||||
item: item,
|
||||
newFilePath: newPath,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
}
|
||||
|
||||
successCount++;
|
||||
|
||||
@@ -164,7 +164,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5853,13 +5853,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
||||
? '.opus'
|
||||
: '.mp3';
|
||||
String newExt;
|
||||
String mimeType;
|
||||
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 mimeType = targetFormat.toLowerCase() == 'opus'
|
||||
? 'audio/opus'
|
||||
: 'audio/mpeg';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
@@ -5884,7 +5898,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: item.localItem!,
|
||||
newFilePath: safUri,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -5903,7 +5922,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
} 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++;
|
||||
|
||||
@@ -252,7 +252,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
if (mounted &&
|
||||
exists &&
|
||||
!_isLocalItem &&
|
||||
!_isCueVirtualTrack &&
|
||||
!_hasLoadedResolvedAudioMetadata) {
|
||||
unawaited(_refreshResolvedAudioMetadataFromFile());
|
||||
}
|
||||
@@ -291,8 +291,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _refreshResolvedAudioMetadataFromFile() async {
|
||||
if (_isLocalItem ||
|
||||
_downloadItem == null ||
|
||||
if ((_isLocalItem && _localLibraryItem == null) ||
|
||||
(!_isLocalItem && _downloadItem == null) ||
|
||||
_isCueVirtualTrack ||
|
||||
_hasLoadedResolvedAudioMetadata) {
|
||||
return;
|
||||
}
|
||||
@@ -326,8 +327,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
// Resolve label/copyright from file when the model doesn't carry them
|
||||
// (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 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 =
|
||||
resolvedLabel != null &&
|
||||
resolvedLabel.isNotEmpty &&
|
||||
@@ -338,14 +354,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
(copyright == null || copyright!.isEmpty);
|
||||
|
||||
final shouldPersistResolvedAudioMetadata =
|
||||
resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null);
|
||||
!_isLocalItem &&
|
||||
(resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
|
||||
|
||||
if ((resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
needsAlbum ||
|
||||
needsDuration ||
|
||||
needsTotalTracks ||
|
||||
needsTotalDiscs ||
|
||||
needsComposer ||
|
||||
needsLabel ||
|
||||
needsCopyright ||
|
||||
isPlaceholderQualityLabel(_quality)) &&
|
||||
@@ -359,6 +379,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
|
||||
if (needsAlbum) 'album': resolvedAlbum,
|
||||
if (needsDuration) 'duration': resolvedDuration,
|
||||
if (needsTotalTracks) 'total_tracks': resolvedTotalTracks,
|
||||
if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs,
|
||||
if (needsComposer) 'composer': resolvedComposer,
|
||||
if (needsLabel) 'label': resolvedLabel,
|
||||
if (needsCopyright) 'copyright': resolvedCopyright,
|
||||
};
|
||||
@@ -480,6 +503,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
: _downloadItem!.trackNumber;
|
||||
}
|
||||
|
||||
int? get totalTracks =>
|
||||
_readPositiveInt(_editedMetadata?['total_tracks']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.totalTracks : null);
|
||||
|
||||
int? get discNumber {
|
||||
final edited = _editedMetadata?['disc_number'];
|
||||
if (edited != null) {
|
||||
@@ -491,6 +518,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
: _downloadItem!.discNumber;
|
||||
}
|
||||
|
||||
int? get totalDiscs =>
|
||||
_readPositiveInt(_editedMetadata?['total_discs']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.totalDiscs : null);
|
||||
|
||||
String? get releaseDate =>
|
||||
_editedMetadata?['date']?.toString() ??
|
||||
(_isLocalItem
|
||||
@@ -517,10 +548,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
(_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre);
|
||||
String? get label =>
|
||||
_editedMetadata?['label']?.toString() ??
|
||||
(_isLocalItem ? null : _downloadItem!.label);
|
||||
(_isLocalItem ? _localLibraryItem!.label : _downloadItem!.label);
|
||||
String? get copyright =>
|
||||
_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 =>
|
||||
_readPositiveInt(_editedMetadata?['duration']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
|
||||
@@ -1255,8 +1289,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_MetadataItem(context.l10n.trackAlbum, albumName),
|
||||
if (trackNumber != null && trackNumber! > 0)
|
||||
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
|
||||
if (totalTracks != null && totalTracks! > 0)
|
||||
_MetadataItem('Track Total', totalTracks.toString()),
|
||||
if (discNumber != null && discNumber! > 0)
|
||||
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
|
||||
if (totalDiscs != null && totalDiscs! > 0)
|
||||
_MetadataItem('Disc Total', totalDiscs.toString()),
|
||||
if (duration != null)
|
||||
_MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)),
|
||||
if (audioQualityStr != null)
|
||||
@@ -1269,6 +1307,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_MetadataItem(context.l10n.trackLabel, label!),
|
||||
if (copyright != null && copyright!.isNotEmpty)
|
||||
_MetadataItem(context.l10n.trackCopyright, copyright!),
|
||||
if (composer != null && composer!.isNotEmpty)
|
||||
_MetadataItem('Composer', composer!),
|
||||
if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
|
||||
];
|
||||
|
||||
@@ -2525,12 +2565,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
'album_name': albumName,
|
||||
'album_artist': albumArtist ?? artistName,
|
||||
'track_number': trackNumber ?? 0,
|
||||
'total_tracks': totalTracks ?? 0,
|
||||
'disc_number': discNumber ?? 0,
|
||||
'total_discs': totalDiscs ?? 0,
|
||||
'release_date': releaseDate ?? '',
|
||||
'isrc': isrc ?? '',
|
||||
'genre': genre ?? '',
|
||||
'label': label ?? '',
|
||||
'copyright': copyright ?? '',
|
||||
'composer': composer ?? '',
|
||||
'duration_ms': durationMs,
|
||||
'search_online': true,
|
||||
};
|
||||
@@ -2548,11 +2591,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
'album_artist': enriched['album_artist'] ?? albumArtist,
|
||||
'date': enriched['release_date'] ?? releaseDate,
|
||||
'track_number': enriched['track_number'] ?? trackNumber,
|
||||
'total_tracks': enriched['total_tracks'] ?? totalTracks,
|
||||
'disc_number': enriched['disc_number'] ?? discNumber,
|
||||
'total_discs': enriched['total_discs'] ?? totalDiscs,
|
||||
'isrc': enriched['isrc'] ?? isrc,
|
||||
'genre': enriched['genre'] ?? genre,
|
||||
'label': enriched['label'] ?? label,
|
||||
'copyright': enriched['copyright'] ?? copyright,
|
||||
'composer': enriched['composer'] ?? composer,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2989,19 +3035,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Map<String, String> _buildFallbackMetadata() {
|
||||
String formatIndexTag(int number, int? total) {
|
||||
if (total != null && total > 0) {
|
||||
return '$number/$total';
|
||||
}
|
||||
return number.toString();
|
||||
}
|
||||
|
||||
return {
|
||||
'TITLE': trackName,
|
||||
'ARTIST': artistName,
|
||||
'ALBUM': albumName,
|
||||
if (albumArtist != null && albumArtist!.isNotEmpty)
|
||||
'ALBUMARTIST': albumArtist!,
|
||||
if (trackNumber != null) 'TRACKNUMBER': trackNumber.toString(),
|
||||
if (discNumber != null) 'DISCNUMBER': discNumber.toString(),
|
||||
if (trackNumber != null)
|
||||
'TRACKNUMBER': formatIndexTag(trackNumber!, totalTracks),
|
||||
if (discNumber != null)
|
||||
'DISCNUMBER': formatIndexTag(discNumber!, totalDiscs),
|
||||
if (releaseDate != null && releaseDate!.isNotEmpty) 'DATE': releaseDate!,
|
||||
if (isrc != null && isrc!.isNotEmpty) 'ISRC': isrc!,
|
||||
if (genre != null && genre!.isNotEmpty) 'GENRE': genre!,
|
||||
if (label != null && label!.isNotEmpty) 'LABEL': label!,
|
||||
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']);
|
||||
|
||||
final trackNumber = source['track_number'];
|
||||
final totalTracks = source['total_tracks'];
|
||||
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 totalDiscs = source['total_discs'];
|
||||
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;
|
||||
@@ -3859,8 +3929,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate);
|
||||
|
||||
if (isSaf) {
|
||||
final treeUri = _downloadItem?.downloadTreeUri;
|
||||
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
||||
String? treeUri;
|
||||
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) {
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
@@ -3879,11 +3991,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldFileName =
|
||||
(_downloadItem?.safFileName != null &&
|
||||
_downloadItem!.safFileName!.isNotEmpty)
|
||||
? _downloadItem!.safFileName!
|
||||
: _extractFileNameFromPathOrUri(cleanFilePath);
|
||||
final dotIdx = oldFileName.lastIndexOf('.');
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
@@ -3952,6 +4059,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
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 {
|
||||
@@ -3971,6 +4086,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
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),
|
||||
'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '')
|
||||
.toString(),
|
||||
'total_tracks': (fileMetadata?['total_tracks'] ?? totalTracks ?? '')
|
||||
.toString(),
|
||||
'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '')
|
||||
.toString(),
|
||||
'total_discs': (fileMetadata?['total_discs'] ?? totalDiscs ?? '')
|
||||
.toString(),
|
||||
'genre': val('genre', genre),
|
||||
'isrc': val('isrc', isrc),
|
||||
'label': val('label', label),
|
||||
'copyright': val('copyright', copyright),
|
||||
'composer': fileMetadata?['composer']?.toString() ?? '',
|
||||
'composer': val('composer', composer),
|
||||
'comment': fileMetadata?['comment']?.toString() ?? '',
|
||||
};
|
||||
|
||||
@@ -4314,11 +4441,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'album_artist': 'album_artist',
|
||||
'date': 'date',
|
||||
'track_number': 'track_number',
|
||||
'total_tracks': 'total_tracks',
|
||||
'disc_number': 'disc_number',
|
||||
'total_discs': 'total_discs',
|
||||
'genre': 'genre',
|
||||
'isrc': 'isrc',
|
||||
'label': 'label',
|
||||
'copyright': 'copyright',
|
||||
'composer': 'composer',
|
||||
'cover': 'cover',
|
||||
};
|
||||
|
||||
@@ -4328,7 +4458,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
late final TextEditingController _albumArtistCtrl;
|
||||
late final TextEditingController _dateCtrl;
|
||||
late final TextEditingController _trackNumCtrl;
|
||||
late final TextEditingController _trackTotalCtrl;
|
||||
late final TextEditingController _discNumCtrl;
|
||||
late final TextEditingController _discTotalCtrl;
|
||||
late final TextEditingController _genreCtrl;
|
||||
late final TextEditingController _isrcCtrl;
|
||||
late final TextEditingController _labelCtrl;
|
||||
@@ -4516,8 +4648,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return l10n.editMetadataFieldDate;
|
||||
case 'track_number':
|
||||
return l10n.editMetadataFieldTrackNum;
|
||||
case 'total_tracks':
|
||||
return 'Track Total';
|
||||
case 'disc_number':
|
||||
return l10n.editMetadataFieldDiscNum;
|
||||
case 'total_discs':
|
||||
return 'Disc Total';
|
||||
case 'genre':
|
||||
return l10n.editMetadataFieldGenre;
|
||||
case 'isrc':
|
||||
@@ -4526,6 +4662,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return l10n.editMetadataFieldLabel;
|
||||
case 'copyright':
|
||||
return l10n.editMetadataFieldCopyright;
|
||||
case 'composer':
|
||||
return 'Composer';
|
||||
case 'cover':
|
||||
return l10n.editMetadataFieldCover;
|
||||
default:
|
||||
@@ -4547,8 +4685,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return _dateCtrl;
|
||||
case 'track_number':
|
||||
return _trackNumCtrl;
|
||||
case 'total_tracks':
|
||||
return _trackTotalCtrl;
|
||||
case 'disc_number':
|
||||
return _discNumCtrl;
|
||||
case 'total_discs':
|
||||
return _discTotalCtrl;
|
||||
case 'genre':
|
||||
return _genreCtrl;
|
||||
case 'isrc':
|
||||
@@ -4557,6 +4699,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return _labelCtrl;
|
||||
case 'copyright':
|
||||
return _copyrightCtrl;
|
||||
case 'composer':
|
||||
return _composerCtrl;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -4722,11 +4866,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
put('album_artist', track['album_artist']);
|
||||
put('date', track['release_date']);
|
||||
put('track_number', track['track_number']);
|
||||
put('total_tracks', track['total_tracks']);
|
||||
put('disc_number', track['disc_number']);
|
||||
put('total_discs', track['total_discs']);
|
||||
put('isrc', track['isrc']);
|
||||
put('genre', track['genre']);
|
||||
put('label', track['label']);
|
||||
put('copyright', track['copyright']);
|
||||
put('composer', track['composer']);
|
||||
}
|
||||
|
||||
Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers(
|
||||
@@ -4927,8 +5074,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
|
||||
'date': (selectedBest['release_date'] ?? '').toString(),
|
||||
'track_number': (selectedBest['track_number'] ?? '').toString(),
|
||||
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
|
||||
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
|
||||
'total_discs': (selectedBest['total_discs'] ?? '').toString(),
|
||||
'isrc': (selectedBest['isrc'] ?? '').toString(),
|
||||
'composer': (selectedBest['composer'] ?? '').toString(),
|
||||
};
|
||||
_mergeOnlineTrackData(enriched, selectedBest);
|
||||
|
||||
@@ -4937,7 +5087,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
final needsExtended =
|
||||
_autoFillFields.contains('genre') ||
|
||||
_autoFillFields.contains('label') ||
|
||||
_autoFillFields.contains('copyright');
|
||||
_autoFillFields.contains('copyright') ||
|
||||
_autoFillFields.contains('composer');
|
||||
|
||||
final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest);
|
||||
|
||||
@@ -5099,7 +5250,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
_albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? '');
|
||||
_dateCtrl = TextEditingController(text: v['date'] ?? '');
|
||||
_trackNumCtrl = TextEditingController(text: v['track_number'] ?? '');
|
||||
_trackTotalCtrl = TextEditingController(text: v['total_tracks'] ?? '');
|
||||
_discNumCtrl = TextEditingController(text: v['disc_number'] ?? '');
|
||||
_discTotalCtrl = TextEditingController(text: v['total_discs'] ?? '');
|
||||
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
|
||||
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
|
||||
_labelCtrl = TextEditingController(text: v['label'] ?? '');
|
||||
@@ -5119,7 +5272,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
_albumArtistCtrl.dispose();
|
||||
_dateCtrl.dispose();
|
||||
_trackNumCtrl.dispose();
|
||||
_trackTotalCtrl.dispose();
|
||||
_discNumCtrl.dispose();
|
||||
_discTotalCtrl.dispose();
|
||||
_genreCtrl.dispose();
|
||||
_isrcCtrl.dispose();
|
||||
_labelCtrl.dispose();
|
||||
@@ -5139,7 +5294,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'album_artist': _albumArtistCtrl.text,
|
||||
'date': _dateCtrl.text,
|
||||
'track_number': _trackNumCtrl.text,
|
||||
'track_total': _trackTotalCtrl.text,
|
||||
'disc_number': _discNumCtrl.text,
|
||||
'disc_total': _discTotalCtrl.text,
|
||||
'genre': _genreCtrl.text,
|
||||
'isrc': _isrcCtrl.text,
|
||||
'label': _labelCtrl.text,
|
||||
@@ -5191,12 +5348,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
'TRACKNUMBER':
|
||||
(metadata['track_number']?.isNotEmpty == true &&
|
||||
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':
|
||||
(metadata['disc_number']?.isNotEmpty == true &&
|
||||
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'] ?? '',
|
||||
'ISRC': metadata['isrc'] ?? '',
|
||||
@@ -5409,6 +5572,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
'Track Total',
|
||||
_trackTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
'Disc #',
|
||||
@@ -5416,6 +5591,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
'Disc Total',
|
||||
_discTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_field('Genre', _genreCtrl),
|
||||
|
||||
@@ -17,6 +17,7 @@ class DownloadRequestPayload {
|
||||
final int trackNumber;
|
||||
final int discNumber;
|
||||
final int totalTracks;
|
||||
final int totalDiscs;
|
||||
final String releaseDate;
|
||||
final String itemId;
|
||||
final int durationMs;
|
||||
@@ -24,6 +25,7 @@ class DownloadRequestPayload {
|
||||
final String genre;
|
||||
final String label;
|
||||
final String copyright;
|
||||
final String composer;
|
||||
final String tidalId;
|
||||
final String qobuzId;
|
||||
final String deezerId;
|
||||
@@ -56,6 +58,7 @@ class DownloadRequestPayload {
|
||||
this.trackNumber = 0,
|
||||
this.discNumber = 0,
|
||||
this.totalTracks = 1,
|
||||
this.totalDiscs = 0,
|
||||
this.releaseDate = '',
|
||||
this.itemId = '',
|
||||
this.durationMs = 0,
|
||||
@@ -63,6 +66,7 @@ class DownloadRequestPayload {
|
||||
this.genre = '',
|
||||
this.label = '',
|
||||
this.copyright = '',
|
||||
this.composer = '',
|
||||
this.tidalId = '',
|
||||
this.qobuzId = '',
|
||||
this.deezerId = '',
|
||||
@@ -97,6 +101,7 @@ class DownloadRequestPayload {
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
'total_discs': totalDiscs,
|
||||
'release_date': releaseDate,
|
||||
'item_id': itemId,
|
||||
'duration_ms': durationMs,
|
||||
@@ -104,6 +109,7 @@ class DownloadRequestPayload {
|
||||
'genre': genre,
|
||||
'label': label,
|
||||
'copyright': copyright,
|
||||
'composer': composer,
|
||||
'tidal_id': tidalId,
|
||||
'qobuz_id': qobuzId,
|
||||
'deezer_id': deezerId,
|
||||
@@ -142,6 +148,7 @@ class DownloadRequestPayload {
|
||||
trackNumber: trackNumber,
|
||||
discNumber: discNumber,
|
||||
totalTracks: totalTracks,
|
||||
totalDiscs: totalDiscs,
|
||||
releaseDate: releaseDate,
|
||||
itemId: itemId,
|
||||
durationMs: durationMs,
|
||||
@@ -149,6 +156,7 @@ class DownloadRequestPayload {
|
||||
genre: genre,
|
||||
label: label,
|
||||
copyright: copyright,
|
||||
composer: composer,
|
||||
tidalId: tidalId,
|
||||
qobuzId: qobuzId,
|
||||
deezerId: deezerId,
|
||||
|
||||
@@ -1311,28 +1311,38 @@ class FFmpegService {
|
||||
cmdBuffer.write('-v error -hide_banner ');
|
||||
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) {
|
||||
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(
|
||||
preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ',
|
||||
);
|
||||
|
||||
// For M4A/MP4, cover art is mapped as a video stream and stored in the
|
||||
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
|
||||
// flag is only valid for Matroska/WebM containers and must NOT be used here.
|
||||
// Force the mp4 muxer when cover art is present because the default ipod
|
||||
// muxer (auto-selected for .m4a) does not register a codec tag for mjpeg,
|
||||
// causing "codec not currently supported in container" on FFmpeg 8.0+.
|
||||
// For M4A cover replacements, mark the image as an attached picture so the
|
||||
// mp4 muxer writes a proper covr atom instead of a generic MJPEG video track.
|
||||
// Force the mp4 muxer because the default ipod muxer (auto-selected for .m4a)
|
||||
// does not register a codec tag for mjpeg on FFmpeg 8.0+.
|
||||
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) {
|
||||
final m4aMetadata = _convertToM4aTags(metadata);
|
||||
for (final entry in m4aMetadata.entries) {
|
||||
@@ -1760,9 +1770,13 @@ class FFmpegService {
|
||||
if (value != '0') vorbis['DISCNUMBER'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
case 'YEAR':
|
||||
vorbis['DATE'] = value;
|
||||
break;
|
||||
case 'YEAR':
|
||||
if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) {
|
||||
vorbis['DATE'] = value;
|
||||
}
|
||||
break;
|
||||
case 'GENRE':
|
||||
vorbis['GENRE'] = value;
|
||||
break;
|
||||
@@ -1921,9 +1935,13 @@ class FFmpegService {
|
||||
m4aMap['disc'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
case 'YEAR':
|
||||
m4aMap['date'] = value;
|
||||
break;
|
||||
case 'YEAR':
|
||||
if (!m4aMap.containsKey('date') || m4aMap['date']!.isEmpty) {
|
||||
m4aMap['date'] = value;
|
||||
}
|
||||
break;
|
||||
case 'GENRE':
|
||||
m4aMap['genre'] = value;
|
||||
break;
|
||||
@@ -2004,9 +2022,13 @@ class FFmpegService {
|
||||
}
|
||||
break;
|
||||
case 'DATE':
|
||||
case 'YEAR':
|
||||
id3Map['date'] = value;
|
||||
break;
|
||||
case 'YEAR':
|
||||
if (!id3Map.containsKey('date') || id3Map['date']!.isEmpty) {
|
||||
id3Map['date'] = value;
|
||||
}
|
||||
break;
|
||||
case 'ISRC':
|
||||
id3Map['TSRC'] = value;
|
||||
break;
|
||||
|
||||
@@ -20,13 +20,18 @@ class LocalLibraryItem {
|
||||
final int? fileModTime;
|
||||
final String? isrc;
|
||||
final int? trackNumber;
|
||||
final int? totalTracks;
|
||||
final int? discNumber;
|
||||
final int? totalDiscs;
|
||||
final int? duration;
|
||||
final String? releaseDate;
|
||||
final int? bitDepth;
|
||||
final int? sampleRate;
|
||||
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
|
||||
final String? genre;
|
||||
final String? composer;
|
||||
final String? label;
|
||||
final String? copyright;
|
||||
final String? format; // flac, mp3, opus, m4a
|
||||
|
||||
const LocalLibraryItem({
|
||||
@@ -41,13 +46,18 @@ class LocalLibraryItem {
|
||||
this.fileModTime,
|
||||
this.isrc,
|
||||
this.trackNumber,
|
||||
this.totalTracks,
|
||||
this.discNumber,
|
||||
this.totalDiscs,
|
||||
this.duration,
|
||||
this.releaseDate,
|
||||
this.bitDepth,
|
||||
this.sampleRate,
|
||||
this.bitrate,
|
||||
this.genre,
|
||||
this.composer,
|
||||
this.label,
|
||||
this.copyright,
|
||||
this.format,
|
||||
});
|
||||
|
||||
@@ -63,13 +73,18 @@ class LocalLibraryItem {
|
||||
'fileModTime': fileModTime,
|
||||
'isrc': isrc,
|
||||
'trackNumber': trackNumber,
|
||||
'totalTracks': totalTracks,
|
||||
'discNumber': discNumber,
|
||||
'totalDiscs': totalDiscs,
|
||||
'duration': duration,
|
||||
'releaseDate': releaseDate,
|
||||
'bitDepth': bitDepth,
|
||||
'sampleRate': sampleRate,
|
||||
'bitrate': bitrate,
|
||||
'genre': genre,
|
||||
'composer': composer,
|
||||
'label': label,
|
||||
'copyright': copyright,
|
||||
'format': format,
|
||||
};
|
||||
|
||||
@@ -85,14 +100,19 @@ class LocalLibraryItem {
|
||||
scannedAt: DateTime.parse(json['scannedAt'] as String),
|
||||
fileModTime: (json['fileModTime'] as num?)?.toInt(),
|
||||
isrc: json['isrc'] as String?,
|
||||
trackNumber: json['trackNumber'] as int?,
|
||||
discNumber: json['discNumber'] as int?,
|
||||
duration: json['duration'] as int?,
|
||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
bitDepth: json['bitDepth'] as int?,
|
||||
sampleRate: json['sampleRate'] as int?,
|
||||
bitDepth: (json['bitDepth'] as num?)?.toInt(),
|
||||
sampleRate: (json['sampleRate'] as num?)?.toInt(),
|
||||
bitrate: (json['bitrate'] as num?)?.toInt(),
|
||||
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?,
|
||||
);
|
||||
|
||||
@@ -122,7 +142,7 @@ class LibraryDatabase {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 4,
|
||||
version: 6,
|
||||
onConfigure: (db) async {
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
await db.execute('PRAGMA synchronous = NORMAL');
|
||||
@@ -148,13 +168,18 @@ class LibraryDatabase {
|
||||
file_mod_time INTEGER,
|
||||
isrc TEXT,
|
||||
track_number INTEGER,
|
||||
total_tracks INTEGER,
|
||||
disc_number INTEGER,
|
||||
total_discs INTEGER,
|
||||
duration INTEGER,
|
||||
release_date TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
bitrate INTEGER,
|
||||
genre TEXT,
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT,
|
||||
format TEXT
|
||||
)
|
||||
''');
|
||||
@@ -190,6 +215,19 @@ class LibraryDatabase {
|
||||
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
|
||||
_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) {
|
||||
@@ -205,13 +243,18 @@ class LibraryDatabase {
|
||||
'file_mod_time': json['fileModTime'],
|
||||
'isrc': json['isrc'],
|
||||
'track_number': json['trackNumber'],
|
||||
'total_tracks': json['totalTracks'],
|
||||
'disc_number': json['discNumber'],
|
||||
'total_discs': json['totalDiscs'],
|
||||
'duration': json['duration'],
|
||||
'release_date': json['releaseDate'],
|
||||
'bit_depth': json['bitDepth'],
|
||||
'sample_rate': json['sampleRate'],
|
||||
'bitrate': json['bitrate'],
|
||||
'genre': json['genre'],
|
||||
'composer': json['composer'],
|
||||
'label': json['label'],
|
||||
'copyright': json['copyright'],
|
||||
'format': json['format'],
|
||||
};
|
||||
}
|
||||
@@ -229,13 +272,18 @@ class LibraryDatabase {
|
||||
'fileModTime': row['file_mod_time'],
|
||||
'isrc': row['isrc'],
|
||||
'trackNumber': row['track_number'],
|
||||
'totalTracks': row['total_tracks'],
|
||||
'discNumber': row['disc_number'],
|
||||
'totalDiscs': row['total_discs'],
|
||||
'duration': row['duration'],
|
||||
'releaseDate': row['release_date'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'bitrate': row['bitrate'],
|
||||
'genre': row['genre'],
|
||||
'composer': row['composer'],
|
||||
'label': row['label'],
|
||||
'copyright': row['copyright'],
|
||||
'format': row['format'],
|
||||
};
|
||||
}
|
||||
@@ -383,6 +431,45 @@ class LibraryDatabase {
|
||||
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 {
|
||||
final db = await database;
|
||||
await db.delete('library', where: 'id = ?', whereArgs: [id]);
|
||||
@@ -554,4 +641,43 @@ class LibraryDatabase {
|
||||
}
|
||||
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(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
source: data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
|
||||
@@ -100,12 +100,28 @@ void mergePlatformMetadataForTagEmbed({
|
||||
put('UNSYNCEDLYRICS', source['lyrics']);
|
||||
|
||||
final trackNumber = source['track_number'];
|
||||
final totalTracks = source['total_tracks'];
|
||||
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 totalDiscs = source['total_discs'];
|
||||
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
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||
publish_to: "none"
|
||||
version: 4.2.0+121
|
||||
version: 4.2.1+122
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user