Skip to content

Commit 45c36d6

Browse files
committed
Merge branch 'dev'
2 parents b39ec41 + 3ac9ff1 commit 45c36d6

28 files changed

+9564
-163
lines changed

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
# Changelog
22

3+
## [3.6.8] - 2026-02-14
4+
5+
### Added
6+
7+
- **Lyrics Source Tracking**: Track Metadata screen now displays the source of loaded lyrics (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music, Embedded, or Extension)
8+
- New `getLyricsLRCWithSource` API returns lyrics with source metadata
9+
- Source badge appears below lyrics section in Track Metadata screen
10+
- **Dedicated Lyrics Provider Priority Page**: Lyrics providers can now be configured from a dedicated settings page with full-screen reorderable list
11+
- Replaced inline bottom sheet with `LyricsProviderPriorityPage`
12+
- Cleaner UI with provider descriptions and priority ordering
13+
- **Paxsenix Integration**: Added Paxsenix API as official lyrics proxy partner for Apple Music, QQ Music, Musixmatch, and Netease sources
14+
- Listed in About page and Partners page on project site
15+
- README updated with partner attribution
16+
17+
### Fixed
18+
19+
- **LRC Background Vocal Preservation**: Apple Music/QQ Music `[bg:...]` background vocal tags are now preserved during LRC parsing instead of being stripped
20+
- Background vocals attach to the previous timed line in exported LRC files
21+
- **LRC Display Improvements**:
22+
- Inline word-by-word timestamps (`<mm:ss.xx>`) are stripped from lyrics display
23+
- Speaker prefixes (`v1:`, `v2:`) are removed for cleaner display
24+
- Multi-line background vocals converted to readable secondary vocal lines
25+
- **Apple Music Lyrics Case Sensitivity**: Fixed `lyricsType` comparison to use case-insensitive matching for "Syllable" type
26+
27+
### Changed
28+
29+
- Track Metadata lyrics fetching now uses `getLyricsLRCWithSource` for consistent source attribution across embedded and online lyrics
30+
31+
---
32+
333
## [3.6.7] - 2026-02-13
434

535
### Added
@@ -16,6 +46,20 @@
1646
- Project website with GitHub Pages deployment workflow
1747
- Mobile burger menu navigation for all site pages
1848
- Go filename template test suite
49+
- "Lyrics Provider" extension type - extensions can now provide lyrics (synced or plain text) via `fetchLyrics()` function
50+
- Lyrics provider extensions are called before built-in providers, giving extensions highest priority
51+
- New `lyrics_provider` manifest type alongside `metadata_provider` and `download_provider`
52+
- Shows "Lyrics Provider" capability badge on extension detail page
53+
- "Lyrics Providers" settings - configurable provider cascade order and per-provider options
54+
- Reorderable provider list: LRCLIB, Musixmatch, Netease, Apple Music, QQ Music
55+
- Netease: toggle translated/romanized lyrics appending
56+
- Apple Music / QQ Music: multi-person word-by-word speaker tags
57+
- Musixmatch: selectable language code for localized lyrics
58+
- "Documentation Search" - global search modal on all site pages
59+
- Opens with Ctrl+K / Cmd+K / `/` keyboard shortcuts on every page
60+
- Search button with bordered pill styling in desktop nav and mobile hamburger menu
61+
- On non-docs pages, search results navigate to the docs page at the matching section
62+
- Full keyboard navigation: arrow keys, Enter to select, Esc to close
1963

2064
### Fixed
2165

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ The software is provided "as is", without warranty of any kind. The author assum
9797
- **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)
9898
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
9999
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
100-
- **Lyrics**: [LRCLib](https://lrclib.net)
100+
- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy)
101101
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
102102
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
103103

