refactor: remove legacy API clients, Yoinkify fallback, and unused lyrics provider

- Delete dead metadata client and extract shared types to metadata_types.go
- Remove Yoinkify download fallback from Deezer, use MusicDL only
- Clean up retired settings fields and metadataSource
- Remove dead l10n keys for retired provider
- Add migration to strip retired provider from existing users' lyrics config
This commit is contained in:
zarzet
2026-03-30 23:23:24 +07:00
parent dd750b95ca
commit 93e77aeb84
27 changed files with 198 additions and 1679 deletions
+19 -180
View File
@@ -14,15 +14,8 @@ import (
"strings"
)
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
type YoinkifyRequest struct {
URL string `json:"url"`
Format string `json:"format"`
GenreSource string `json:"genreSource"`
}
type DeezerDownloadResult struct {
FilePath string
BitDepth int
@@ -37,41 +30,6 @@ type DeezerDownloadResult struct {
LyricsLRC string
}
func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) {
rawSpotify := strings.TrimSpace(req.SpotifyID)
if rawSpotify != "" {
if isLikelySpotifyTrackID(rawSpotify) {
return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil
}
if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" {
return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil
}
}
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found {
deezerID = strings.TrimSpace(prefixed)
}
}
if deezerID != "" {
songlink := NewSongLinkClient()
spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID)
if err != nil {
return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err)
}
spotifyID = strings.TrimSpace(spotifyID)
if spotifyID == "" {
return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID)
}
return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil
}
return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify")
}
func isLikelySpotifyTrackID(value string) bool {
if len(value) != 22 {
return false
@@ -88,113 +46,6 @@ func isLikelySpotifyTrackID(value string) bool {
return true
}
func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error {
payload := YoinkifyRequest{
URL: spotifyURL,
Format: "flac",
GenreSource: "spotify",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to encode Yoinkify request: %w", err)
}
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create Yoinkify request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("failed to call Yoinkify: %w", err)
}
defer resp.Body.Close()
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
bodyText := strings.TrimSpace(string(bodyBytes))
if bodyText != "" {
return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText)
}
return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode)
}
if strings.Contains(contentType, "application/json") {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
bodyText := strings.TrimSpace(string(bodyBytes))
if bodyText == "" {
bodyText = "empty JSON payload"
}
return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush output: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close output: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024))
return nil
}
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
@@ -479,41 +330,29 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
)
}()
// Try MusicDL first (better quality), fallback to Yoinkify
var downloadErr error
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
if deezerURLErr == nil {
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr)
}
} else {
GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr)
if deezerURLErr != nil {
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed: could not resolve Deezer URL: %w",
deezerURLErr,
)
}
if downloadErr != nil || deezerURLErr != nil {
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
if err != nil {
if deezerURLErr != nil {
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
deezerURLErr,
err,
)
}
return DeezerDownloadResult{}, err
}
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
downloadErr := deezerClient.DownloadFromMusicDL(
deezerTrackURL,
outputPath,
req.OutputFD,
req.ItemID,
)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed via MusicDL: %w",
downloadErr,
)
}
<-parallelDone
+12 -233
View File
@@ -3,7 +3,6 @@ package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
@@ -23,7 +22,6 @@ const (
// Lyrics provider names (used in settings and cascade ordering)
const (
LyricsProviderSpotifyAPI = "spotify_api"
LyricsProviderLRCLIB = "lrclib"
LyricsProviderNetease = "netease"
LyricsProviderMusixmatch = "musixmatch"
@@ -35,7 +33,6 @@ const (
// LRCLIB first (no proxy dependency), then the others.
var DefaultLyricsProviders = []string{
LyricsProviderLRCLIB,
LyricsProviderSpotifyAPI,
LyricsProviderMusixmatch,
LyricsProviderNetease,
LyricsProviderAppleMusic,
@@ -47,11 +44,6 @@ var (
lyricsProviders []string // ordered list of enabled providers
)
var (
spotifyLyricsRateLimitMu sync.RWMutex
spotifyLyricsRateLimitedTil time.Time
)
// LyricsFetchOptions controls optional provider-specific enhancements.
type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
@@ -84,7 +76,6 @@ func SetLyricsProviderOrder(providers []string) {
}
validNames := map[string]bool{
LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true,
LyricsProviderNetease: true,
LyricsProviderMusixmatch: true,
@@ -119,7 +110,6 @@ func GetLyricsProviderOrder() []string {
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
@@ -249,18 +239,6 @@ type LRCLibResponse struct {
SyncedLyrics string `json:"syncedLyrics"`
}
type SpotifyLyricsLine struct {
TimeTag string `json:"timeTag"`
Words string `json:"words"`
}
type SpotifyLyricsAPIResponse struct {
Error bool `json:"error"`
Message string `json:"message"`
SyncType string `json:"syncType"`
Lines []SpotifyLyricsLine `json:"lines"`
}
type LyricsLine struct {
StartTimeMs int64 `json:"startTimeMs"`
Words string `json:"words"`
@@ -368,214 +346,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return c.parseLRCLibResponse(&results[0]), nil
}
func parseSpotifyLyricsTimeTagToMs(tag string) int64 {
raw := strings.TrimSpace(tag)
raw = strings.TrimPrefix(raw, "[")
raw = strings.TrimSuffix(raw, "]")
if raw == "" {
return 0
}
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
return ms
}
re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`)
matches := re.FindStringSubmatch(raw)
if len(matches) != 4 {
return 0
}
minutes, _ := strconv.ParseInt(matches[1], 10, 64)
seconds, _ := strconv.ParseInt(matches[2], 10, 64)
fraction := matches[3]
fractionInt, _ := strconv.ParseInt(fraction, 10, 64)
if len(fraction) == 2 {
fractionInt *= 10
} else if len(fraction) == 1 {
fractionInt *= 100
}
return minutes*60*1000 + seconds*1000 + fractionInt
}
func getSpotifyLyricsRateLimitUntil() time.Time {
spotifyLyricsRateLimitMu.RLock()
defer spotifyLyricsRateLimitMu.RUnlock()
return spotifyLyricsRateLimitedTil
}
func setSpotifyLyricsRateLimitUntil(until time.Time) {
spotifyLyricsRateLimitMu.Lock()
spotifyLyricsRateLimitedTil = until
spotifyLyricsRateLimitMu.Unlock()
}
func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
raw := strings.TrimSpace(retryAfter)
if raw == "" {
return now.Add(10 * time.Minute)
}
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
return now.Add(time.Duration(sec) * time.Second)
}
if when, err := http.ParseTime(raw); err == nil && when.After(now) {
return when
}
return now.Add(10 * time.Minute)
}
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
if len(lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if syncType == "" {
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
syncType = "LINE_SYNCED"
} else {
syncType = "UNSYNCED"
}
}
return &LyricsResponse{
Lines: lines,
SyncType: syncType,
Instrumental: false,
PlainLyrics: plainLyrics,
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}, nil
}
func plainLyricsFromTimedLines(lines []LyricsLine) string {
parts := make([]string, 0, len(lines))
for _, line := range lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
parts = append(parts, words)
}
return strings.Join(parts, "\n")
}
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
var lrcPayload string
if err := json.Unmarshal(body, &lrcPayload); err == nil {
trimmed := strings.TrimSpace(lrcPayload)
if trimmed == "" {
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
}
lines := parseSyncedLyrics(trimmed)
if len(lines) > 0 {
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
}
plainLines := plainTextLyricsLines(trimmed)
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
lines := make([]LyricsLine, 0, len(apiResp.Lines))
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
lines = append(lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
for i := 0; i < len(lines)-1; i++ {
nextStart := lines[i+1].StartTimeMs
if nextStart > lines[i].StartTimeMs {
lines[i].EndTimeMs = nextStart
}
}
if len(lines) > 0 {
last := len(lines) - 1
if lines[last].EndTimeMs == 0 {
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
}
}
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
}
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
now := time.Now()
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds()))
return nil, fmt.Errorf(
"Spotify Lyrics API cooldown active (%ds remaining after previous 429)",
waitFor,
)
}
spotifyID = strings.TrimSpace(spotifyID)
if spotifyID == "" {
return nil, fmt.Errorf("spotify ID is empty")
}
if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" {
spotifyID = parsed.ID
}
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
}
if resp.StatusCode != 200 {
if resp.StatusCode == http.StatusTooManyRequests {
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
setSpotifyLyricsRateLimitUntil(retryUntil)
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
}
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
}
return parseSpotifyLyricsResponseBody(bodyBytes)
}
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
@@ -600,6 +370,18 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
return bestPlain
}
func plainLyricsFromTimedLines(lines []LyricsLine) string {
parts := make([]string, 0, len(lines))
for _, line := range lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
parts = append(parts, words)
}
return strings.Join(parts, "\n")
}
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec
@@ -669,9 +451,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var err error
switch providerName {
case LyricsProviderSpotifyAPI:
lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID)
case LyricsProviderLRCLIB:
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
+145
View File
@@ -0,0 +1,145 @@
package gobackend
import "time"
type cacheEntry struct {
data interface{}
expiresAt time.Time
}
func (e *cacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt)
}
type TrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,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"`
}
type AlbumTrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,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"`
}
type AlbumInfoMetadata struct {
TotalTracks int `json:"total_tracks"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
ArtistId string `json:"artist_id,omitempty"`
Images string `json:"images"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
}
type AlbumResponsePayload struct {
AlbumInfo AlbumInfoMetadata `json:"album_info"`
TrackList []AlbumTrackMetadata `json:"track_list"`
}
type PlaylistInfoMetadata struct {
Name string `json:"name,omitempty"`
Images string `json:"images,omitempty"`
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
Owner struct {
DisplayName string `json:"display_name"`
Name string `json:"name"`
Images string `json:"images"`
} `json:"owner"`
}
type PlaylistResponsePayload struct {
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
TrackList []AlbumTrackMetadata `json:"track_list"`
}
type ArtistInfoMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Images string `json:"images"`
Followers int `json:"followers"`
Popularity int `json:"popularity"`
}
type ArtistAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
TotalTracks int `json:"total_tracks"`
Images string `json:"images"`
AlbumType string `json:"album_type"`
Artists string `json:"artists"`
}
type ArtistResponsePayload struct {
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
Albums []ArtistAlbumMetadata `json:"albums"`
}
type TrackResponse struct {
Track TrackMetadata `json:"track"`
}
type SearchArtistResult struct {
ID string `json:"id"`
Name string `json:"name"`
Images string `json:"images"`
Followers int `json:"followers"`
Popularity int `json:"popularity"`
}
type SearchAlbumResult struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type"`
}
type SearchPlaylistResult struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Images string `json:"images"`
TotalTracks int `json:"total_tracks"`
}
type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"`
Albums []SearchAlbumResult `json:"albums"`
Playlists []SearchPlaylistResult `json:"playlists"`
}
File diff suppressed because it is too large Load Diff
-6
View File
@@ -4647,12 +4647,6 @@ abstract class AppLocalizations {
/// **'You have unsaved changes that will be lost.'**
String get lyricsProvidersDiscardContent;
/// Description for Spotify Lyrics API provider
///
/// In en, this message translates to:
/// **'Spotify-sourced synced lyrics via community API'**
String get lyricsProviderSpotifyApiDesc;
/// Description for LRCLIB provider
///
/// In en, this message translates to:
-4
View File
@@ -2706,10 +2706,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2674,10 +2674,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2674,10 +2674,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2675,10 +2675,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2673,10 +2673,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2684,10 +2684,6 @@ class AppLocalizationsId extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2660,10 +2660,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2653,10 +2653,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2673,10 +2673,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2674,10 +2674,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2733,10 +2733,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2679,10 +2679,6 @@ class AppLocalizationsTr extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -2674,10 +2674,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
-4
View File
@@ -3551,10 +3551,6 @@
"@lyricsProvidersDiscardContent": {
"description": "Body text of the discard-changes dialog on lyrics provider page"
},
"lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
"@lyricsProviderSpotifyApiDesc": {
"description": "Description for Spotify Lyrics API provider"
},
"lyricsProviderLrclibDesc": "Open-source synced lyrics database",
"@lyricsProviderLrclibDesc": {
"description": "Description for LRCLIB provider"
-18
View File
@@ -30,10 +30,6 @@ class AppSettings {
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
final String spotifyClientId;
final String spotifyClientSecret;
final bool useCustomSpotifyCredentials;
final String metadataSource;
final bool enableLogging;
final bool useExtensionProviders;
final String? searchProvider;
@@ -107,10 +103,6 @@ class AppSettings {
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
this.spotifyClientId = '',
this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = false,
this.metadataSource = 'deezer',
this.enableLogging = false,
this.useExtensionProviders = true,
this.searchProvider,
@@ -134,7 +126,6 @@ class AppSettings {
this.hasCompletedTutorial = false,
this.lyricsProviders = const [
'lrclib',
'spotify_api',
'musixmatch',
'netease',
'apple_music',
@@ -172,10 +163,6 @@ class AppSettings {
String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload,
String? spotifyClientId,
String? spotifyClientSecret,
bool? useCustomSpotifyCredentials,
String? metadataSource,
bool? enableLogging,
bool? useExtensionProviders,
String? searchProvider,
@@ -235,11 +222,6 @@ class AppSettings {
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload:
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials:
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders,
+1 -17
View File
@@ -32,11 +32,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? false,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
@@ -65,14 +60,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
(json['lyricsProviders'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [
'lrclib',
'spotify_api',
'musixmatch',
'netease',
'apple_music',
'qqmusic',
],
const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
lyricsIncludeTranslationNetease:
json['lyricsIncludeTranslationNetease'] as bool? ?? false,
lyricsIncludeRomanizationNetease:
@@ -111,10 +99,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret,
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
'metadataSource': instance.metadataSource,
'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider,
+1 -2
View File
@@ -662,9 +662,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
if (settings.searchProvider == extensionId) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
_log.d(
'Cleared search provider and reset to Deezer because extension $extensionId was disabled',
'Cleared search provider because extension $extensionId was disabled',
);
}
+17 -51
View File
@@ -12,7 +12,7 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 7;
const _currentMigrationVersion = 9;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
@@ -44,7 +44,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _normalizeSongLinkRegionIfNeeded();
}
await _retireBuiltInSpotifyProvider();
await _cleanupRetiredSpotifySettings();
LogBuffer.loggingEnabled = state.enableLogging;
@@ -86,13 +86,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) {
if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer');
await _saveSettings();
}
}
if (lastMigration < _currentMigrationVersion) {
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
state = state.copyWith(storageMode: 'saf');
@@ -101,26 +94,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
state = state.copyWith(hasCompletedTutorial: true);
}
// Migration 4: include Spotify Lyrics API in provider order for existing users
if (!state.lyricsProviders.contains('spotify_api')) {
final updatedProviders = List<String>.from(state.lyricsProviders);
final lrclibIndex = updatedProviders.indexOf('lrclib');
if (lrclibIndex >= 0) {
updatedProviders.insert(lrclibIndex + 1, 'spotify_api');
} else {
updatedProviders.add('spotify_api');
}
state = state.copyWith(lyricsProviders: updatedProviders);
}
if (state.metadataSource != 'deezer' ||
state.spotifyClientId.isNotEmpty ||
state.spotifyClientSecret.isNotEmpty ||
state.useCustomSpotifyCredentials) {
if (state.lyricsProviders.contains('spotify_api')) {
final updatedProviders = state.lyricsProviders
.where((provider) => provider != 'spotify_api')
.toList();
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
lyricsProviders: updatedProviders.isEmpty
? const [
'lrclib',
'musixmatch',
'netease',
'apple_music',
'qqmusic',
]
: updatedProviders,
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
@@ -134,8 +121,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
Future<void> _saveSettings() async {
final settingsToSave = state.copyWith(spotifyClientSecret: '');
_pendingSettingsJson = jsonEncode(settingsToSave.toJson());
_pendingSettingsJson = jsonEncode(state.toJson());
if (_isSavingSettings) {
_saveQueued = true;
@@ -186,28 +172,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
Future<void> _retireBuiltInSpotifyProvider() async {
Future<void> _cleanupRetiredSpotifySettings() async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
if (storedSecret != null && storedSecret.isNotEmpty) {
await _secureStorage.delete(key: _spotifyClientSecretKey);
}
if (state.metadataSource == 'deezer' &&
state.spotifyClientId.isEmpty &&
state.spotifyClientSecret.isEmpty &&
!state.useCustomSpotifyCredentials) {
return;
}
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
await _saveSettings();
}
void setDefaultService(String service) {
@@ -380,11 +351,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setMetadataSource(String source) {
state = state.copyWith(metadataSource: source);
_saveSettings();
}
void setSearchProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearSearchProvider: true);
@@ -1563,7 +1563,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
}
static const _providerDisplayNames = <String, String>{
'spotify_api': 'Spotify Lyrics API',
'lrclib': 'LRCLIB',
'netease': 'Netease',
'musixmatch': 'Musixmatch',
@@ -17,7 +17,6 @@ class _LyricsProviderPriorityPageState
extends ConsumerState<LyricsProviderPriorityPage> {
static const _allProviderIds = [
'lrclib',
'spotify_api',
'netease',
'musixmatch',
'apple_music',
@@ -133,9 +132,7 @@ class _LyricsProviderPriorityPageState
void _disableProvider(String id) {
if (_enabledProviders.length <= 1) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.lyricsProvidersAtLeastOne),
),
SnackBar(content: Text(context.l10n.lyricsProvidersAtLeastOne)),
);
return;
}
@@ -184,12 +181,6 @@ class _LyricsProviderPriorityPageState
BuildContext context,
) {
switch (id) {
case 'spotify_api':
return _LyricsProviderInfo(
name: 'Spotify Lyrics API',
description: context.l10n.lyricsProviderSpotifyApiDesc,
icon: Icons.music_note_outlined,
);
case 'lrclib':
return _LyricsProviderInfo(
name: 'LRCLIB',
@@ -70,15 +70,7 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_MetadataSourceSelector(
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setMetadataSource(v),
),
],
),
child: SettingsGroup(children: [const _MetadataSourceSelector()]),
),
SliverToBoxAdapter(
@@ -706,8 +698,7 @@ class _ChannelChip extends StatelessWidget {
}
class _MetadataSourceSelector extends ConsumerWidget {
final ValueChanged<String> onChanged;
const _MetadataSourceSelector({required this.onChanged});
const _MetadataSourceSelector();
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
@@ -770,7 +761,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
if (hasNonDefaultProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
onChanged('deezer');
},
),
const SizedBox(width: 8),
@@ -782,7 +772,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
ref
.read(settingsProvider.notifier)
.setSearchProvider('tidal');
onChanged('tidal');
},
),
const SizedBox(width: 8),
@@ -794,7 +783,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
ref
.read(settingsProvider.notifier)
.setSearchProvider('qobuz');
onChanged('qobuz');
},
),
],
-1
View File
@@ -426,7 +426,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
);
}
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
if (mounted) context.go('/tutorial');