mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ac9ff1dd7 | |||
| 3e90b29d2b | |||
| b74186464b | |||
| f4934dcb28 | |||
| 30973a8e78 | |||
| 9b89625660 | |||
| c70ba5962e |
@@ -1,5 +1,35 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.6.8] - 2026-02-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Lyrics Source Tracking**: Track Metadata screen now displays the source of loaded lyrics (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music, Embedded, or Extension)
|
||||||
|
- New `getLyricsLRCWithSource` API returns lyrics with source metadata
|
||||||
|
- Source badge appears below lyrics section in Track Metadata screen
|
||||||
|
- **Dedicated Lyrics Provider Priority Page**: Lyrics providers can now be configured from a dedicated settings page with full-screen reorderable list
|
||||||
|
- Replaced inline bottom sheet with `LyricsProviderPriorityPage`
|
||||||
|
- Cleaner UI with provider descriptions and priority ordering
|
||||||
|
- **Paxsenix Integration**: Added Paxsenix API as official lyrics proxy partner for Apple Music, QQ Music, Musixmatch, and Netease sources
|
||||||
|
- Listed in About page and Partners page on project site
|
||||||
|
- README updated with partner attribution
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **LRC Background Vocal Preservation**: Apple Music/QQ Music `[bg:...]` background vocal tags are now preserved during LRC parsing instead of being stripped
|
||||||
|
- Background vocals attach to the previous timed line in exported LRC files
|
||||||
|
- **LRC Display Improvements**:
|
||||||
|
- Inline word-by-word timestamps (`<mm:ss.xx>`) are stripped from lyrics display
|
||||||
|
- Speaker prefixes (`v1:`, `v2:`) are removed for cleaner display
|
||||||
|
- Multi-line background vocals converted to readable secondary vocal lines
|
||||||
|
- **Apple Music Lyrics Case Sensitivity**: Fixed `lyricsType` comparison to use case-insensitive matching for "Syllable" type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Track Metadata lyrics fetching now uses `getLyricsLRCWithSource` for consistent source attribution across embedded and online lyrics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.6.7] - 2026-02-13
|
## [3.6.7] - 2026-02-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -16,6 +46,20 @@
|
|||||||
- Project website with GitHub Pages deployment workflow
|
- Project website with GitHub Pages deployment workflow
|
||||||
- Mobile burger menu navigation for all site pages
|
- Mobile burger menu navigation for all site pages
|
||||||
- Go filename template test suite
|
- Go filename template test suite
|
||||||
|
- "Lyrics Provider" extension type - extensions can now provide lyrics (synced or plain text) via `fetchLyrics()` function
|
||||||
|
- Lyrics provider extensions are called before built-in providers, giving extensions highest priority
|
||||||
|
- New `lyrics_provider` manifest type alongside `metadata_provider` and `download_provider`
|
||||||
|
- Shows "Lyrics Provider" capability badge on extension detail page
|
||||||
|
- "Lyrics Providers" settings - configurable provider cascade order and per-provider options
|
||||||
|
- Reorderable provider list: LRCLIB, Musixmatch, Netease, Apple Music, QQ Music
|
||||||
|
- Netease: toggle translated/romanized lyrics appending
|
||||||
|
- Apple Music / QQ Music: multi-person word-by-word speaker tags
|
||||||
|
- Musixmatch: selectable language code for localized lyrics
|
||||||
|
- "Documentation Search" - global search modal on all site pages
|
||||||
|
- Opens with Ctrl+K / Cmd+K / `/` keyboard shortcuts on every page
|
||||||
|
- Search button with bordered pill styling in desktop nav and mobile hamburger menu
|
||||||
|
- On non-docs pages, search results navigate to the docs page at the matching section
|
||||||
|
- Full keyboard navigation: arrow keys, Enter to select, Esc to close
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
|||||||
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
|
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
|
||||||
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
|
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
|
||||||
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
|
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
|
||||||
- **Lyrics**: [LRCLib](https://lrclib.net)
|
- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy)
|
||||||
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
|
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
|
||||||
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
|
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||||
|
|
||||||
|
|||||||
@@ -1582,6 +1582,32 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"getLyricsLRCWithSource" -> {
|
||||||
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
if (filePath.startsWith("content://")) {
|
||||||
|
val tempPath = copyUriToTemp(Uri.parse(filePath))
|
||||||
|
if (tempPath == null) {
|
||||||
|
"""{"lyrics":"","source":"","sync_type":"","instrumental":false}"""
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, tempPath, durationMs)
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
File(tempPath).delete()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"embedLyricsToFile" -> {
|
"embedLyricsToFile" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val lyrics = call.argument<String>("lyrics") ?: ""
|
val lyrics = call.argument<String>("lyrics") ?: ""
|
||||||
@@ -1756,6 +1782,60 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"setLyricsProviders" -> {
|
||||||
|
val providersJson = call.argument<String>("providers_json") ?: "[]"
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.setLyricsProvidersJSON(providersJson)
|
||||||
|
"""{"success":true}"""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getLyricsProviders" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.getLyricsProvidersJSON()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"[]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getAvailableLyricsProviders" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.getAvailableLyricsProvidersJSON()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"[]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"setLyricsFetchOptions" -> {
|
||||||
|
val optionsJson = call.argument<String>("options_json") ?: "{}"
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.setLyricsFetchOptionsJSON(optionsJson)
|
||||||
|
"""{"success":true}"""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getLyricsFetchOptions" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.getLyricsFetchOptionsJSON()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"{}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"reEnrichFile" -> {
|
"reEnrichFile" -> {
|
||||||
val requestJson = call.argument<String>("request_json") ?: "{}"
|
val requestJson = call.argument<String>("request_json") ?: "{}"
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -1008,6 +1008,64 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
|||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||||
|
if filePath != "" {
|
||||||
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
|
if err == nil && lyrics != "" {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"lyrics": lyrics,
|
||||||
|
"source": "Embedded",
|
||||||
|
"sync_type": "EMBEDDED",
|
||||||
|
"instrumental": false,
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"lyrics": "",
|
||||||
|
"source": "",
|
||||||
|
"sync_type": "",
|
||||||
|
"instrumental": false,
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewLyricsClient()
|
||||||
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
lrcContent := ""
|
||||||
|
if lyricsData.Instrumental {
|
||||||
|
lrcContent = "[instrumental:true]"
|
||||||
|
} else {
|
||||||
|
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"lyrics": lrcContent,
|
||||||
|
"source": lyricsData.Source,
|
||||||
|
"sync_type": lyricsData.SyncType,
|
||||||
|
"instrumental": lyricsData.Instrumental,
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||||
err := EmbedLyrics(filePath, lyrics)
|
err := EmbedLyrics(filePath, lyrics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1599,6 +1657,62 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== LYRICS PROVIDER SETTINGS ====================
|
||||||
|
|
||||||
|
// SetLyricsProvidersJSON sets the lyrics provider order from a JSON array of provider IDs.
|
||||||
|
func SetLyricsProvidersJSON(providersJSON string) error {
|
||||||
|
var providers []string
|
||||||
|
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
SetLyricsProviderOrder(providers)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsProvidersJSON returns the current lyrics provider order as JSON.
|
||||||
|
func GetLyricsProvidersJSON() (string, error) {
|
||||||
|
providers := GetLyricsProviderOrder()
|
||||||
|
jsonBytes, err := json.Marshal(providers)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailableLyricsProvidersJSON returns metadata about all available lyrics providers.
|
||||||
|
func GetAvailableLyricsProvidersJSON() (string, error) {
|
||||||
|
providers := GetAvailableLyricsProviders()
|
||||||
|
jsonBytes, err := json.Marshal(providers)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLyricsFetchOptionsJSON sets lyrics provider fetch options.
|
||||||
|
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
|
||||||
|
opts := GetLyricsFetchOptions()
|
||||||
|
if strings.TrimSpace(optionsJSON) != "" {
|
||||||
|
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SetLyricsFetchOptions(opts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsFetchOptionsJSON returns current lyrics provider fetch options.
|
||||||
|
func GetLyricsFetchOptionsJSON() (string, error) {
|
||||||
|
opts := GetLyricsFetchOptions()
|
||||||
|
jsonBytes, err := json.Marshal(opts)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
|
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
|
||||||
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
||||||
// complete metadata from the internet before embedding.
|
// complete metadata from the internet before embedding.
|
||||||
|
|||||||
@@ -713,6 +713,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||||
HasDownloadProvider bool `json:"has_download_provider"`
|
HasDownloadProvider bool `json:"has_download_provider"`
|
||||||
|
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||||
@@ -770,6 +771,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
|
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
|
||||||
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||||
|
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||||
TrackMatching: ext.Manifest.TrackMatching,
|
TrackMatching: ext.Manifest.TrackMatching,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type ExtensionType string
|
|||||||
const (
|
const (
|
||||||
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||||
|
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SettingType string
|
type SettingType string
|
||||||
@@ -167,10 +168,10 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range m.Types {
|
for _, t := range m.Types {
|
||||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
Field: "type",
|
Field: "type",
|
||||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
|
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +227,10 @@ func (m *ExtensionManifest) IsDownloadProvider() bool {
|
|||||||
return m.HasType(ExtensionTypeDownloadProvider)
|
return m.HasType(ExtensionTypeDownloadProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) IsLyricsProvider() bool {
|
||||||
|
return m.HasType(ExtensionTypeLyricsProvider)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
for _, allowed := range m.Permissions.Network {
|
for _, allowed := range m.Permissions.Network {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -1699,3 +1700,140 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
|
|||||||
|
|
||||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Lyrics Provider ====================
|
||||||
|
|
||||||
|
// ExtLyricsResult represents lyrics data returned from an extension
|
||||||
|
type ExtLyricsResult struct {
|
||||||
|
Lines []ExtLyricsLine `json:"lines"`
|
||||||
|
SyncType string `json:"syncType"`
|
||||||
|
Instrumental bool `json:"instrumental"`
|
||||||
|
PlainLyrics string `json:"plainLyrics"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtLyricsLine struct {
|
||||||
|
StartTimeMs int64 `json:"startTimeMs"`
|
||||||
|
Words string `json:"words"`
|
||||||
|
EndTimeMs int64 `json:"endTimeMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics calls the extension's fetchLyrics function
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.extension.Enabled {
|
||||||
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.extension.VMMu.Lock()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
|
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||||
|
const trackVar = "__sf_lyrics_track"
|
||||||
|
const artistVar = "__sf_lyrics_artist"
|
||||||
|
const albumVar = "__sf_lyrics_album"
|
||||||
|
const durationVar = "__sf_lyrics_duration"
|
||||||
|
global := p.vm.GlobalObject()
|
||||||
|
_ = global.Set(trackVar, trackName)
|
||||||
|
_ = global.Set(artistVar, artistName)
|
||||||
|
_ = global.Set(albumVar, albumName)
|
||||||
|
_ = global.Set(durationVar, durationSec)
|
||||||
|
defer func() {
|
||||||
|
global.Delete(trackVar)
|
||||||
|
global.Delete(artistVar)
|
||||||
|
global.Delete(albumVar)
|
||||||
|
global.Delete(durationVar)
|
||||||
|
}()
|
||||||
|
|
||||||
|
const script = `
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.fetchLyrics === 'function') {
|
||||||
|
return extension.fetchLyrics(__sf_lyrics_track, __sf_lyrics_artist, __sf_lyrics_album, __sf_lyrics_duration);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||||
|
if err != nil {
|
||||||
|
if IsTimeoutError(err) {
|
||||||
|
return nil, fmt.Errorf("fetchLyrics timeout: extension took too long to respond")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("fetchLyrics failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||||
|
return nil, fmt.Errorf("fetchLyrics returned null")
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := result.Export()
|
||||||
|
jsonBytes, err := json.Marshal(exported)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal lyrics result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var extResult ExtLyricsResult
|
||||||
|
if err := json.Unmarshal(jsonBytes, &extResult); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ExtLyricsResult to LyricsResponse
|
||||||
|
response := &LyricsResponse{
|
||||||
|
SyncType: extResult.SyncType,
|
||||||
|
Instrumental: extResult.Instrumental,
|
||||||
|
PlainLyrics: extResult.PlainLyrics,
|
||||||
|
Provider: extResult.Provider,
|
||||||
|
Source: "Extension: " + p.extension.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.Provider == "" {
|
||||||
|
response.Provider = p.extension.Manifest.DisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range extResult.Lines {
|
||||||
|
response.Lines = append(response.Lines, LyricsLine{
|
||||||
|
StartTimeMs: line.StartTimeMs,
|
||||||
|
Words: line.Words,
|
||||||
|
EndTimeMs: line.EndTimeMs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the extension provided plainLyrics but no lines, parse them as unsynced
|
||||||
|
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
|
||||||
|
response.SyncType = "UNSYNCED"
|
||||||
|
for _, line := range strings.Split(response.PlainLyrics, "\n") {
|
||||||
|
if strings.TrimSpace(line) != "" {
|
||||||
|
response.Lines = append(response.Lines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: line,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsProviders returns all enabled extensions that provide lyrics
|
||||||
|
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
var providers []*ExtensionProviderWrapper
|
||||||
|
for _, ext := range m.extensions {
|
||||||
|
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
|
||||||
|
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a deterministic order so provider selection is stable across runs.
|
||||||
|
sort.Slice(providers, func(i, j int) bool {
|
||||||
|
return providers[i].extension.ID < providers[j].extension.ID
|
||||||
|
})
|
||||||
|
|
||||||
|
return providers
|
||||||
|
}
|
||||||
|
|||||||
+397
-48
@@ -20,6 +20,140 @@ const (
|
|||||||
durationToleranceSec = 10.0
|
durationToleranceSec = 10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Lyrics provider names (used in settings and cascade ordering)
|
||||||
|
const (
|
||||||
|
LyricsProviderLRCLIB = "lrclib"
|
||||||
|
LyricsProviderNetease = "netease"
|
||||||
|
LyricsProviderMusixmatch = "musixmatch"
|
||||||
|
LyricsProviderAppleMusic = "apple_music"
|
||||||
|
LyricsProviderQQMusic = "qqmusic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
|
||||||
|
// LRCLIB first (no proxy dependency), then the others.
|
||||||
|
var DefaultLyricsProviders = []string{
|
||||||
|
LyricsProviderLRCLIB,
|
||||||
|
LyricsProviderMusixmatch,
|
||||||
|
LyricsProviderNetease,
|
||||||
|
LyricsProviderAppleMusic,
|
||||||
|
LyricsProviderQQMusic,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global lyrics provider configuration
|
||||||
|
var (
|
||||||
|
lyricsProvidersMu sync.RWMutex
|
||||||
|
lyricsProviders []string // ordered list of enabled providers
|
||||||
|
)
|
||||||
|
|
||||||
|
// LyricsFetchOptions controls optional provider-specific enhancements.
|
||||||
|
type LyricsFetchOptions struct {
|
||||||
|
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||||
|
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||||
|
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
|
||||||
|
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultLyricsFetchOptions = LyricsFetchOptions{
|
||||||
|
IncludeTranslationNetease: false,
|
||||||
|
IncludeRomanizationNetease: false,
|
||||||
|
MultiPersonWordByWord: true,
|
||||||
|
MusixmatchLanguage: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
lyricsFetchOptionsMu sync.RWMutex
|
||||||
|
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
|
||||||
|
// Providers not in the list are disabled. An empty list resets to defaults.
|
||||||
|
func SetLyricsProviderOrder(providers []string) {
|
||||||
|
lyricsProvidersMu.Lock()
|
||||||
|
defer lyricsProvidersMu.Unlock()
|
||||||
|
|
||||||
|
if len(providers) == 0 {
|
||||||
|
lyricsProviders = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate provider names
|
||||||
|
validNames := map[string]bool{
|
||||||
|
LyricsProviderLRCLIB: true,
|
||||||
|
LyricsProviderNetease: true,
|
||||||
|
LyricsProviderMusixmatch: true,
|
||||||
|
LyricsProviderAppleMusic: true,
|
||||||
|
LyricsProviderQQMusic: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var valid []string
|
||||||
|
for _, p := range providers {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(p))
|
||||||
|
if validNames[normalized] {
|
||||||
|
valid = append(valid, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsProviders = valid
|
||||||
|
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsProviderOrder returns the current lyrics provider order.
|
||||||
|
func GetLyricsProviderOrder() []string {
|
||||||
|
lyricsProvidersMu.RLock()
|
||||||
|
defer lyricsProvidersMu.RUnlock()
|
||||||
|
|
||||||
|
if len(lyricsProviders) == 0 {
|
||||||
|
return DefaultLyricsProviders
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, len(lyricsProviders))
|
||||||
|
copy(result, lyricsProviders)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailableLyricsProviders returns metadata about all available providers.
|
||||||
|
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||||
|
return []map[string]interface{}{
|
||||||
|
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||||
|
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||||
|
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||||
|
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||||
|
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
||||||
|
opts.MusixmatchLanguage = strings.ToLower(strings.TrimSpace(opts.MusixmatchLanguage))
|
||||||
|
opts.MusixmatchLanguage = regexp.MustCompile(`[^a-z0-9\-_]`).ReplaceAllString(opts.MusixmatchLanguage, "")
|
||||||
|
if len(opts.MusixmatchLanguage) > 16 {
|
||||||
|
opts.MusixmatchLanguage = opts.MusixmatchLanguage[:16]
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
||||||
|
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||||
|
normalized := normalizeLyricsFetchOptions(opts)
|
||||||
|
|
||||||
|
lyricsFetchOptionsMu.Lock()
|
||||||
|
defer lyricsFetchOptionsMu.Unlock()
|
||||||
|
lyricsFetchOptions = normalized
|
||||||
|
|
||||||
|
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
|
||||||
|
normalized.IncludeTranslationNetease,
|
||||||
|
normalized.IncludeRomanizationNetease,
|
||||||
|
normalized.MultiPersonWordByWord,
|
||||||
|
normalized.MusixmatchLanguage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
||||||
|
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||||
|
lyricsFetchOptionsMu.RLock()
|
||||||
|
defer lyricsFetchOptionsMu.RUnlock()
|
||||||
|
return lyricsFetchOptions
|
||||||
|
}
|
||||||
|
|
||||||
type lyricsCacheEntry struct {
|
type lyricsCacheEntry struct {
|
||||||
response *LyricsResponse
|
response *LyricsResponse
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
@@ -90,6 +224,15 @@ func (c *lyricsCache) Size() int {
|
|||||||
return len(c.cache)
|
return len(c.cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) ClearAll() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
cleared := len(c.cache)
|
||||||
|
c.cache = make(map[string]*lyricsCacheEntry)
|
||||||
|
return cleared
|
||||||
|
}
|
||||||
|
|
||||||
type LRCLibResponse struct {
|
type LRCLibResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -139,7 +282,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,7 +317,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,68 +383,203 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
|
|||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
primaryArtist := normalizeArtistName(artistName)
|
primaryArtist := normalizeArtistName(artistName)
|
||||||
|
fetchOptions := GetLyricsFetchOptions()
|
||||||
|
|
||||||
|
extManager := GetExtensionManager()
|
||||||
|
var extensionProviders []*ExtensionProviderWrapper
|
||||||
|
if extManager != nil {
|
||||||
|
extensionProviders = extManager.GetLyricsProviders()
|
||||||
|
}
|
||||||
|
|
||||||
|
var cachedNonExtension *LyricsResponse
|
||||||
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
isExtensionCache := strings.HasPrefix(cached.Source, "Extension:")
|
||||||
cachedCopy := *cached
|
if len(extensionProviders) == 0 || isExtensionCache {
|
||||||
cachedCopy.Source = cached.Source + " (cached)"
|
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||||
|
cachedCopy := *cached
|
||||||
|
cachedCopy.Source = cached.Source + " (cached)"
|
||||||
|
return &cachedCopy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If extension providers are currently enabled, don't let stale built-in cache
|
||||||
|
// mask newly installed/activated extensions.
|
||||||
|
cachedNonExtension = cached
|
||||||
|
GoLog("[Lyrics] Ignoring cached non-extension lyrics because extension providers are available\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidResult := func(l *LyricsResponse) bool {
|
||||||
|
return lyricsHasUsableText(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try extension lyrics providers first
|
||||||
|
if len(extensionProviders) > 0 {
|
||||||
|
for _, provider := range extensionProviders {
|
||||||
|
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||||
|
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
|
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cachedNonExtension != nil {
|
||||||
|
cachedCopy := *cachedNonExtension
|
||||||
|
cachedCopy.Source = cachedNonExtension.Source + " (cached fallback)"
|
||||||
|
GoLog("[Lyrics] Extension providers unavailable for this track, using cached built-in lyrics\n")
|
||||||
return &cachedCopy, nil
|
return &cachedCopy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var lyrics *LyricsResponse
|
// Get configured provider order
|
||||||
var err error
|
providerOrder := GetLyricsProviderOrder()
|
||||||
|
|
||||||
isValidResult := func(l *LyricsResponse) bool {
|
|
||||||
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
|
||||||
}
|
|
||||||
|
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
|
||||||
if err == nil && isValidResult(lyrics) {
|
|
||||||
lyrics.Source = "LRCLIB"
|
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
|
||||||
return lyrics, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if primaryArtist != artistName {
|
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
|
||||||
if err == nil && isValidResult(lyrics) {
|
|
||||||
lyrics.Source = "LRCLIB"
|
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
|
||||||
return lyrics, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
if simplifiedTrack != trackName {
|
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||||
|
|
||||||
|
// Cascade through all configured built-in providers
|
||||||
|
for _, providerName := range providerOrder {
|
||||||
|
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||||
|
|
||||||
|
var lyrics *LyricsResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch providerName {
|
||||||
|
case LyricsProviderLRCLIB:
|
||||||
|
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
||||||
|
|
||||||
|
case LyricsProviderNetease:
|
||||||
|
neteaseClient := NewNeteaseClient()
|
||||||
|
lyrics, err = neteaseClient.FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
primaryArtist,
|
||||||
|
durationSec,
|
||||||
|
fetchOptions.IncludeTranslationNetease,
|
||||||
|
fetchOptions.IncludeRomanizationNetease,
|
||||||
|
)
|
||||||
|
if err != nil && primaryArtist != artistName {
|
||||||
|
lyrics, err = neteaseClient.FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
artistName,
|
||||||
|
durationSec,
|
||||||
|
fetchOptions.IncludeTranslationNetease,
|
||||||
|
fetchOptions.IncludeRomanizationNetease,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil && simplifiedTrack != trackName {
|
||||||
|
lyrics, err = neteaseClient.FetchLyrics(
|
||||||
|
simplifiedTrack,
|
||||||
|
primaryArtist,
|
||||||
|
durationSec,
|
||||||
|
fetchOptions.IncludeTranslationNetease,
|
||||||
|
fetchOptions.IncludeRomanizationNetease,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case LyricsProviderMusixmatch:
|
||||||
|
musixmatchClient := NewMusixmatchClient()
|
||||||
|
lyrics, err = musixmatchClient.FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
primaryArtist,
|
||||||
|
durationSec,
|
||||||
|
fetchOptions.MusixmatchLanguage,
|
||||||
|
)
|
||||||
|
if err != nil && primaryArtist != artistName {
|
||||||
|
lyrics, err = musixmatchClient.FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
artistName,
|
||||||
|
durationSec,
|
||||||
|
fetchOptions.MusixmatchLanguage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case LyricsProviderAppleMusic:
|
||||||
|
appleClient := NewAppleMusicClient()
|
||||||
|
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||||
|
if err != nil && primaryArtist != artistName {
|
||||||
|
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
case LyricsProviderQQMusic:
|
||||||
|
qqClient := NewQQMusicClient()
|
||||||
|
lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||||
|
if err != nil && primaryArtist != artistName {
|
||||||
|
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil && isValidResult(lyrics) {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB (simplified)"
|
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
query := primaryArtist + " " + trackName
|
if err != nil {
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
|
||||||
if err == nil && isValidResult(lyrics) {
|
|
||||||
lyrics.Source = "LRCLIB Search"
|
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
|
||||||
return lyrics, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if simplifiedTrack != trackName {
|
|
||||||
query = primaryArtist + " " + simplifiedTrack
|
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
|
||||||
if err == nil && isValidResult(lyrics) {
|
|
||||||
lyrics.Source = "LRCLIB Search (simplified)"
|
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
|
||||||
return lyrics, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("lyrics not found from any source")
|
return nil, fmt.Errorf("lyrics not found from any source")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
|
||||||
|
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
||||||
|
var lyrics *LyricsResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// 1. Exact match with primary artist
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||||
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
|
lyrics.Source = "LRCLIB"
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Exact match with full artist name
|
||||||
|
if primaryArtist != artistName {
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
|
lyrics.Source = "LRCLIB"
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Simplified track name
|
||||||
|
if simplifiedTrack != trackName {
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||||
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
|
lyrics.Source = "LRCLIB (simplified)"
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Search by query
|
||||||
|
query := primaryArtist + " " + trackName
|
||||||
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
|
lyrics.Source = "LRCLIB Search"
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Search with simplified track name
|
||||||
|
if simplifiedTrack != trackName {
|
||||||
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
|
lyrics.Source = "LRCLIB Search (simplified)"
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("LRCLIB: no lyrics found")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
|
func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
|
||||||
result := &LyricsResponse{
|
result := &LyricsResponse{
|
||||||
Instrumental: resp.Instrumental,
|
Instrumental: resp.Instrumental,
|
||||||
@@ -339,10 +617,20 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve Apple/QQ background vocal tags by attaching them to
|
||||||
|
// the previous timed line. This keeps [bg:...] in final exported LRC.
|
||||||
|
if strings.HasPrefix(line, "[bg:") && len(lines) > 0 {
|
||||||
|
lines[len(lines)-1].Words = strings.TrimSpace(lines[len(lines)-1].Words + "\n" + line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
matches := lrcPattern.FindStringSubmatch(line)
|
matches := lrcPattern.FindStringSubmatch(line)
|
||||||
if len(matches) == 5 {
|
if len(matches) == 5 {
|
||||||
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
|
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
|
||||||
words := strings.TrimSpace(matches[4])
|
words := strings.TrimSpace(matches[4])
|
||||||
|
if words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
lines = append(lines, LyricsLine{
|
lines = append(lines, LyricsLine{
|
||||||
StartTimeMs: startMs,
|
StartTimeMs: startMs,
|
||||||
@@ -363,6 +651,63 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lyricsHasUsableText(lyrics *LyricsResponse) bool {
|
||||||
|
if lyrics == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lyrics.Instrumental {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(lyrics.PlainLyrics) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, line := range lyrics.Lines {
|
||||||
|
if strings.TrimSpace(line.Words) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectLyricsErrorPayload extracts human-readable error messages from
|
||||||
|
// JSON payloads returned by lyrics proxies when no lyric is available.
|
||||||
|
func detectLyricsErrorPayload(raw string) (string, bool) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsKeys := []string{"lyrics", "lyric", "lrc", "content", "lines", "syncedLyrics", "unsyncedLyrics"}
|
||||||
|
hasLyricsKey := false
|
||||||
|
for _, key := range lyricsKeys {
|
||||||
|
if _, ok := payload[key]; ok {
|
||||||
|
hasLyricsKey = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errorKeys := []string{"message", "error", "detail", "reason"}
|
||||||
|
for _, key := range errorKeys {
|
||||||
|
if msg, ok := payload[key].(string); ok {
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
if msg != "" && !hasLyricsKey {
|
||||||
|
return msg, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
|
||||||
|
return "request unsuccessful", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
||||||
min, _ := strconv.ParseInt(minutes, 10, 64)
|
min, _ := strconv.ParseInt(minutes, 10, 64)
|
||||||
sec, _ := strconv.ParseInt(seconds, 10, 64)
|
sec, _ := strconv.ParseInt(seconds, 10, 64)
|
||||||
@@ -376,12 +721,16 @@ func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func msToLRCTimestamp(ms int64) string {
|
func msToLRCTimestamp(ms int64) string {
|
||||||
|
return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
func msToLRCTimestampInline(ms int64) string {
|
||||||
totalSeconds := ms / 1000
|
totalSeconds := ms / 1000
|
||||||
minutes := totalSeconds / 60
|
minutes := totalSeconds / 60
|
||||||
seconds := totalSeconds % 60
|
seconds := totalSeconds % 60
|
||||||
centiseconds := (ms % 1000) / 10
|
centiseconds := (ms % 1000) / 10
|
||||||
|
|
||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppleMusicClient fetches lyrics from Apple Music.
|
||||||
|
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||||
|
type AppleMusicClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Music token manager — singleton with mutex for thread safety
|
||||||
|
type appleTokenManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalAppleTokenManager = &appleTokenManager{}
|
||||||
|
|
||||||
|
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if m.token != "" {
|
||||||
|
return m.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Fetch the Apple Music beta page
|
||||||
|
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Find the index JS file URL
|
||||||
|
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||||
|
match := indexJsRegex.Find(body)
|
||||||
|
if match == nil {
|
||||||
|
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||||
|
}
|
||||||
|
|
||||||
|
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||||
|
|
||||||
|
// Step 3: Fetch the JS file
|
||||||
|
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||||
|
}
|
||||||
|
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
jsResp, err := client.Do(jsReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||||
|
}
|
||||||
|
defer jsResp.Body.Close()
|
||||||
|
|
||||||
|
jsBody, err := io.ReadAll(jsResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Extract JWT token (starts with eyJh)
|
||||||
|
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||||
|
tokenMatch := tokenRegex.Find(jsBody)
|
||||||
|
if tokenMatch == nil {
|
||||||
|
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.token = string(tokenMatch)
|
||||||
|
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||||
|
return m.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *appleTokenManager) clearToken() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.token = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Music API response models
|
||||||
|
type appleMusicSearchResponse struct {
|
||||||
|
Results struct {
|
||||||
|
Songs *struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"songs"`
|
||||||
|
} `json:"results"`
|
||||||
|
Resources *struct {
|
||||||
|
Songs map[string]struct {
|
||||||
|
Attributes struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Artwork struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"artwork"`
|
||||||
|
} `json:"attributes"`
|
||||||
|
} `json:"songs"`
|
||||||
|
} `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||||
|
type paxResponse struct {
|
||||||
|
Type string `json:"type"` // "Syllable" or "Line"
|
||||||
|
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||||
|
}
|
||||||
|
|
||||||
|
type paxLyrics struct {
|
||||||
|
Text []paxLyricDetail `json:"text"`
|
||||||
|
Timestamp int `json:"timestamp"`
|
||||||
|
OppositeTurn bool `json:"oppositeTurn"`
|
||||||
|
Background bool `json:"background"`
|
||||||
|
BackgroundText []paxLyricDetail `json:"backgroundText"`
|
||||||
|
EndTime int `json:"endtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type paxLyricDetail struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Part bool `json:"part"`
|
||||||
|
Timestamp *int `json:"timestamp"`
|
||||||
|
EndTime *int `json:"endtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppleMusicClient() *AppleMusicClient {
|
||||||
|
return &AppleMusicClient{
|
||||||
|
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||||
|
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||||
|
query := trackName + " " + artistName
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
return "", fmt.Errorf("empty search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("apple music token error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedQuery := url.QueryEscape(query)
|
||||||
|
searchURL := fmt.Sprintf(
|
||||||
|
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||||
|
encodedQuery,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Origin", "https://music.apple.com")
|
||||||
|
req.Header.Set("Referer", "https://music.apple.com/")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("apple music search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 401 {
|
||||||
|
globalAppleTokenManager.clearToken()
|
||||||
|
return "", fmt.Errorf("apple music token expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp appleMusicSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||||
|
return "", fmt.Errorf("no songs found on apple music")
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResp.Results.Songs.Data[0].ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||||
|
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||||
|
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", lyricsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("apple music lyrics fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("apple music lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||||
|
if bodyStr == "" {
|
||||||
|
return "", fmt.Errorf("empty lyrics response from apple music")
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
|
||||||
|
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||||
|
// Try to parse as PaxResponse first
|
||||||
|
var paxResp paxResponse
|
||||||
|
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||||
|
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as a direct list of PaxLyrics
|
||||||
|
var directLyrics []paxLyrics
|
||||||
|
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||||
|
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to parse pax lyrics response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||||
|
lastStart := ""
|
||||||
|
|
||||||
|
for _, syllable := range details {
|
||||||
|
if syllable.Timestamp != nil {
|
||||||
|
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
|
||||||
|
if start != lastStart {
|
||||||
|
builder.WriteString(start)
|
||||||
|
lastStart = start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString(syllable.Text)
|
||||||
|
if !syllable.Part {
|
||||||
|
builder.WriteString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if syllable.EndTime != nil {
|
||||||
|
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
for i, line := range content {
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := msToLRCTimestamp(int64(line.Timestamp))
|
||||||
|
|
||||||
|
if strings.EqualFold(lyricsType, "Syllable") {
|
||||||
|
sb.WriteString(timestamp)
|
||||||
|
if multiPersonWordByWord {
|
||||||
|
if line.OppositeTurn {
|
||||||
|
sb.WriteString("v2:")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("v1:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPaxLyricDetail(&sb, line.Text)
|
||||||
|
|
||||||
|
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
|
||||||
|
sb.WriteString("\n[bg:")
|
||||||
|
appendPaxLyricDetail(&sb, line.BackgroundText)
|
||||||
|
sb.WriteString("]")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(line.Text) > 0 {
|
||||||
|
sb.WriteString(timestamp)
|
||||||
|
sb.WriteString(line.Text[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
|
||||||
|
func (c *AppleMusicClient) FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
artistName string,
|
||||||
|
durationSec float64,
|
||||||
|
multiPersonWordByWord bool,
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
songID, err := c.SearchSong(trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLyrics, err := c.FetchLyricsByID(songID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||||
|
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as pax format (word-by-word or line)
|
||||||
|
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
|
if err != nil {
|
||||||
|
// If pax parsing fails, try to parse as direct LRC text
|
||||||
|
lrcText = rawLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := parseSyncedLyrics(lrcText)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "Apple Music",
|
||||||
|
Source: "Apple Music",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to plain text if no timestamps found
|
||||||
|
plainLines := strings.Split(lrcText, "\n")
|
||||||
|
var resultLines []LyricsLine
|
||||||
|
for _, line := range plainLines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
resultLines = append(resultLines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resultLines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: resultLines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
Provider: "Apple Music",
|
||||||
|
Source: "Apple Music",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no lyrics found on apple music")
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
|
||||||
|
// The proxy handles Musixmatch authentication internally.
|
||||||
|
type MusixmatchClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Musixmatch proxy response models
|
||||||
|
type musixmatchSearchResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SongName string `json:"songName"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
Artwork string `json:"artwork"`
|
||||||
|
ReleaseDate string `json:"releaseDate"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
AlbumID int64 `json:"albumId"`
|
||||||
|
HasSyncedLyrics bool `json:"hasSyncedLyrics"`
|
||||||
|
HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"`
|
||||||
|
AvailableLanguages []string `json:"availableLanguages"`
|
||||||
|
OriginalLanguage string `json:"originalLanguage"`
|
||||||
|
SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"`
|
||||||
|
UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type musixmatchLyricsResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
UpdatedTime string `json:"updatedTime"`
|
||||||
|
Lyrics string `json:"lyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMusixmatchClient() *MusixmatchClient {
|
||||||
|
return &MusixmatchClient{
|
||||||
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
|
baseURL: "http://158.180.60.95",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||||
|
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||||
|
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||||
|
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||||
|
return nil, fmt.Errorf("empty track or artist name")
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedArtist := url.QueryEscape(artistName)
|
||||||
|
encodedTrack := url.QueryEscape(trackName)
|
||||||
|
|
||||||
|
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, 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("musixmatch search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result musixmatchSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||||
|
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||||
|
lang := strings.ToLower(strings.TrimSpace(language))
|
||||||
|
if songID <= 0 || lang == "" {
|
||||||
|
return nil, fmt.Errorf("invalid song id or language")
|
||||||
|
}
|
||||||
|
|
||||||
|
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, 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("musixmatch language fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result musixmatchSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer synced lyrics for selected language
|
||||||
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to unsynced lyrics for selected language
|
||||||
|
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||||
|
var lines []LyricsLine
|
||||||
|
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
lines = append(lines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||||
|
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||||
|
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||||
|
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||||
|
if localizedErr == nil {
|
||||||
|
return localized, nil
|
||||||
|
}
|
||||||
|
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer synced lyrics
|
||||||
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: "Musixmatch",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to unsynced lyrics
|
||||||
|
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||||
|
var lines []LyricsLine
|
||||||
|
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
lines = append(lines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: "Musixmatch",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||||
|
// This is a direct public API — no proxy dependency.
|
||||||
|
type NeteaseClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netease API response models
|
||||||
|
type neteaseSearchResponse struct {
|
||||||
|
Result struct {
|
||||||
|
Songs []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Artists []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artists"`
|
||||||
|
} `json:"songs"`
|
||||||
|
SongCount int `json:"songCount"`
|
||||||
|
} `json:"result"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type neteaseLyricsResponse struct {
|
||||||
|
LRC *neteaseLyricField `json:"lrc"`
|
||||||
|
TLyric *neteaseLyricField `json:"tlyric"`
|
||||||
|
RomaLRC *neteaseLyricField `json:"romalrc"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type neteaseLyricField struct {
|
||||||
|
Lyric string `json:"lyric"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var neteaseHeaders = map[string]string{
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Cache-Control": "max-age=0",
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNeteaseClient() *NeteaseClient {
|
||||||
|
return &NeteaseClient{
|
||||||
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchSong searches for a song on Netease and returns the song ID.
|
||||||
|
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
||||||
|
query := trackName + " " + artistName
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
return 0, fmt.Errorf("empty search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := "http://music.163.com/api/search/pc"
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("s", query)
|
||||||
|
params.Set("type", "1")
|
||||||
|
params.Set("limit", "1")
|
||||||
|
params.Set("offset", "0")
|
||||||
|
|
||||||
|
fullURL := searchURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range neteaseHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("netease search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return 0, fmt.Errorf("netease search returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp neteaseSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to decode netease search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
|
||||||
|
return 0, fmt.Errorf("no songs found on netease")
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResp.Result.Songs[0].ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||||
|
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||||
|
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("id", fmt.Sprintf("%d", songID))
|
||||||
|
params.Set("lv", "1")
|
||||||
|
params.Set("tv", "1")
|
||||||
|
params.Set("rv", "1")
|
||||||
|
|
||||||
|
fullURL := lyricsURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range neteaseHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("netease lyrics fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("netease lyrics returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyricsResp neteaseLyricsResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&lyricsResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode netease lyrics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsResp.LRC == nil || strings.TrimSpace(lyricsResp.LRC.Lyric) == "" {
|
||||||
|
return "", fmt.Errorf("no lyrics available on netease")
|
||||||
|
}
|
||||||
|
|
||||||
|
lyric := lyricsResp.LRC.Lyric
|
||||||
|
|
||||||
|
if includeTranslation && lyricsResp.TLyric != nil && strings.TrimSpace(lyricsResp.TLyric.Lyric) != "" {
|
||||||
|
lyric += "\n\n" + lyricsResp.TLyric.Lyric
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeRomanization && lyricsResp.RomaLRC != nil && strings.TrimSpace(lyricsResp.RomaLRC.Lyric) != "" {
|
||||||
|
lyric += "\n\n" + lyricsResp.RomaLRC.Lyric
|
||||||
|
}
|
||||||
|
|
||||||
|
return lyric, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics searches for a track and returns parsed LyricsResponse.
|
||||||
|
func (c *NeteaseClient) FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
artistName string,
|
||||||
|
durationSec float64,
|
||||||
|
includeTranslation,
|
||||||
|
includeRomanization bool,
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
songID, err := c.SearchSong(trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lrcText, err := c.FetchLyricsByID(songID, includeTranslation, includeRomanization)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the LRC text into LyricsResponse
|
||||||
|
lines := parseSyncedLyrics(lrcText)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
// May be plain text lyrics without timestamps
|
||||||
|
plainLines := strings.Split(lrcText, "\n")
|
||||||
|
for _, line := range plainLines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
lines = append(lines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return nil, fmt.Errorf("netease returned empty lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
Provider: "Netease",
|
||||||
|
Source: "Netease",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "Netease",
|
||||||
|
Source: "Netease",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QQMusicClient fetches lyrics from QQ Music.
|
||||||
|
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||||
|
type QQMusicClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// QQ Music search response models
|
||||||
|
type qqMusicSearchResponse struct {
|
||||||
|
Data struct {
|
||||||
|
Song struct {
|
||||||
|
List []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Singer []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"singer"`
|
||||||
|
Album struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"album"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"list"`
|
||||||
|
} `json:"song"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QQ Music lyrics request payload for paxsenix proxy
|
||||||
|
type qqLyricsPayload struct {
|
||||||
|
Artist []string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQQMusicClient() *QQMusicClient {
|
||||||
|
return &QQMusicClient{
|
||||||
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||||
|
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||||
|
query := trackName + " " + artistName
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
return nil, fmt.Errorf("empty search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("format", "json")
|
||||||
|
params.Set("inCharset", "utf8")
|
||||||
|
params.Set("outCharset", "utf8")
|
||||||
|
params.Set("platform", "yqq.json")
|
||||||
|
params.Set("new_json", "1")
|
||||||
|
params.Set("w", query)
|
||||||
|
|
||||||
|
fullURL := searchURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp qqMusicSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Data.Song.List) == 0 {
|
||||||
|
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||||
|
}
|
||||||
|
|
||||||
|
song := searchResp.Data.Song.List[0]
|
||||||
|
|
||||||
|
var artists []string
|
||||||
|
for _, singer := range song.Singer {
|
||||||
|
artists = append(artists, singer.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &qqLyricsPayload{
|
||||||
|
Artist: artists,
|
||||||
|
Album: song.Album.Name,
|
||||||
|
ID: song.ID,
|
||||||
|
Title: song.Title,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||||
|
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||||
|
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||||
|
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("qqmusic lyrics fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("qqmusic lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||||
|
if bodyStr == "" {
|
||||||
|
return "", fmt.Errorf("empty lyrics response from qqmusic")
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||||
|
func (c *QQMusicClient) FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
artistName string,
|
||||||
|
durationSec float64,
|
||||||
|
multiPersonWordByWord bool,
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
payload, err := c.searchSong(trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||||
|
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as pax format (word-by-word or line)
|
||||||
|
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
|
if err != nil {
|
||||||
|
// If pax parsing fails, try to use as direct LRC text
|
||||||
|
lrcText = rawLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := parseSyncedLyrics(lrcText)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "QQ Music",
|
||||||
|
Source: "QQ Music",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to plain text
|
||||||
|
plainLines := strings.Split(lrcText, "\n")
|
||||||
|
var resultLines []LyricsLine
|
||||||
|
for _, line := range plainLines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
resultLines = append(resultLines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resultLines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: resultLines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
Provider: "QQ Music",
|
||||||
|
Source: "QQ Music",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no lyrics found on qqmusic")
|
||||||
|
}
|
||||||
@@ -191,6 +191,17 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "getLyricsLRCWithSource":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let spotifyId = args["spotify_id"] as! String
|
||||||
|
let trackName = args["track_name"] as! String
|
||||||
|
let artistName = args["artist_name"] as! String
|
||||||
|
let filePath = args["file_path"] as? String ?? ""
|
||||||
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let response = GobackendGetLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "embedLyricsToFile":
|
case "embedLyricsToFile":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -783,6 +794,36 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
// Lyrics Provider Settings
|
||||||
|
case "setLyricsProviders":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let providersJson = args["providers_json"] as? String ?? "[]"
|
||||||
|
GobackendSetLyricsProvidersJSON(providersJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return "{\"success\":true}"
|
||||||
|
|
||||||
|
case "getLyricsProviders":
|
||||||
|
let response = GobackendGetLyricsProvidersJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getAvailableLyricsProviders":
|
||||||
|
let response = GobackendGetAvailableLyricsProvidersJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setLyricsFetchOptions":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let optionsJson = args["options_json"] as? String ?? "{}"
|
||||||
|
GobackendSetLyricsFetchOptionsJSON(optionsJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return "{\"success\":true}"
|
||||||
|
|
||||||
|
case "getLyricsFetchOptions":
|
||||||
|
let response = GobackendGetLyricsFetchOptionsJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ class AppSettings {
|
|||||||
final bool
|
final bool
|
||||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||||
|
|
||||||
|
// Lyrics Provider Settings
|
||||||
|
final List<String>
|
||||||
|
lyricsProviders; // Ordered list of enabled lyrics provider IDs
|
||||||
|
final bool
|
||||||
|
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
|
||||||
|
final bool
|
||||||
|
lyricsIncludeRomanizationNetease; // Append romanized lyrics (Netease)
|
||||||
|
final bool
|
||||||
|
lyricsMultiPersonWordByWord; // Enable v1/v2 + [bg:] tags for Apple/QQ syllable lyrics
|
||||||
|
final String
|
||||||
|
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
this.audioQuality = 'LOSSLESS',
|
this.audioQuality = 'LOSSLESS',
|
||||||
@@ -100,6 +112,12 @@ class AppSettings {
|
|||||||
this.localLibraryShowDuplicates = true,
|
this.localLibraryShowDuplicates = true,
|
||||||
// Tutorial default
|
// Tutorial default
|
||||||
this.hasCompletedTutorial = false,
|
this.hasCompletedTutorial = false,
|
||||||
|
// Lyrics providers default order
|
||||||
|
this.lyricsProviders = const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
|
||||||
|
this.lyricsIncludeTranslationNetease = false,
|
||||||
|
this.lyricsIncludeRomanizationNetease = false,
|
||||||
|
this.lyricsMultiPersonWordByWord = true,
|
||||||
|
this.musixmatchLanguage = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -147,6 +165,12 @@ class AppSettings {
|
|||||||
bool? localLibraryShowDuplicates,
|
bool? localLibraryShowDuplicates,
|
||||||
// Tutorial
|
// Tutorial
|
||||||
bool? hasCompletedTutorial,
|
bool? hasCompletedTutorial,
|
||||||
|
// Lyrics providers
|
||||||
|
List<String>? lyricsProviders,
|
||||||
|
bool? lyricsIncludeTranslationNetease,
|
||||||
|
bool? lyricsIncludeRomanizationNetease,
|
||||||
|
bool? lyricsMultiPersonWordByWord,
|
||||||
|
String? musixmatchLanguage,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -202,6 +226,15 @@ class AppSettings {
|
|||||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||||
// Tutorial
|
// Tutorial
|
||||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||||
|
// Lyrics providers
|
||||||
|
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||||
|
lyricsIncludeTranslationNetease:
|
||||||
|
lyricsIncludeTranslationNetease ?? this.lyricsIncludeTranslationNetease,
|
||||||
|
lyricsIncludeRomanizationNetease:
|
||||||
|
lyricsIncludeRomanizationNetease ?? this.lyricsIncludeRomanizationNetease,
|
||||||
|
lyricsMultiPersonWordByWord:
|
||||||
|
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
|
||||||
|
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+63
-45
@@ -53,50 +53,68 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
localLibraryShowDuplicates:
|
localLibraryShowDuplicates:
|
||||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||||
|
lyricsProviders:
|
||||||
|
(json['lyricsProviders'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
|
||||||
|
lyricsIncludeTranslationNetease:
|
||||||
|
json['lyricsIncludeTranslationNetease'] as bool? ?? false,
|
||||||
|
lyricsIncludeRomanizationNetease:
|
||||||
|
json['lyricsIncludeRomanizationNetease'] as bool? ?? false,
|
||||||
|
lyricsMultiPersonWordByWord:
|
||||||
|
json['lyricsMultiPersonWordByWord'] as bool? ?? true,
|
||||||
|
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(
|
||||||
<String, dynamic>{
|
AppSettings instance,
|
||||||
'defaultService': instance.defaultService,
|
) => <String, dynamic>{
|
||||||
'audioQuality': instance.audioQuality,
|
'defaultService': instance.defaultService,
|
||||||
'filenameFormat': instance.filenameFormat,
|
'audioQuality': instance.audioQuality,
|
||||||
'downloadDirectory': instance.downloadDirectory,
|
'filenameFormat': instance.filenameFormat,
|
||||||
'storageMode': instance.storageMode,
|
'downloadDirectory': instance.downloadDirectory,
|
||||||
'downloadTreeUri': instance.downloadTreeUri,
|
'storageMode': instance.storageMode,
|
||||||
'autoFallback': instance.autoFallback,
|
'downloadTreeUri': instance.downloadTreeUri,
|
||||||
'embedLyrics': instance.embedLyrics,
|
'autoFallback': instance.autoFallback,
|
||||||
'maxQualityCover': instance.maxQualityCover,
|
'embedLyrics': instance.embedLyrics,
|
||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'maxQualityCover': instance.maxQualityCover,
|
||||||
'concurrentDownloads': instance.concurrentDownloads,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
'checkForUpdates': instance.checkForUpdates,
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
'updateChannel': instance.updateChannel,
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'updateChannel': instance.updateChannel,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||||
'filterContributingArtistsInAlbumArtist':
|
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||||
instance.filterContributingArtistsInAlbumArtist,
|
'filterContributingArtistsInAlbumArtist':
|
||||||
'historyViewMode': instance.historyViewMode,
|
instance.filterContributingArtistsInAlbumArtist,
|
||||||
'historyFilterMode': instance.historyFilterMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'historyFilterMode': instance.historyFilterMode,
|
||||||
'spotifyClientId': instance.spotifyClientId,
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'metadataSource': instance.metadataSource,
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
'enableLogging': instance.enableLogging,
|
'metadataSource': instance.metadataSource,
|
||||||
'useExtensionProviders': instance.useExtensionProviders,
|
'enableLogging': instance.enableLogging,
|
||||||
'searchProvider': instance.searchProvider,
|
'useExtensionProviders': instance.useExtensionProviders,
|
||||||
'separateSingles': instance.separateSingles,
|
'searchProvider': instance.searchProvider,
|
||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'separateSingles': instance.separateSingles,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'locale': instance.locale,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
'lyricsMode': instance.lyricsMode,
|
'locale': instance.locale,
|
||||||
'tidalHighFormat': instance.tidalHighFormat,
|
'lyricsMode': instance.lyricsMode,
|
||||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||||
'localLibraryPath': instance.localLibraryPath,
|
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
'localLibraryPath': instance.localLibraryPath,
|
||||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||||
};
|
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||||
|
'lyricsProviders': instance.lyricsProviders,
|
||||||
|
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
||||||
|
'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease,
|
||||||
|
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
|
||||||
|
'musixmatchLanguage': instance.musixmatchLanguage,
|
||||||
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Extension {
|
|||||||
final List<QualityOption> qualityOptions;
|
final List<QualityOption> qualityOptions;
|
||||||
final bool hasMetadataProvider;
|
final bool hasMetadataProvider;
|
||||||
final bool hasDownloadProvider;
|
final bool hasDownloadProvider;
|
||||||
|
final bool hasLyricsProvider;
|
||||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||||
final SearchBehavior? searchBehavior;
|
final SearchBehavior? searchBehavior;
|
||||||
final URLHandler? urlHandler;
|
final URLHandler? urlHandler;
|
||||||
@@ -49,6 +50,7 @@ class Extension {
|
|||||||
this.qualityOptions = const [],
|
this.qualityOptions = const [],
|
||||||
this.hasMetadataProvider = false,
|
this.hasMetadataProvider = false,
|
||||||
this.hasDownloadProvider = false,
|
this.hasDownloadProvider = false,
|
||||||
|
this.hasLyricsProvider = false,
|
||||||
this.skipMetadataEnrichment = false,
|
this.skipMetadataEnrichment = false,
|
||||||
this.searchBehavior,
|
this.searchBehavior,
|
||||||
this.urlHandler,
|
this.urlHandler,
|
||||||
@@ -78,6 +80,7 @@ class Extension {
|
|||||||
.toList() ?? [],
|
.toList() ?? [],
|
||||||
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
|
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
|
||||||
hasDownloadProvider: json['has_download_provider'] as bool? ?? false,
|
hasDownloadProvider: json['has_download_provider'] as bool? ?? false,
|
||||||
|
hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false,
|
||||||
skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false,
|
skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false,
|
||||||
searchBehavior: json['search_behavior'] != null
|
searchBehavior: json['search_behavior'] != null
|
||||||
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
||||||
@@ -111,6 +114,7 @@ class Extension {
|
|||||||
List<QualityOption>? qualityOptions,
|
List<QualityOption>? qualityOptions,
|
||||||
bool? hasMetadataProvider,
|
bool? hasMetadataProvider,
|
||||||
bool? hasDownloadProvider,
|
bool? hasDownloadProvider,
|
||||||
|
bool? hasLyricsProvider,
|
||||||
bool? skipMetadataEnrichment,
|
bool? skipMetadataEnrichment,
|
||||||
SearchBehavior? searchBehavior,
|
SearchBehavior? searchBehavior,
|
||||||
URLHandler? urlHandler,
|
URLHandler? urlHandler,
|
||||||
@@ -134,6 +138,7 @@ class Extension {
|
|||||||
qualityOptions: qualityOptions ?? this.qualityOptions,
|
qualityOptions: qualityOptions ?? this.qualityOptions,
|
||||||
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
|
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
|
||||||
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
||||||
|
hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider,
|
||||||
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||||
urlHandler: urlHandler ?? this.urlHandler,
|
urlHandler: urlHandler ?? this.urlHandler,
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
|
|
||||||
LogBuffer.loggingEnabled = state.enableLogging;
|
LogBuffer.loggingEnabled = state.enableLogging;
|
||||||
|
|
||||||
|
_syncLyricsSettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncLyricsSettingsToBackend() {
|
||||||
|
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
|
||||||
|
_log.w('Failed to sync lyrics providers to backend: $e');
|
||||||
|
});
|
||||||
|
|
||||||
|
PlatformBridge.setLyricsFetchOptions({
|
||||||
|
'include_translation_netease': state.lyricsIncludeTranslationNetease,
|
||||||
|
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
|
||||||
|
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
|
||||||
|
'musixmatch_language': state.musixmatchLanguage,
|
||||||
|
}).catchError((e) {
|
||||||
|
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||||
@@ -188,6 +205,36 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setLyricsProviders(List<String> providers) {
|
||||||
|
state = state.copyWith(lyricsProviders: providers);
|
||||||
|
_saveSettings();
|
||||||
|
_syncLyricsSettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLyricsIncludeTranslationNetease(bool enabled) {
|
||||||
|
state = state.copyWith(lyricsIncludeTranslationNetease: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
_syncLyricsSettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLyricsIncludeRomanizationNetease(bool enabled) {
|
||||||
|
state = state.copyWith(lyricsIncludeRomanizationNetease: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
_syncLyricsSettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLyricsMultiPersonWordByWord(bool enabled) {
|
||||||
|
state = state.copyWith(lyricsMultiPersonWordByWord: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
_syncLyricsSettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMusixmatchLanguage(String languageCode) {
|
||||||
|
state = state.copyWith(musixmatchLanguage: languageCode.trim().toLowerCase());
|
||||||
|
_saveSettings();
|
||||||
|
_syncLyricsSettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
void setMaxQualityCover(bool enabled) {
|
void setMaxQualityCover(bool enabled) {
|
||||||
state = state.copyWith(maxQualityCover: enabled);
|
state = state.copyWith(maxQualityCover: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -141,6 +141,14 @@ class AboutPage extends StatelessWidget {
|
|||||||
title: context.l10n.aboutSpotiSaver,
|
title: context.l10n.aboutSpotiSaver,
|
||||||
subtitle: context.l10n.aboutSpotiSaverDesc,
|
subtitle: context.l10n.aboutSpotiSaverDesc,
|
||||||
onTap: () => _launchUrl('https://spotisaver.net'),
|
onTap: () => _launchUrl('https://spotisaver.net'),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_AboutSettingsItem(
|
||||||
|
icon: Icons.lyrics_outlined,
|
||||||
|
title: 'Paxsenix',
|
||||||
|
subtitle:
|
||||||
|
'Partner lyrics proxy for Apple Music and QQ Music sources',
|
||||||
|
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ class DonatePage extends StatelessWidget {
|
|||||||
// Combined notice card
|
// Combined notice card
|
||||||
Card(
|
Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
|
color: colorScheme.secondaryContainer.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
@@ -98,7 +100,8 @@ class DonatePage extends StatelessWidget {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_NoticeLine(
|
_NoticeLine(
|
||||||
icon: Icons.block,
|
icon: Icons.block,
|
||||||
text: 'Not selling early access, premium features, or paywalls',
|
text:
|
||||||
|
'Not selling early access, premium features, or paywalls',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -110,36 +113,40 @@ class DonatePage extends StatelessWidget {
|
|||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_NoticeLine(
|
_NoticeLine(
|
||||||
icon: Icons.favorite_border,
|
icon: Icons.favorite_border,
|
||||||
text: 'Your support is the only way to keep this project alive',
|
text:
|
||||||
|
'Your support is the only way to keep this project alive',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
Divider(
|
Divider(
|
||||||
height: 24,
|
height: 24,
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
color: colorScheme.outlineVariant.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_NoticeLine(
|
_NoticeLine(
|
||||||
icon: Icons.history,
|
icon: Icons.history,
|
||||||
text: 'Your name stays permanently in every version it was included in',
|
text:
|
||||||
|
'Your name stays permanently in every version it was included in',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_NoticeLine(
|
_NoticeLine(
|
||||||
icon: Icons.update,
|
icon: Icons.update,
|
||||||
text: 'Supporter list is updated monthly and embedded in the app',
|
text:
|
||||||
|
'Supporter list is updated monthly and embedded in the app',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_NoticeLine(
|
_NoticeLine(
|
||||||
icon: Icons.cloud_off,
|
icon: Icons.cloud_off,
|
||||||
text: 'No remote server -- everything is stored locally',
|
text:
|
||||||
|
'No remote server -- everything is stored locally',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -205,6 +212,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
|||||||
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
||||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
||||||
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
|
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
|
||||||
|
_DonorTile(name: 'laflame', colorScheme: colorScheme),
|
||||||
_DonorTile(
|
_DonorTile(
|
||||||
name: 'Elias el Autentico',
|
name: 'Elias el Autentico',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
@@ -414,9 +422,9 @@ class _NoticeLine extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(
|
||||||
color: colorScheme.onSurface,
|
context,
|
||||||
),
|
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
|||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||||
@@ -279,6 +280,62 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
ref,
|
ref,
|
||||||
settings.lyricsMode,
|
settings.lyricsMode,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.source_outlined,
|
||||||
|
title: 'Lyrics Providers',
|
||||||
|
subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders),
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const LyricsProviderPriorityPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.translate_outlined,
|
||||||
|
title: 'Netease: Include Translation',
|
||||||
|
subtitle: settings.lyricsIncludeTranslationNetease
|
||||||
|
? 'Append translated lyrics when available'
|
||||||
|
: 'Use original lyrics only',
|
||||||
|
value: settings.lyricsIncludeTranslationNetease,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setLyricsIncludeTranslationNetease(value),
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.text_fields_outlined,
|
||||||
|
title: 'Netease: Include Romanization',
|
||||||
|
subtitle: settings.lyricsIncludeRomanizationNetease
|
||||||
|
? 'Append romanized lyrics when available'
|
||||||
|
: 'Disabled',
|
||||||
|
value: settings.lyricsIncludeRomanizationNetease,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setLyricsIncludeRomanizationNetease(value),
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.record_voice_over_outlined,
|
||||||
|
title: 'Apple/QQ Multi-Person Word-by-Word',
|
||||||
|
subtitle: settings.lyricsMultiPersonWordByWord
|
||||||
|
? 'Enable v1/v2 speaker and [bg:] tags'
|
||||||
|
: 'Simplified word-by-word formatting',
|
||||||
|
value: settings.lyricsMultiPersonWordByWord,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setLyricsMultiPersonWordByWord(value),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.language_outlined,
|
||||||
|
title: 'Musixmatch Language',
|
||||||
|
subtitle: settings.musixmatchLanguage.isEmpty
|
||||||
|
? 'Auto (original)'
|
||||||
|
: settings.musixmatchLanguage.toUpperCase(),
|
||||||
|
onTap: () => _showMusixmatchLanguagePicker(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.musixmatchLanguage,
|
||||||
|
),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1183,6 +1240,111 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _providerDisplayNames = <String, String>{
|
||||||
|
'lrclib': 'LRCLIB',
|
||||||
|
'netease': 'Netease',
|
||||||
|
'musixmatch': 'Musixmatch',
|
||||||
|
'apple_music': 'Apple Music',
|
||||||
|
'qqmusic': 'QQ Music',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _getLyricsProvidersSubtitle(List<String> providers) {
|
||||||
|
if (providers.isEmpty) return 'None enabled';
|
||||||
|
return providers
|
||||||
|
.map((p) => _providerDisplayNames[p] ?? p)
|
||||||
|
.join(' > ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeMusixmatchLanguage(String value) {
|
||||||
|
final normalized = value.trim().toLowerCase();
|
||||||
|
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMusixmatchLanguagePicker(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String currentLanguage,
|
||||||
|
) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final controller = TextEditingController(text: currentLanguage);
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
top: 24,
|
||||||
|
bottom: 24 + MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Musixmatch Language',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Set preferred language code (example: en, es, ja). Leave empty for auto.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Language code',
|
||||||
|
hintText: 'auto / en / es / ja',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(context.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(settingsProvider.notifier).setMusixmatchLanguage('');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Auto'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final normalized = _normalizeMusixmatchLanguage(
|
||||||
|
controller.text,
|
||||||
|
);
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setMusixmatchLanguage(normalized);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.dialogSave),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _getTidalHighFormatLabel(String format) {
|
String _getTidalHighFormatLabel(String format) {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'mp3_320':
|
case 'mp3_320':
|
||||||
|
|||||||
@@ -218,6 +218,11 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
title: context.l10n.extensionDownloadProvider,
|
title: context.l10n.extensionDownloadProvider,
|
||||||
enabled: extension.hasDownloadProvider,
|
enabled: extension.hasDownloadProvider,
|
||||||
),
|
),
|
||||||
|
_CapabilityItem(
|
||||||
|
icon: Icons.lyrics,
|
||||||
|
title: context.l10n.extensionLyricsProvider,
|
||||||
|
enabled: extension.hasLyricsProvider,
|
||||||
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.manage_search,
|
icon: Icons.manage_search,
|
||||||
title: context.l10n.extensionsSearchProvider,
|
title: context.l10n.extensionsSearchProvider,
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class LyricsProviderPriorityPage extends ConsumerStatefulWidget {
|
||||||
|
const LyricsProviderPriorityPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<LyricsProviderPriorityPage> createState() =>
|
||||||
|
_LyricsProviderPriorityPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LyricsProviderPriorityPageState
|
||||||
|
extends ConsumerState<LyricsProviderPriorityPage> {
|
||||||
|
static const _allProviderIds = [
|
||||||
|
'lrclib',
|
||||||
|
'netease',
|
||||||
|
'musixmatch',
|
||||||
|
'apple_music',
|
||||||
|
'qqmusic',
|
||||||
|
];
|
||||||
|
|
||||||
|
late List<String> _enabledProviders;
|
||||||
|
late List<String> _initialProviders;
|
||||||
|
bool _hasChanges = false;
|
||||||
|
|
||||||
|
List<String> get _disabledProviders => _allProviderIds
|
||||||
|
.where((id) => !_enabledProviders.contains(id))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
_enabledProviders = List.from(settings.lyricsProviders);
|
||||||
|
_initialProviders = List.from(settings.lyricsProviders);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markChanged() {
|
||||||
|
final changed = _enabledProviders.length != _initialProviders.length ||
|
||||||
|
!_enabledProviders
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.every((e) =>
|
||||||
|
e.key < _initialProviders.length &&
|
||||||
|
_initialProviders[e.key] == e.value);
|
||||||
|
setState(() => _hasChanges = changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
final disabled = _disabledProviders;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_hasChanges,
|
||||||
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
|
if (didPop) return;
|
||||||
|
final shouldPop = await _confirmDiscard(context);
|
||||||
|
if (shouldPop && context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
// ── Collapsing App Bar ──
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 120 + topPadding,
|
||||||
|
collapsedHeight: kToolbarHeight,
|
||||||
|
floating: false,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () async {
|
||||||
|
if (_hasChanges) {
|
||||||
|
final shouldPop = await _confirmDiscard(context);
|
||||||
|
if (shouldPop && context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_hasChanges)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saveChanges,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxHeight = 120 + topPadding;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||||
|
(maxHeight - minHeight))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
final leftPadding = 56 - (32 * expandRatio);
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding:
|
||||||
|
EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
|
title: Text(
|
||||||
|
'Lyrics Providers',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Description ──
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
'Enable, disable and reorder lyrics sources. '
|
||||||
|
'Providers are tried top-to-bottom until lyrics are found.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Enabled section header ──
|
||||||
|
if (_enabledProviders.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(
|
||||||
|
title: 'Enabled (${_enabledProviders.length})',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Reorderable enabled list ──
|
||||||
|
if (_enabledProviders.isNotEmpty)
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverReorderableList(
|
||||||
|
itemCount: _enabledProviders.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final id = _enabledProviders[index];
|
||||||
|
final info = _getLyricsProviderInfo(id);
|
||||||
|
return _EnabledProviderItem(
|
||||||
|
key: ValueKey(id),
|
||||||
|
providerId: id,
|
||||||
|
info: info,
|
||||||
|
index: index,
|
||||||
|
isFirst: index == 0,
|
||||||
|
onToggle: () => _disableProvider(id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onReorder: (oldIndex, newIndex) {
|
||||||
|
setState(() {
|
||||||
|
if (newIndex > oldIndex) newIndex -= 1;
|
||||||
|
final item = _enabledProviders.removeAt(oldIndex);
|
||||||
|
_enabledProviders.insert(newIndex, item);
|
||||||
|
});
|
||||||
|
_markChanged();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Disabled section header ──
|
||||||
|
if (disabled.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(
|
||||||
|
title: 'Disabled (${disabled.length})',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Disabled list ──
|
||||||
|
if (disabled.isNotEmpty)
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final id = disabled[index];
|
||||||
|
final info = _getLyricsProviderInfo(id);
|
||||||
|
return _DisabledProviderItem(
|
||||||
|
key: ValueKey(id),
|
||||||
|
providerId: id,
|
||||||
|
info: info,
|
||||||
|
onToggle: () => _enableProvider(id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: disabled.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Info banner ──
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline,
|
||||||
|
size: 20, color: colorScheme.tertiary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Extension lyrics providers always run before '
|
||||||
|
'built-in providers. At least one provider must '
|
||||||
|
'remain enabled.',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State mutations ──
|
||||||
|
|
||||||
|
void _enableProvider(String id) {
|
||||||
|
setState(() => _enabledProviders.add(id));
|
||||||
|
_markChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disableProvider(String id) {
|
||||||
|
if (_enabledProviders.length <= 1) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('At least one provider must remain enabled'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _enabledProviders.remove(id));
|
||||||
|
_markChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save / Discard ──
|
||||||
|
|
||||||
|
Future<void> _saveChanges() async {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setLyricsProviders(List<String>.from(_enabledProviders));
|
||||||
|
setState(() {
|
||||||
|
_initialProviders = List.from(_enabledProviders);
|
||||||
|
_hasChanges = false;
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Lyrics provider priority saved')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Discard changes?'),
|
||||||
|
content:
|
||||||
|
const Text('You have unsaved changes that will be lost.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Discard'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Provider metadata ──
|
||||||
|
|
||||||
|
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
|
||||||
|
switch (id) {
|
||||||
|
case 'lrclib':
|
||||||
|
return _LyricsProviderInfo(
|
||||||
|
name: 'LRCLIB',
|
||||||
|
description: 'Open-source synced lyrics database',
|
||||||
|
icon: Icons.subtitles_outlined,
|
||||||
|
);
|
||||||
|
case 'netease':
|
||||||
|
return _LyricsProviderInfo(
|
||||||
|
name: 'Netease',
|
||||||
|
description: 'NetEase Cloud Music (good for Asian songs)',
|
||||||
|
icon: Icons.cloud_outlined,
|
||||||
|
);
|
||||||
|
case 'musixmatch':
|
||||||
|
return _LyricsProviderInfo(
|
||||||
|
name: 'Musixmatch',
|
||||||
|
description: 'Largest lyrics database (multi-language)',
|
||||||
|
icon: Icons.translate,
|
||||||
|
);
|
||||||
|
case 'apple_music':
|
||||||
|
return _LyricsProviderInfo(
|
||||||
|
name: 'Apple Music',
|
||||||
|
description: 'Word-by-word synced lyrics (via proxy)',
|
||||||
|
icon: Icons.music_note,
|
||||||
|
);
|
||||||
|
case 'qqmusic':
|
||||||
|
return _LyricsProviderInfo(
|
||||||
|
name: 'QQ Music',
|
||||||
|
description: 'QQ Music (good for Chinese songs, via proxy)',
|
||||||
|
icon: Icons.queue_music,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return _LyricsProviderInfo(
|
||||||
|
name: id,
|
||||||
|
description: 'Extension provider',
|
||||||
|
icon: Icons.extension,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Enabled provider card (reorderable)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class _EnabledProviderItem extends StatelessWidget {
|
||||||
|
final String providerId;
|
||||||
|
final _LyricsProviderInfo info;
|
||||||
|
final int index;
|
||||||
|
final bool isFirst;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
|
||||||
|
const _EnabledProviderItem({
|
||||||
|
super.key,
|
||||||
|
required this.providerId,
|
||||||
|
required this.info,
|
||||||
|
required this.index,
|
||||||
|
required this.isFirst,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final backgroundColor = isDark
|
||||||
|
? Color.alphaBlend(
|
||||||
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Material(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: ReorderableDragStartListener(
|
||||||
|
index: index,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Numbered badge
|
||||||
|
Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isFirst
|
||||||
|
? colorScheme.primaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'${index + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isFirst
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Icon
|
||||||
|
Icon(info.icon, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Name + description
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
info.name,
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
info.description,
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Enable/disable switch
|
||||||
|
SizedBox(
|
||||||
|
height: 32,
|
||||||
|
child: FittedBox(
|
||||||
|
child: Switch(
|
||||||
|
value: true,
|
||||||
|
onChanged: (_) => onToggle(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
// Drag handle
|
||||||
|
Icon(
|
||||||
|
Icons.drag_handle,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Disabled provider card
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class _DisabledProviderItem extends StatelessWidget {
|
||||||
|
final String providerId;
|
||||||
|
final _LyricsProviderInfo info;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
|
||||||
|
const _DisabledProviderItem({
|
||||||
|
super.key,
|
||||||
|
required this.providerId,
|
||||||
|
required this.info,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final backgroundColor = isDark
|
||||||
|
? Color.alphaBlend(
|
||||||
|
Colors.white.withValues(alpha: 0.03),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: colorScheme.surfaceContainerLow;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.6,
|
||||||
|
child: Material(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: onToggle,
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Empty space aligned with numbered badge
|
||||||
|
const SizedBox(width: 28),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Icon (muted)
|
||||||
|
Icon(info.icon, color: colorScheme.outline),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Name + description
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
info.name,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyLarge
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
info.description,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Switch
|
||||||
|
SizedBox(
|
||||||
|
height: 32,
|
||||||
|
child: FittedBox(
|
||||||
|
child: Switch(
|
||||||
|
value: false,
|
||||||
|
onChanged: (_) => onToggle(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Provider info model
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class _LyricsProviderInfo {
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _LyricsProviderInfo({
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -56,6 +57,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
||||||
bool _lyricsLoading = false;
|
bool _lyricsLoading = false;
|
||||||
String? _lyricsError;
|
String? _lyricsError;
|
||||||
|
String? _lyricsSource;
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
|
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
|
||||||
bool _isEmbedding = false; // Track embed operation in progress
|
bool _isEmbedding = false; // Track embed operation in progress
|
||||||
@@ -69,6 +71,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
|
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
|
||||||
);
|
);
|
||||||
static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$');
|
static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$');
|
||||||
|
static final RegExp _lrcInlineTimestampPattern = RegExp(
|
||||||
|
r'<\d{2}:\d{2}\.\d{2,3}>',
|
||||||
|
);
|
||||||
|
static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*');
|
||||||
|
static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$');
|
||||||
static const List<String> _months = [
|
static const List<String> _months = [
|
||||||
'Jan',
|
'Jan',
|
||||||
'Feb',
|
'Feb',
|
||||||
@@ -1339,6 +1346,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (_lyricsSource != null && _lyricsSource!.trim().isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'Source: ${_lyricsSource!}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
if (_lyricsLoading)
|
if (_lyricsLoading)
|
||||||
@@ -1460,6 +1477,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_lyricsLoading = true;
|
_lyricsLoading = true;
|
||||||
_lyricsError = null;
|
_lyricsError = null;
|
||||||
_isInstrumental = false;
|
_isInstrumental = false;
|
||||||
|
_lyricsSource = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1468,20 +1486,31 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
// First, check if lyrics are embedded in the file
|
// First, check if lyrics are embedded in the file
|
||||||
if (_fileExists) {
|
if (_fileExists) {
|
||||||
final embeddedResult = await PlatformBridge.getLyricsLRC(
|
final embeddedResult =
|
||||||
'',
|
await PlatformBridge.getLyricsLRCWithSource(
|
||||||
trackName,
|
'',
|
||||||
artistName,
|
trackName,
|
||||||
filePath: cleanFilePath,
|
artistName,
|
||||||
durationMs: 0,
|
filePath: cleanFilePath,
|
||||||
).timeout(const Duration(seconds: 5), onTimeout: () => '');
|
durationMs: 0,
|
||||||
|
).timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
|
||||||
|
);
|
||||||
|
|
||||||
if (embeddedResult.isNotEmpty) {
|
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
|
||||||
|
final embeddedSource = embeddedResult['source']?.toString() ?? '';
|
||||||
|
|
||||||
|
if (embeddedLyrics.isNotEmpty) {
|
||||||
// Lyrics found in file
|
// Lyrics found in file
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final cleanLyrics = _cleanLrcForDisplay(embeddedResult);
|
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyrics = cleanLyrics;
|
_lyrics = cleanLyrics;
|
||||||
|
_rawLyrics = embeddedLyrics;
|
||||||
|
_lyricsSource = embeddedSource.isNotEmpty
|
||||||
|
? embeddedSource
|
||||||
|
: 'Embedded';
|
||||||
_lyricsEmbedded = true;
|
_lyricsEmbedded = true;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
@@ -1491,43 +1520,55 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No embedded lyrics, fetch from online
|
// No embedded lyrics, fetch from online
|
||||||
final result = await PlatformBridge.getLyricsLRC(
|
final result = await PlatformBridge.getLyricsLRCWithSource(
|
||||||
_spotifyId ?? '',
|
_spotifyId ?? '',
|
||||||
trackName,
|
trackName,
|
||||||
artistName,
|
artistName,
|
||||||
filePath: null, // Don't check file again
|
filePath: null, // Don't check file again
|
||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
).timeout(const Duration(seconds: 20), onTimeout: () => '');
|
).timeout(const Duration(seconds: 20));
|
||||||
|
|
||||||
|
final lrcText = result['lyrics']?.toString() ?? '';
|
||||||
|
final source = result['source']?.toString() ?? '';
|
||||||
|
final instrumental =
|
||||||
|
(result['instrumental'] as bool? ?? false) ||
|
||||||
|
lrcText == '[instrumental:true]';
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Check for instrumental marker
|
// Check for instrumental marker
|
||||||
if (result == '[instrumental:true]') {
|
if (instrumental) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isInstrumental = true;
|
_isInstrumental = true;
|
||||||
|
_lyricsSource = source.isNotEmpty ? source : null;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
} else if (result.isEmpty) {
|
} else if (lrcText.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
final cleanLyrics = _cleanLrcForDisplay(result);
|
final cleanLyrics = _cleanLrcForDisplay(lrcText);
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyrics = cleanLyrics;
|
_lyrics = cleanLyrics;
|
||||||
_rawLyrics = result; // Keep raw LRC with timestamps for embedding
|
_rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding
|
||||||
|
_lyricsSource = source.isNotEmpty ? source : null;
|
||||||
_lyricsEmbedded = false; // Lyrics from online, not embedded
|
_lyricsEmbedded = false; // Lyrics from online, not embedded
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} on TimeoutException {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_lyricsError = context.l10n.trackLyricsTimeout;
|
||||||
|
_lyricsLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final errorMsg = e.toString().contains('TimeoutException')
|
|
||||||
? context.l10n.trackLyricsTimeout
|
|
||||||
: context.l10n.trackLyricsLoadFailed;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = errorMsg;
|
_lyricsError = context.l10n.trackLyricsLoadFailed;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2213,17 +2254,28 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
final cleanLines = <String>[];
|
final cleanLines = <String>[];
|
||||||
|
|
||||||
for (final line in lines) {
|
for (final line in lines) {
|
||||||
final trimmedLine = line.trim();
|
var cleaned = line.trim();
|
||||||
|
|
||||||
// Skip metadata tags
|
// Skip metadata tags
|
||||||
if (_lrcMetadataPattern.hasMatch(trimmedLine)) {
|
if (_lrcMetadataPattern.hasMatch(cleaned) &&
|
||||||
|
!_lrcBackgroundLinePattern.hasMatch(cleaned)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove timestamp and clean up
|
// Convert [bg:...] wrapper to a plain secondary vocal line.
|
||||||
final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim();
|
final bgMatch = _lrcBackgroundLinePattern.firstMatch(cleaned);
|
||||||
if (cleanLine.isNotEmpty) {
|
if (bgMatch != null) {
|
||||||
cleanLines.add(cleanLine);
|
cleaned = bgMatch.group(1)?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove line timestamp, inline word-by-word timestamps, and speaker prefix.
|
||||||
|
cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim();
|
||||||
|
cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, '');
|
||||||
|
cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, '');
|
||||||
|
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
|
||||||
|
if (cleaned.isNotEmpty) {
|
||||||
|
cleanLines.add(cleaned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,6 +276,23 @@ class PlatformBridge {
|
|||||||
return result as String;
|
return result as String;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> getLyricsLRCWithSource(
|
||||||
|
String spotifyId,
|
||||||
|
String trackName,
|
||||||
|
String artistName, {
|
||||||
|
String? filePath,
|
||||||
|
int durationMs = 0,
|
||||||
|
}) async {
|
||||||
|
final result = await _channel.invokeMethod('getLyricsLRCWithSource', {
|
||||||
|
'spotify_id': spotifyId,
|
||||||
|
'track_name': trackName,
|
||||||
|
'artist_name': artistName,
|
||||||
|
'file_path': filePath ?? '',
|
||||||
|
'duration_ms': durationMs,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> embedLyricsToFile(
|
static Future<Map<String, dynamic>> embedLyricsToFile(
|
||||||
String filePath,
|
String filePath,
|
||||||
String lyrics,
|
String lyrics,
|
||||||
@@ -332,6 +349,47 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== LYRICS PROVIDER SETTINGS ====================
|
||||||
|
|
||||||
|
/// Sets the lyrics provider order. Providers not in the list are disabled.
|
||||||
|
static Future<void> setLyricsProviders(List<String> providers) async {
|
||||||
|
final providersJSON = jsonEncode(providers);
|
||||||
|
await _channel.invokeMethod('setLyricsProviders', {
|
||||||
|
'providers_json': providersJSON,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current lyrics provider order.
|
||||||
|
static Future<List<String>> getLyricsProviders() async {
|
||||||
|
final result = await _channel.invokeMethod('getLyricsProviders');
|
||||||
|
final List<dynamic> decoded = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return decoded.cast<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns metadata about all available lyrics providers.
|
||||||
|
static Future<List<Map<String, dynamic>>>
|
||||||
|
getAvailableLyricsProviders() async {
|
||||||
|
final result = await _channel.invokeMethod('getAvailableLyricsProviders');
|
||||||
|
final List<dynamic> decoded = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return decoded.cast<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets advanced lyrics fetch options used by provider-specific integrations.
|
||||||
|
static Future<void> setLyricsFetchOptions(
|
||||||
|
Map<String, dynamic> options,
|
||||||
|
) async {
|
||||||
|
final optionsJSON = jsonEncode(options);
|
||||||
|
await _channel.invokeMethod('setLyricsFetchOptions', {
|
||||||
|
'options_json': optionsJSON,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns current advanced lyrics fetch options.
|
||||||
|
static Future<Map<String, dynamic>> getLyricsFetchOptions() async {
|
||||||
|
final result = await _channel.invokeMethod('getLyricsFetchOptions');
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> reEnrichFile(
|
static Future<Map<String, dynamic>> reEnrichFile(
|
||||||
Map<String, dynamic> request,
|
Map<String, dynamic> request,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
+5871
File diff suppressed because one or more lines are too long
+272
-18
File diff suppressed because one or more lines are too long
+270
-19
File diff suppressed because one or more lines are too long
+287
-18
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user