android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,32 @@ class MainActivity: FlutterFragmentActivity() {
15821582
}
15831583
result.success(response)
15841584
}
1585+
"getLyricsLRCWithSource" -> {
1586+
val spotifyId = call.argument<String>("spotify_id") ?: ""
1587+
val trackName = call.argument<String>("track_name") ?: ""
1588+
val artistName = call.argument<String>("artist_name") ?: ""
1589+
val filePath = call.argument<String>("file_path") ?: ""
1590+
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
1591+
val response = withContext(Dispatchers.IO) {
1592+
if (filePath.startsWith("content://")) {
1593+
val tempPath = copyUriToTemp(Uri.parse(filePath))
1594+
if (tempPath == null) {
1595+
"""{"lyrics":"","source":"","sync_type":"","instrumental":false}"""
1596+
} else {
1597+
try {
1598+
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, tempPath, durationMs)
1599+
} finally {
1600+
try {
1601+
File(tempPath).delete()
1602+
} catch (_: Exception) {}
1603+
}
1604+
}
1605+
} else {
1606+
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs)
1607+
}
1608+
}
1609+
result.success(response)
1610+
}
15851611
"embedLyricsToFile" -> {
15861612
val filePath = call.argument<String>("file_path") ?: ""
15871613
val lyrics = call.argument<String>("lyrics") ?: ""
@@ -1756,6 +1782,60 @@ class MainActivity: FlutterFragmentActivity() {
17561782
}
17571783
result.success(response)
17581784
}
1785+
"setLyricsProviders" -> {
1786+
val providersJson = call.argument<String>("providers_json") ?: "[]"
1787+
val response = withContext(Dispatchers.IO) {
1788+
try {
1789+
Gobackend.setLyricsProvidersJSON(providersJson)
1790+
"""{"success":true}"""
1791+
} catch (e: Exception) {
1792+
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
1793+
}
1794+
}
1795+
result.success(response)
1796+
}
1797+
"getLyricsProviders" -> {
1798+
val response = withContext(Dispatchers.IO) {
1799+
try {
1800+
Gobackend.getLyricsProvidersJSON()
1801+
} catch (e: Exception) {
1802+
"[]"
1803+
}
1804+
}
1805+
result.success(response)
1806+
}
1807+
"getAvailableLyricsProviders" -> {
1808+
val response = withContext(Dispatchers.IO) {
1809+
try {
1810+
Gobackend.getAvailableLyricsProvidersJSON()
1811+
} catch (e: Exception) {
1812+
"[]"
1813+
}
1814+
}
1815+
result.success(response)
1816+
}
1817+
"setLyricsFetchOptions" -> {
1818+
val optionsJson = call.argument<String>("options_json") ?: "{}"
1819+
val response = withContext(Dispatchers.IO) {
1820+
try {
1821+
Gobackend.setLyricsFetchOptionsJSON(optionsJson)
1822+
"""{"success":true}"""
1823+
} catch (e: Exception) {
1824+
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
1825+
}
1826+
}
1827+
result.success(response)
1828+
}
1829+
"getLyricsFetchOptions" -> {
1830+
val response = withContext(Dispatchers.IO) {
1831+
try {
1832+
Gobackend.getLyricsFetchOptionsJSON()
1833+
} catch (e: Exception) {
1834+
"{}"
1835+
}
1836+
}
1837+
result.success(response)
1838+
}
17591839
"reEnrichFile" -> {
17601840
val requestJson = call.argument<String>("request_json") ?: "{}"
17611841
val response = withContext(Dispatchers.IO) {

go_backend/exports.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,64 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
10081008
return lrcContent, nil
10091009
}
10101010

1011+
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
1012+
if filePath != "" {
1013+
lyrics, err := ExtractLyrics(filePath)
1014+
if err == nil && lyrics != "" {
1015+
result := map[string]interface{}{
1016+
"lyrics": lyrics,
1017+
"source": "Embedded",
1018+
"sync_type": "EMBEDDED",
1019+
"instrumental": false,
1020+
}
1021+
jsonBytes, err := json.Marshal(result)
1022+
if err != nil {
1023+
return "", err
1024+
}
1025+
return string(jsonBytes), nil
1026+
}
1027+
1028+
result := map[string]interface{}{
1029+
"lyrics": "",
1030+
"source": "",
1031+
"sync_type": "",
1032+
"instrumental": false,
1033+
}
1034+
jsonBytes, err := json.Marshal(result)
1035+
if err != nil {
1036+
return "", err
1037+
}
1038+
return string(jsonBytes), nil
1039+
}
1040+
1041+
client := NewLyricsClient()
1042+
durationSec := float64(durationMs) / 1000.0
1043+
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
1044+
if err != nil {
1045+
return "", err
1046+
}
1047+
1048+
lrcContent := ""
1049+
if lyricsData.Instrumental {
1050+
lrcContent = "[instrumental:true]"
1051+
} else {
1052+
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
1053+
}
1054+
1055+
result := map[string]interface{}{
1056+
"lyrics": lrcContent,
1057+
"source": lyricsData.Source,
1058+
"sync_type": lyricsData.SyncType,
1059+
"instrumental": lyricsData.Instrumental,
1060+
}
1061+
jsonBytes, err := json.Marshal(result)
1062+
if err != nil {
1063+
return "", err
1064+
}
1065+
1066+
return string(jsonBytes), nil
1067+
}
1068+
10111069
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
10121070
err := EmbedLyrics(filePath, lyrics)
10131071
if err != nil {
@@ -1599,6 +1657,62 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6
15991657
return nil
16001658
}
16011659

1660+
// ==================== LYRICS PROVIDER SETTINGS ====================
1661+
1662+
// SetLyricsProvidersJSON sets the lyrics provider order from a JSON array of provider IDs.
1663+
func SetLyricsProvidersJSON(providersJSON string) error {
1664+
var providers []string
1665+
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
1666+
return err
1667+
}
1668+
1669+
SetLyricsProviderOrder(providers)
1670+
return nil
1671+
}
1672+
1673+
// GetLyricsProvidersJSON returns the current lyrics provider order as JSON.
1674+
func GetLyricsProvidersJSON() (string, error) {
1675+
providers := GetLyricsProviderOrder()
1676+
jsonBytes, err := json.Marshal(providers)
1677+
if err != nil {
1678+
return "", err
1679+
}
1680+
return string(jsonBytes), nil
1681+
}
1682+
1683+
// GetAvailableLyricsProvidersJSON returns metadata about all available lyrics providers.
1684+
func GetAvailableLyricsProvidersJSON() (string, error) {
1685+
providers := GetAvailableLyricsProviders()
1686+
jsonBytes, err := json.Marshal(providers)
1687+
if err != nil {
1688+
return "", err
1689+
}
1690+
return string(jsonBytes), nil
1691+
}
1692+
1693+
// SetLyricsFetchOptionsJSON sets lyrics provider fetch options.
1694+
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
1695+
opts := GetLyricsFetchOptions()
1696+
if strings.TrimSpace(optionsJSON) != "" {
1697+
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
1698+
return err
1699+
}
1700+
}
1701+
1702+
SetLyricsFetchOptions(opts)
1703+
return nil
1704+
}
1705+
1706+
// GetLyricsFetchOptionsJSON returns current lyrics provider fetch options.
1707+
func GetLyricsFetchOptionsJSON() (string, error) {
1708+
opts := GetLyricsFetchOptions()
1709+
jsonBytes, err := json.Marshal(opts)
1710+
if err != nil {
1711+
return "", err
1712+
}
1713+
return string(jsonBytes), nil
1714+
}
1715+
16021716
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
16031717
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
16041718
// complete metadata from the internet before embedding.

go_backend/extension_manager.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
713713
Permissions []string `json:"permissions"`
714714
HasMetadataProvider bool `json:"has_metadata_provider"`
715715
HasDownloadProvider bool `json:"has_download_provider"`
716+
HasLyricsProvider bool `json:"has_lyrics_provider"`
716717
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
717718
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
718719
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
@@ -770,6 +771,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
770771
Permissions: permissions,
771772
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
772773
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
774+
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
773775
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
774776
SearchBehavior: ext.Manifest.SearchBehavior,
775777
TrackMatching: ext.Manifest.TrackMatching,

go_backend/extension_manifest.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type ExtensionType string
1212
const (
1313
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
1414
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
15+
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
1516
)
1617

1718
type SettingType string
@@ -167,10 +168,10 @@ func (m *ExtensionManifest) Validate() error {
167168
}
168169

169170
for _, t := range m.Types {
170-
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
171+
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
171172
return &ManifestValidationError{
172173
Field: "type",
173-
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
174+
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
174175
}
175176
}
176177
}
@@ -226,6 +227,10 @@ func (m *ExtensionManifest) IsDownloadProvider() bool {
226227
return m.HasType(ExtensionTypeDownloadProvider)
227228
}
228229

230+
func (m *ExtensionManifest) IsLyricsProvider() bool {
231+
return m.HasType(ExtensionTypeLyricsProvider)
232+
}
233+
229234
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
230235
domain = strings.ToLower(strings.TrimSpace(domain))
231236
for _, allowed := range m.Permissions.Network {

0 commit comments

Comments
 (0)