Compare commits

...

36 Commits

Author SHA1 Message Date
zarzet 1ce66b9e03 fix: align ios deployment target for file picker 2026-06-02 01:09:53 +07:00
zarzet 8e68af79aa fix: prevent queue header action clipping 2026-06-02 00:58:43 +07:00
zarzet 6246e6e821 chore: update flutter and native dependencies 2026-06-02 00:58:42 +07:00
zarzet 421d5ffdc8 feat: polish search empty state and share caching 2026-06-02 00:58:42 +07:00
zarzet b82dabe316 fix: align cross-service sharing and fallback routing 2026-06-02 00:58:42 +07:00
zarzet ffdaf14ba5 feat: rebuild cross-extension sharing and queue controls
Co-authored-by: Amonoman <musaauron87@gmail.com>
2026-06-02 00:58:41 +07:00
zarzet f52527a41b chore: bump version to 4.5.6 (build 133) 2026-06-02 00:58:41 +07:00
zarzet 56a89c5fc6 fix: harden download errors and re-enrich sidecars 2026-06-02 00:58:40 +07:00
zarzet 4f5163be01 fix: resolve album-only autofill and placeholder re-enrich regressions
- Dart: _metadataMatchIsConfident now handles album-only case (title empty)
  by adding albumMatches fallback branch
- Go: selectBestReEnrichTrack treats placeholder values (Unknown Title,
  Unknown Artist) as empty via isPlaceholderReEnrichValue, so album-based
  fallback filtering works correctly
- Add test for placeholder album fallback in selectBestReEnrichTrack
2026-06-02 00:58:40 +07:00
zarzet 822c094c8c fix: stricter metadata matching, respect embedLyrics setting, improve Apple Music lyrics
- Re-enrich: reject candidates that don't match title/artist/album unless exact ISRC match
- Respect settings.embedLyrics instead of hardcoding true in re-enrich flows
- Skip lyrics resolution in NativeDownloadFinalizer when not needed
- Apple Music lyrics: use direct catalog API with token scraping instead of Paxsenix search
- Support ELRC/ELRCMultiPerson/Plain formats in Apple Music lyrics response
- Add confidence check in metadata auto-fill to prevent applying wrong metadata
- Add tests for stricter re-enrich matching logic
2026-06-02 00:58:40 +07:00
github-actions[bot] 0952b76e11 chore: update AltStore source to v4.5.5 2026-05-14 23:25:38 +00:00
zarzet 7291dbd9e2 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps.json
2026-05-15 06:11:51 +07:00
zarzet fb4cd75cb2 feat: expose audio codec in download result and skip lossy-to-lossless conversion
Go backend:
- Add AudioCodec field to DownloadResult and DownloadResponse
- Extension download results can now include audio_codec/audioCodec
- ffmpegGetInfo and probeAudioQuality now return codec field
- Add trackItemBytes option to file.download() for custom progress handling

Flutter:
- Check audio_codec before container conversion
- Skip FLAC conversion if source codec is lossy (AAC, MP3, Opus, etc.)
- Prevents fake upscale from lossy to lossless containers
2026-05-15 04:37:25 +07:00
zarzet 8b7cecc1c5 refactor: extract download progress label formatting
- Extract _formatDownloadProgressLabel() for cleaner code
- Show received/total size when bytesTotal is available
- Estimate total size from progress when only bytesReceived is known
- Add text overflow handling with ellipsis
2026-05-15 01:29:02 +07:00
zarzet 012dcdc2dd fix: native FLAC handling and extension API optimizations
Native FLAC handling:
- Properly detect and publish native FLAC payloads inside MP4 containers
- Rename to .flac extension and embed metadata instead of skipping
- Fix all code paths: SAF, non-SAF, and native worker finalizer

Extension API optimizations:
- Enable response compression for API/search calls (faster metadata loads)
- Keep downloads uncompressed for accurate progress/streaming
- Add separate extensionAPITransport with compression enabled

Platform bridge caching:
- Cache handleURLWithExtension results (5 min TTL)
- Cache customSearchWithExtension results (2 min TTL)
- Prevent duplicate in-flight requests for same URL/query

Dependency cleanup:
- Remove unused sqflite_common_ffi and sqlite3 packages
2026-05-15 00:54:58 +07:00
zarzet 629eb66595 chore: bump version to 4.5.5 (build 132) 2026-05-14 20:48:29 +07:00
zarzet 36749a40d3 Revert "feat: add library scroll-to-top and scroll-to-bottom quick buttons"
This reverts commit f84a33bbf2.
2026-05-14 20:47:24 +07:00
zarzet 4336e6dc78 feat: add 5 new lyrics providers
New lyrics providers using Paxsenix API:
- Spotify: Synced lyrics from Spotify
- Deezer: Synced lyrics from Deezer
- YouTube: Lyrics from YouTube
- Kugou: Lyrics from Kugou (Chinese service)
- Genius: Plain text lyrics from Genius

Implementation:
- Add lyrics client implementations for all providers
- Smart search result scoring based on track name, artist, and duration
- Support for both synced (LRC) and unsynced lyrics formats
- Fallback search with simplified track names and primary artist

UI updates:
- Add provider entries to lyrics priority settings page
- Add display names for new providers in settings
2026-05-14 20:42:14 +07:00
zarzet 3e3e87e73e fix: MP3 lyrics embedding via ID3v2.3 USLT frame
FFmpeg doesn't always embed lyrics correctly to MP3 files. This adds
manual ID3v2.3 USLT (Unsynchronized Lyrics) frame writing after FFmpeg
metadata embedding to ensure lyrics are properly stored.

Implementation:
- Extract lyrics from metadata (UNSYNCEDLYRICS or LYRICS key)
- Build ID3v2.3 compliant USLT frame with UTF-16LE encoding
- Insert or replace USLT frame in existing ID3v2.3 tag
- Create new ID3v2.3 tag if file has no ID3 header
- Skip gracefully for unsupported ID3 versions or flags

Also includes minor audio analysis improvements:
- Consistent dynamic range calculation (peak - rms)
- Filter out 'unknown' and 'n/a' labels
- Add -vn -sn -dn flags for more robust stream selection
2026-05-14 18:25:03 +07:00
zarzet 1b8d6ce7fa feat: enhanced audio analysis with loudness, clipping, and spectral cutoff
Audio Analysis Enhancements:
- Display codec name and container format
- Show decoded sample format (s16, s32, fltp, etc.)
- Add LUFS integrated loudness measurement (broadcast standard)
- Add true peak measurement (dBTP)
- Detect and count clipping samples per channel
- Estimate spectral cutoff frequency (helps detect fake upscales)
- Show per-channel statistics (Peak, RMS, DR, Clip count)

UI Improvements:
- MetricChip now handles long text with ellipsis
- Constrained max width for better layout

Cache version bumped to 4 to force rescan with new metrics.
2026-05-14 16:28:49 +07:00
zarzet 60f1df1488 refactor: use audio_conversion_utils in downloaded_album_screen
- Replace inline format detection with convertibleAudioSourceFormat()
- Replace inline conversion rules with canConvertAudioFormat()
- Add unit tests for Dolby format detection and conversion rules
2026-05-14 15:49:27 +07:00
zarzet ff86869c33 feat: audio analysis rescan and AAC conversion support
Audio Analysis:
- Add rescan capability by bumping cache version
- Display channel layout (stereo, 5.1, etc.) and bitrate
- Use astats filter for more accurate peak/RMS measurements
- Support more formats: mp4, ac3, eac3, mka, wv, ape, tta, aif
- Only report bit depth for codecs that store it (FLAC, ALAC, WAV)
- Validate cache for SAF content:// URIs

Conversion:
- Add AAC as conversion target format
- Recognize ALAC as lossless source
- Prevent accidental deletion when source and target URI match
- Store format and bitrate in database after conversion

Utilities:
- Add audio_conversion_utils.dart for centralized conversion logic
- Add isSameContentUri() helper for safe URI comparison
2026-05-14 15:46:55 +07:00
zarzet 2a2d817314 feat: add AAC lossy target and toggle for Apple Music eLRC word sync
The HIGH-quality lossy format picker can now produce an AAC/M4A 320 kbps output alongside MP3 and Opus. FFmpegService.convertM4aToLossy/convertAudioFormat, the Dart queue pipeline, the Kotlin finalizer, and the library database format helper all route .m4a through a unified aac codec path and tag the resulting file with the M4A metadata writer. The Lossy Format setting gains a new option, and the track metadata convert dialog lists AAC next to the other targets.

Apple Music lyrics gain a 'eLRC word sync' switch (default off). When disabled the pax-to-LRC formatter strips inline word timestamps, producing line-synced LRC that is safer for players that choke on eLRC; enabling it restores the previous word-by-word behaviour. The change propagates through SetLyricsFetchOptions and invalidates the global lyrics cache on toggle.

Broad l10n migration: roughly 400 previously hardcoded English strings across queue, settings, track metadata, repo, audio analysis, setup and extension screens now live in the ARB catalog, with matching plural/placeholder forms. No behaviour change beyond localisation. Existing and new unit tests (lyrics eLRC toggle and Dart settings round-trip) pass.
2026-05-12 02:23:04 +07:00
zarzet 7845ac8be5 feat: show remote-config launch announcement on app start
Introduce AppRemoteConfigService which fetches a platform/version/locale-aware JSON payload from api.zarz.moe/v1/spotiflac-mobile/config and caches it in SharedPreferences. main_shell shows a one-shot announcement dialog (respecting dismissible, CTA, time window and version gates) when no update prompt is pending; dismissed IDs are persisted so each announcement surfaces only once.

Tweaks bundled in: the service health dot loses its blur halo in favour of solid Material 3 tones, and AppInfo gains the remote config endpoint constant. The share listener and SAF migration hook stay synchronous inside the post-frame callback so share-intent URLs never race the network-bound checks.

New unit tests cover the announcement CTA/active-window rules.
2026-05-11 01:37:10 +07:00
zarzet 81547013f9 fix: gate M4A to FLAC conversion on a codec probe in every branch
The SAF and local post-download branches used to rush an ffmpeg 'M4A to FLAC' remux whenever the output extension was .flac, which silently upscaled AAC or EAC3 streams into a lossless container. Each branch now mirrors the native worker by probing the primary audio codec before converting: lossless sources (and true FLAC-in-MP4 files) stay in their native container with the right extension, while genuine ALAC/WAV payloads still get remuxed.

Add an outputExt field to DownloadRequestPayload so the Go backend always knows the user-requested container, and use it together with _shouldRequestContainerConversion to pick the right behaviour for shouldPreserveNativeM4a and the Kotlin finalizer. Decryption descriptors no longer force M4A preservation on their own; the codec probe already makes that call correctly.
2026-05-11 00:52:02 +07:00
zarzet 8e605cbd0f feat: persist codec format and bitrate in download history
Bump the history schema on both the Kotlin finalizer and the Dart database to v9, adding bitrate (kbps) and format (codec label) columns, and let the download flow fill them from backend/probe metadata so lossy downloads keep a 'AAC 256kbps' label instead of falling back to the stored placeholder. Library filtering and the track metadata screen now read format/bitrate directly from those columns, which also fixes mis-tagged quality badges after re-downloading a track at a different format.

Additional fixes bundled in: EditFileMetadata now routes ReplayGain writes through the M4A path whenever the file starts with ftyp (fixing .flac files that actually hold MP4 containers); GetM4AQuality falls back to the first trak/mdia/mdhd duration when mvhd is zero so EAC3 streams no longer report 0s; and both Kotlin and Dart reject bitrate values below 16 kbps to prevent probe noise from surfacing as '0 kbps' labels. New unit tests cover the EAC3 mdhd fallback and the mis-named M4A replaygain path.
2026-05-10 23:18:32 +07:00
zarzet d664d46ca4 feat: detect FLAC/ALAC/EAC3/AC3/AC4 codecs inside MP4 containers
GetM4AQuality now recognizes fLaC, alac, ec-3, ac-3, and ac-4 sample entries and parses the MP4 FLACSpecificBox so library entries carry the real codec rather than the container extension. The AudioQuality struct exposes Codec and Bitrate fields (with an estimator for compressed streams), and ReadFileMetadata publishes format + audio_codec so Flutter and Kotlin can make format decisions based on the actual stream.

Downstream: library_scan labels M4A-family items as flac/alac/eac3/ac3/ac4/m4a, zeroes the bitrate for lossless formats, and the filter UI + quality badges use the codec-derived format instead of only the file extension. Scans and SAF importers also accept .mp4 and .aac file extensions. New unit tests cover codec name mapping and MP4 FLACSpecificBox decoding.
2026-05-10 22:14:47 +07:00
zarzet b4031936a0 feat: allow re-running audio quality analysis after cached result
The audio analysis card used to read from a persistent cache but offered no way to refresh the result when the underlying file had been re-downloaded at a different quality (for example, re-downloading a track as FLAC after capturing it as AAC). Add an explicit rescan control that clears the cached JSON + spectrogram, reruns the FFmpeg probe and analysis pipeline, and swaps in the fresh data while keeping the loading copy distinct from first-run analysis. A retry button is also exposed in the error card so transient failures do not require navigating away.

All audio_analysis strings now have a Re-analyze / Re-analyzing pair in the ARB catalog so every locale can translate them independently.
2026-05-10 21:27:54 +07:00
zarzet f84a33bbf2 feat: add library scroll-to-top and scroll-to-bottom quick buttons
Add a pair of floating quick-scroll buttons on the library tab so long lists become easier to navigate. The buttons sit above the bottom navigation (or the selection toolbar in selection mode), fade in and out based on the active page's scroll metrics, and share their scroll-target keys per filter mode so switching filters does not carry over the previous page's scroll state.
2026-05-10 19:09:38 +07:00
zarzet 8f5c59683a fix: force native FLAC muxer when decrypting to .flac output
Downloads from providers that stream FLAC inside an fMP4 container (e.g. Amazon Music) were being written to disk with a .flac extension while the payload still carried ISO-BMFF atoms. The container-conversion guard then saw codec=flac and skipped the remux, leaving native FLAC tag writers to fail with 'fLaC head incorrect'.

Force '-f flac' on the decryption command whenever the target extension is .flac so FFmpeg emits a real FLAC stream, and add an 'fLaC' magic-byte probe on both the Dart and Kotlin container-conversion guards so a FLAC-in-MP4 source is remuxed rather than silently passed through as a tag-writer hazard.
2026-05-10 18:50:49 +07:00
zarzet 4b7146afe4 fix: report zero bit depth for non-ALAC M4A containers
GetM4AQuality previously defaulted to 16-bit whenever the audio sample entry was not ALAC, which silently labeled lossy AAC downloads as CD quality in the library and in extension APIs. Only fill BitDepth when the atom is ALAC (including the ALACSpecificConfig refinement), and leave it as zero for AAC/mp4a, matching how the MP3 and Opus probes already report lossy sources. Tests cover both the ALAC and AAC branches.
2026-05-10 18:31:19 +07:00
zarzet 939407675b fix: probe codec to avoid fake FLAC upscale from lossy sources
The native-worker container conversion used to remux any .m4a download to .flac whenever the user requested a FLAC output, which silently upgraded lossy AAC streams to a FLAC container without adding any information. Guard the remux with an FFmpeg/FFprobe codec probe on both the Dart and Kotlin finalization paths so only genuinely lossless sources (ALAC, WavPack, PCM, etc.) are converted, and expose a requires_container_conversion capability so extensions can force conversion when they know the source is lossless.
2026-05-09 20:51:40 +07:00
zarzet 20ac6b2cd4 fix(native-worker): preserve requested output container in finalizer
When the native worker result advertises a requested non-FLAC output extension (for example '.m4a'), skip the m4a-to-flac container conversion in both the Dart and Kotlin finalizers so the native output container is preserved end-to-end.

- ffmpeg_service: propagate the top-level 'output_extension' hint into the download-result descriptor for both the map-backed and legacy paths; expose a normalized getter for consistent comparisons.

- download_queue_provider: short-circuit the native-worker container-conversion step when the descriptor's requested extension is not '.flac', with a debug log describing the skip.

- NativeDownloadFinalizer: mirror the guard on the Kotlin side so the finalizer does not force a container conversion that would clobber the requested native output.
2026-05-09 01:23:38 +07:00
zarzet 904b45e8f6 chore: housekeeping cleanup and code deduplication
- Remove stray tracked files (root AndroidManifest.xml, build.gradle.bak, temp_project template)
- Move README-only images out of app asset bundle to reduce APK/IPA size (~1.68MB)
- Fix logo filename typo (transparant -> transparent)
- Deduplicate _readPositiveInt into shared int_utils.dart
- Deduplicate _themeModeFromString (reuse from theme_settings.dart)
- Remove deprecated LocalLibraryState.items getter
- Remove unused sqflite_common_ffi dependency
- Update apps.json version to 4.5.1
- Fix Flutter version in CONTRIBUTING.md (3.38.1 -> 3.41.5)
- Improve .gitignore patterns (NUL, *.bak, root AndroidManifest.xml)
2026-05-08 21:37:56 +07:00
zarzet 1bd54c530b fix(saf): use extension-agnostic .partial staged filename
Staged SAF outputs and library-scan partials now share a single naming pattern: '<name>.partial' regardless of the audio extension. The previous '<name>.partial.<ext>' form caused SAF / media-scanner to surface half-written files as valid audio.

- SafDownloadHandler: force 'application/octet-stream' MIME for staged docs and collapse buildStagedSafFileName to '<name>.partial'. Keep the legacy form behind buildLegacyStagedSafFileName and sweep both via deleteStaleStagedFiles so upgrades clean old residue.

- library_scan: add isLibraryStagingFile that skips both the new and legacy partial patterns during collectLibraryAudioFiles so residual staging files never show up in the library.

- library_scan_supplement_test: seed both legacy and new partial files and assert they are ignored by the scanner.
2026-05-08 20:35:41 +07:00
github-actions[bot] d005e2e2e7 chore: update AltStore source to v4.5.1 2026-05-07 18:22:36 +00:00
145 changed files with 15885 additions and 1736 deletions
+4 -1
View File
@@ -44,6 +44,7 @@ go_backend/*.xcframework/
# Android
android/.gradle/
android/app/libs/gobackend.aar
android/app/libs/gobackend-sources.jar
android/local.properties
android/*.iml
android/key.properties
@@ -57,7 +58,6 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/
@@ -67,7 +67,10 @@ AGENTS.md
# Temp/misc
nul
NUL
network_requests.txt
*.bak
/AndroidManifest.xml
# Log files
*.log
Binary file not shown.
+1 -1
View File
@@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Use FVM (Flutter Version: 3.38.1)**
3. **Use FVM (Flutter Version: 3.41.5)**
```bash
fvm use
```
+7 -7
View File
@@ -1,9 +1,9 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
</picture>
<p align="center">
@@ -28,10 +28,10 @@
## Screenshots
<p align="center">
<img src="assets/images/1.jpg?v=2" width="200" />
<img src="assets/images/2.jpg?v=2" width="200" />
<img src="assets/images/3.jpg?v=2" width="200" />
<img src="assets/images/4.jpg?v=2" width="200" />
<img src="assets/readme/1.jpg?v=2" width="200" />
<img src="assets/readme/2.jpg?v=2" width="200" />
<img src="assets/readme/3.jpg?v=2" width="200" />
<img src="assets/readme/4.jpg?v=2" width="200" />
</p>
---
+3 -7
View File
@@ -9,6 +9,9 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
plugins:
riverpod_lint: 3.1.4-dev.3
analyzer:
exclude:
- build/**
@@ -19,9 +22,6 @@ analyzer:
strict-casts: true
strict-inference: true
strict-raw-types: true
plugins:
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -44,9 +44,5 @@ linter:
cancel_subscriptions: true
close_sinks: true
custom_lint:
rules:
- avoid_public_notifier_properties
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
-71
View File
@@ -1,71 +0,0 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "com.zarz.spotiflac"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "com.zarz.spotiflac"
minSdkVersion flutter.minSdkVersion
targetSdk flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
signingConfig signingConfigs.debug
minifyEnabled false
shrinkResources false
}
}
}
flutter {
source '../..'
}
dependencies {
// Go backend library (gomobile generated)
implementation fileTree(dir: 'libs', include: ['*.aar'])
// Kotlin coroutines for async Go backend calls
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
}
+2 -2
View File
@@ -120,8 +120,8 @@ dependencies {
// Include all AAR and JAR files from libs folder
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.13.0")
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
@@ -1,5 +0,0 @@
package com.example.temp_project
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -690,7 +690,8 @@ class DownloadService : Service() {
request.itemId,
request.requestJson,
request.itemJson,
result
result,
settingsJson
) {
nativeWorkerCancelRequested ||
nativeWorkerPaused ||
@@ -772,6 +772,7 @@ class MainActivity: FlutterFragmentActivity() {
return when {
name.endsWith(".m4a") -> ".m4a"
name.endsWith(".mp4") -> ".mp4"
name.endsWith(".aac") -> ".aac"
name.endsWith(".mp3") -> ".mp3"
name.endsWith(".opus") -> ".opus"
name.endsWith(".flac") -> ".flac"
@@ -783,6 +784,10 @@ class MainActivity: FlutterFragmentActivity() {
private fun extFromMimeType(mime: String?): String {
return when (mime) {
"audio/mp4" -> ".m4a"
"audio/aac" -> ".aac"
"audio/eac3" -> ".m4a"
"audio/ac3" -> ".m4a"
"audio/ac4" -> ".m4a"
"audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus"
"audio/flac" -> ".flac"
@@ -1032,6 +1037,48 @@ class MainActivity: FlutterFragmentActivity() {
}
}
/**
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
* in the same parent directory. Used by re-enrich when the user's lyrics
* mode requests an external/both sidecar. Best-effort: failures are logged
* and swallowed so they never abort the metadata enrichment itself.
*/
private fun writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
if (lrcContent.isBlank()) return false
try {
val parent = safParentDir(audioUri) ?: run {
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
return false
}
val audioName = try {
DocumentFile.fromSingleUri(this, audioUri)?.name
} catch (_: Exception) {
null
} ?: return false
val baseName = audioName.substringBeforeLast('.', audioName)
val lrcName = "$baseName.lrc"
val target = createOrReuseDocumentFile(
parent,
"application/octet-stream",
lrcName
) ?: run {
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
return false
}
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
output.write(lrcContent.toByteArray(Charsets.UTF_8))
} ?: return false
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
return true
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
return false
}
}
/**
* Extract the audio filename referenced by a CUE sheet file.
* Reads the FILE "name" TYPE line from the .cue text.
@@ -1063,7 +1110,7 @@ class MainActivity: FlutterFragmentActivity() {
}
private val cueSiblingAudioExtensions = listOf(
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
)
private fun getSafChildFileLookup(
@@ -1135,7 +1182,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
@@ -1435,7 +1482,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
@@ -2599,6 +2646,23 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"writeSafSidecarLrc" -> {
val safUri = call.argument<String>("saf_uri") ?: ""
val lyrics = call.argument<String>("lyrics") ?: ""
val response = withContext(Dispatchers.IO) {
try {
val uri = Uri.parse(safUri)
if (writeSafSidecarLrc(uri, lyrics)) {
"""{"success":true}"""
} else {
"""{"success":false,"error":"Failed to write LRC sidecar"}"""
}
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"downloadCoverToFile" -> {
val coverUrl = call.argument<String>("cover_url") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
@@ -2756,6 +2820,9 @@ class MainActivity: FlutterFragmentActivity() {
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
}
if (obj.optBoolean("write_external_lrc", false)) {
writeSafSidecarLrc(uri, obj.optString("lyrics", ""))
}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
@@ -3090,6 +3157,17 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"findCollectionAcrossExtensions" -> {
val requestJson = call.arguments as? String ?: "{}"
val response: String = withContext(Dispatchers.IO) {
val method = Gobackend::class.java.getMethod(
"findCollectionAcrossExtensionsJSON",
String::class.java
)
method.invoke(null, requestJson) as? String ?: "[]"
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
@@ -3475,7 +3553,7 @@ class MainActivity: FlutterFragmentActivity() {
} catch (_: Exception) { "" }
val cueBaseName = cueName.substringBeforeLast('.')
if (cueBaseName.isNotBlank()) {
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
@@ -16,6 +16,7 @@ import com.antonkarpenko.ffmpegkit.ReturnCode
import gobackend.Gobackend
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.util.Locale
import java.util.concurrent.CancellationException
@@ -29,7 +30,7 @@ object NativeDownloadFinalizer {
const val NATIVE_WORKER_CONTRACT_VERSION = 1
// Native finalizer owns background-safe history writes while Flutter may be suspended.
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
private const val HISTORY_SCHEMA_VERSION = 8
private const val HISTORY_SCHEMA_VERSION = 9
private val activeFFmpegSessionIds = mutableSetOf<Long>()
private val nativeFFmpegSessionIds = mutableSetOf<Long>()
private val activeFFmpegSessionLock = Any()
@@ -72,6 +73,8 @@ object NativeDownloadFinalizer {
"quality",
"bit_depth",
"sample_rate",
"bitrate",
"format",
"genre",
"composer",
"label",
@@ -95,6 +98,7 @@ object NativeDownloadFinalizer {
".ogg",
".wav",
".aac",
".mp4",
)
private data class FinalizeInput(
@@ -112,6 +116,7 @@ object NativeDownloadFinalizer {
var bitDepth: Int?,
var sampleRate: Int?,
var bitrateKbps: Int? = null,
var audioCodec: String? = null,
var pendingExternalLrc: String? = null,
var pendingExternalLrcFileName: String? = null,
)
@@ -141,6 +146,7 @@ object NativeDownloadFinalizer {
requestJson: String,
itemJson: String,
result: JSONObject,
settingsJson: String = "{}",
shouldCancel: () -> Boolean = { false },
): JSONObject {
if (!result.optBoolean("success", false)) return result
@@ -174,6 +180,9 @@ object NativeDownloadFinalizer {
sampleRate = optPositiveInt(result, "actual_sample_rate"),
bitrateKbps = optPositiveBitrateKbps(result, "bitrate")
?: optPositiveBitrateKbps(result, "actual_bitrate"),
audioCodec = normalizeAudioCodec(
result.optString("audio_codec", "").ifBlank { result.optString("format", "") },
),
)
try {
@@ -209,14 +218,20 @@ object NativeDownloadFinalizer {
refreshFinalAudioQualityMetadata(context, result, state)
}
val history = buildHistoryRow(effectiveInput, state)
upsertHistory(context, history)
val saveDownloadHistory = parseObject(settingsJson)
.optBoolean("save_download_history", true)
val history = if (saveDownloadHistory) {
buildHistoryRow(effectiveInput, state).also { upsertHistory(context, it) }
} else {
null
}
result.put("file_path", state.filePath)
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
if (state.quality.isNotBlank()) result.put("quality", state.quality)
result.put("native_finalized", true)
result.put("history_written", true)
result.put("history_item", historyToJson(history))
result.put("history_written", history != null)
if (history != null) result.put("history_item", historyToJson(history))
} catch (e: CancellationException) {
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
result.put("success", false)
@@ -419,7 +434,13 @@ object NativeDownloadFinalizer {
for ((candidateOutput, mapAudioOnly) in attempts) {
try {
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${q(candidateOutput)} -y"
// Force the flac muxer when the target extension is
// .flac. Without this override FFmpeg keeps the ISO-BMFF
// stream layout, producing FLAC-in-MP4 under a .flac
// filename which downstream native FLAC tag writers
// cannot read.
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
val result = runFFmpeg(command, shouldCancel)
lastOutput = result.second
if (result.first && File(candidateOutput).exists()) {
@@ -461,13 +482,23 @@ object NativeDownloadFinalizer {
if (!looksLikeM4a(state.filePath, state.fileName)) return
val tidalHighFormat = input.request.optString("tidal_high_format", "").ifBlank { "mp3_320" }
val format = if (tidalHighFormat.startsWith("opus")) "opus" else "mp3"
val format = when {
tidalHighFormat.startsWith("opus") -> "opus"
tidalHighFormat.startsWith("aac") || tidalHighFormat.startsWith("m4a") -> "aac"
else -> "mp3"
}
val metadataFormat = if (format == "aac") "m4a" else format
val displayFormat = if (format == "aac") "AAC" else format.uppercase(Locale.ROOT)
val bitrate = if (tidalHighFormat.contains("_")) {
"${tidalHighFormat.substringAfterLast("_")}k"
} else {
if (format == "opus") "128k" else "320k"
}
val ext = if (format == "opus") ".opus" else ".mp3"
val ext = when (format) {
"opus" -> ".opus"
"aac" -> ".m4a"
else -> ".mp3"
}
val localInput = materializeForFFmpeg(context, input, state)
val deleteLocalInput = state.filePath.startsWith("content://")
val output = buildOutputPath(localInput, ext)
@@ -475,6 +506,8 @@ object NativeDownloadFinalizer {
try {
val command = if (format == "opus") {
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(output)} -y"
} else if (format == "aac") {
"-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(output)} -y"
} else {
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(output)} -y"
}
@@ -482,14 +515,14 @@ object NativeDownloadFinalizer {
if (!result.first || !File(output).exists()) {
throw IllegalStateException("HIGH conversion failed: ${result.second}")
}
embedBasicMetadata(context, output, input, format)
embedBasicMetadata(context, output, input, metadataFormat)
replaceStatePath(context, input, state, output, deleteOld = true)
adoptedOutput = true
} finally {
if (!adoptedOutput) File(output).delete()
if (deleteLocalInput) File(localInput).delete()
}
state.quality = "${format.uppercase(Locale.ROOT)} ${bitrate.removeSuffix("k")}kbps"
state.quality = "$displayFormat ${bitrate.removeSuffix("k")}kbps"
state.bitDepth = null
state.sampleRate = null
}
@@ -501,13 +534,37 @@ object NativeDownloadFinalizer {
shouldCancel: () -> Boolean,
) {
if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return
if (!looksLikeM4a(state.filePath, state.fileName) && !shouldForceContainerConversion(input, state)) return
val requestedDecryptionExt = requestedDecryptionOutputExt(input)
val forceContainerConversion = shouldForceContainerConversion(input, state)
if (!forceContainerConversion && requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return
val mayNeedContainerConversion = forceContainerConversion ||
looksLikeM4a(state.filePath, state.fileName) ||
state.filePath.startsWith("content://")
if (!mayNeedContainerConversion) return
val localInput = materializeForFFmpeg(context, input, state)
val deleteLocalInput = state.filePath.startsWith("content://")
val output = buildOutputPath(localInput, ".flac")
var adoptedOutput = false
try {
val codec = probePrimaryAudioCodec(localInput, shouldCancel)
val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput)
if (!isLosslessAudioCodec(codec)) {
Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}")
return
}
if (isAlreadyNativeFlac) {
Log.d(TAG, "Native FLAC payload detected; publishing as FLAC and embedding metadata")
val nativeFlacOutput = if (localInput.lowercase(Locale.ROOT).endsWith(".flac")) {
localInput
} else {
File(localInput).copyTo(File(output), overwrite = true).absolutePath
}
embedBasicMetadata(context, nativeFlacOutput, input, "flac")
replaceStatePath(context, input, state, nativeFlacOutput, deleteOld = true)
adoptedOutput = true
return
}
val result = runFFmpeg(
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y",
shouldCancel,
@@ -633,6 +690,17 @@ object NativeDownloadFinalizer {
val bitDepth = optPositiveInt(metadata, "bit_depth")
val sampleRate = optPositiveInt(metadata, "sample_rate")
val probedCodec = normalizeAudioCodec(
metadata.optString("audio_codec", "").ifBlank {
metadata.optString("codec", "").ifBlank {
metadata.optString("format", "")
}
}
)
if (probedCodec != null) {
state.audioCodec = probedCodec
result.put("audio_codec", probedCodec)
}
if (bitDepth != null) {
state.bitDepth = bitDepth
result.put("actual_bit_depth", bitDepth)
@@ -643,7 +711,7 @@ object NativeDownloadFinalizer {
}
val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate")
?: optPositiveBitrateKbps(metadata, "bit_rate")
if (bitrateKbps != null) {
if (bitrateKbps != null && isLossyAudioCodec(state.audioCodec)) {
state.bitrateKbps = bitrateKbps
result.put("bitrate", bitrateKbps)
}
@@ -654,6 +722,7 @@ object NativeDownloadFinalizer {
bitDepth = state.bitDepth,
sampleRate = state.sampleRate,
bitrateKbps = state.bitrateKbps,
audioCodec = state.audioCodec,
storedQuality = state.quality,
)
if (displayQuality != null) {
@@ -691,15 +760,19 @@ object NativeDownloadFinalizer {
bitDepth: Int?,
sampleRate: Int?,
bitrateKbps: Int?,
audioCodec: String? = null,
storedQuality: String?,
): String? {
val format = audioFormatForPath(filePath, fileName)
val format = audioFormatForCodec(audioCodec) ?: audioFormatForPath(filePath, fileName)
if (format == "OPUS" ||
format == "MP3" ||
format == "AAC" ||
format == "EAC3" ||
format == "AC3" ||
format == "AC4" ||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
) {
return if (bitrateKbps != null && bitrateKbps > 0) {
return if (bitrateKbps != null && bitrateKbps >= 16) {
"$format ${bitrateKbps}kbps"
} else {
nonPlaceholderQuality(storedQuality) ?: format
@@ -715,6 +788,43 @@ object NativeDownloadFinalizer {
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
}
private fun audioFormatForCodec(codec: String?): String? {
return when (normalizeAudioCodec(codec)) {
"flac" -> "FLAC"
"alac" -> "ALAC"
"aac" -> "AAC"
"eac3" -> "EAC3"
"ac3" -> "AC3"
"ac4" -> "AC4"
"mp3" -> "MP3"
"opus" -> "OPUS"
else -> null
}
}
private fun isLossyAudioCodec(codec: String?): Boolean {
return when (normalizeAudioCodec(codec)) {
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
else -> false
}
}
private fun normalizeAudioCodec(codec: String?): String? {
val normalized = normalizeOptional(codec)
?.lowercase(Locale.ROOT)
?.replace('-', '_')
?: return null
return when (normalized) {
"mp4a" -> "aac"
"ec_3" -> "eac3"
"ac_3" -> "ac3"
"ac_4" -> "ac4"
"mp4" -> "m4a"
"ogg" -> "opus"
else -> normalized
}
}
private fun audioFormatForPath(filePath: String, fileName: String): String? {
for (candidate in listOf(filePath, fileName)) {
val lower = candidate.trim().lowercase(Locale.ROOT)
@@ -730,6 +840,11 @@ object NativeDownloadFinalizer {
private fun nonPlaceholderQuality(quality: String?): String? {
val normalized = normalizeOptional(quality) ?: return null
val bitrateMatch = Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
if (bitrateMatch != null) {
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
if (bitrate != null && bitrate < 16) return null
}
val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_')
val placeholders = setOf(
"best",
@@ -972,10 +1087,11 @@ object NativeDownloadFinalizer {
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
val lyrics = resolveLyricsLrc(input)
val shouldEmbedLyrics = input.request.optBoolean("embed_lyrics", false) &&
(input.request.optString("lyrics_mode", "embed") == "embed" ||
input.request.optString("lyrics_mode", "embed") == "both") &&
val lyricsMode = input.request.optString("lyrics_mode", "embed")
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
(lyricsMode == "embed" || lyricsMode == "both")
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
val shouldEmbedLyrics = shouldResolveLyrics &&
lyrics.isNotBlank() &&
lyrics != "[instrumental:true]"
if (format == "flac") {
@@ -1146,7 +1262,7 @@ object NativeDownloadFinalizer {
return when (normalizeExt(File(path).extension)) {
".mp3" -> "mp3"
".opus", ".ogg" -> "opus"
".m4a", ".mp4" -> "m4a"
".m4a", ".mp4", ".aac" -> "m4a"
else -> "flac"
}
}
@@ -1294,7 +1410,7 @@ object NativeDownloadFinalizer {
val rawName = input.request.optString("saf_file_name", "")
.ifBlank { state.fileName }
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".ogg", ".lrc")
val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc")
var base = rawName.trim()
val lower = base.lowercase(Locale.ROOT)
for (knownExt in knownExts) {
@@ -1315,19 +1431,66 @@ object NativeDownloadFinalizer {
private fun shouldForceContainerConversion(input: FinalizeInput, state: FinalizeState): Boolean {
if (input.result.optBoolean("requires_container_conversion", false)) return true
if (input.request.optBoolean("requires_container_conversion", false)) return true
return false
}
val actualExt = normalizeExt(
input.result.optString("actual_extension", "")
.ifBlank { input.result.optString("output_extension", "") }
private fun probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String {
val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel)
val output = result.second
val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output)
return match?.groupValues?.getOrNull(1)
?.trim()
?.lowercase(Locale.ROOT)
?.replace('-', '_')
.orEmpty()
}
/**
* Returns true when the file on [path] starts with the native FLAC magic
* bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside
* an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC
* tag writers require the raw fLaC header, so we must detect that mismatch
* before skipping the container conversion step.
*/
private fun isNativeFlacFile(path: String): Boolean {
return try {
RandomAccessFile(path, "r").use { raf ->
if (raf.length() < 4L) return false
val header = ByteArray(4)
raf.readFully(header)
header[0] == 0x66.toByte() && // 'f'
header[1] == 0x4C.toByte() && // 'L'
header[2] == 0x61.toByte() && // 'a'
header[3] == 0x43.toByte() // 'C'
}
} catch (e: Exception) {
Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}")
false
}
}
private fun isLosslessAudioCodec(codec: String): Boolean {
val normalized = codec.trim().lowercase(Locale.ROOT).replace('-', '_')
if (normalized.isBlank()) return false
if (normalized.startsWith("pcm_")) return true
return normalized in setOf(
"alac",
"flac",
"wavpack",
"ape",
"tta",
"mlp",
"truehd",
"shorten"
)
if (actualExt == ".m4a" || actualExt == ".mp4") return true
}
val container = input.result.optString("actual_container", "")
.ifBlank { input.result.optString("container", "") }
.trim()
.lowercase(Locale.ROOT)
.removePrefix(".")
return container == "m4a" || container == "mp4" || container == "mov" || container == "aac"
private fun requestedDecryptionOutputExt(input: FinalizeInput): String {
val descriptor = input.result.optJSONObject("decryption")
return normalizeExt(
descriptor?.optString("output_extension", "")
?.ifBlank { input.result.optString("output_extension", "") }
)
}
private fun validateRequestContract(request: JSONObject) {
@@ -1541,6 +1704,10 @@ object NativeDownloadFinalizer {
values.put("quality", state.quality)
state.bitDepth?.let { values.put("bit_depth", it) }
state.sampleRate?.let { values.put("sample_rate", it) }
state.bitrateKbps?.takeIf { it >= 16 && isLossyAudioCodec(state.audioCodec) }?.let {
values.put("bitrate", it)
}
normalizeAudioCodec(state.audioCodec)?.let { values.put("format", it) }
values.put("genre", normalizeOptional(result.optString("genre", "").ifBlank { input.request.optString("genre", "") }))
values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) }))
values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") }))
@@ -1597,6 +1764,8 @@ object NativeDownloadFinalizer {
quality TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
bitrate INTEGER,
format TEXT,
genre TEXT,
composer TEXT,
label TEXT,
@@ -1612,6 +1781,8 @@ object NativeDownloadFinalizer {
ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT")
ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER")
ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER")
ensureHistoryColumn(db, "bitrate", "ALTER TABLE history ADD COLUMN bitrate INTEGER")
ensureHistoryColumn(db, "format", "ALTER TABLE history ADD COLUMN format TEXT")
ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT")
ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT")
ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT")
@@ -1983,6 +2154,8 @@ object NativeDownloadFinalizer {
putCamel("quality", "quality")
putCamel("bit_depth", "bitDepth")
putCamel("sample_rate", "sampleRate")
putCamel("bitrate", "bitrate")
putCamel("format", "format")
putCamel("genre", "genre")
putCamel("composer", "composer")
putCamel("label", "label")
@@ -2014,11 +2187,12 @@ object NativeDownloadFinalizer {
private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? {
val value = optPositiveInt(obj, key) ?: return null
return if (value >= 10000) {
val kbps = if (value >= 10000) {
Math.round(value / 1000.0).toInt()
} else {
value
}
return if (kbps >= 16) kbps else null
}
private fun positiveOrNull(primary: Int, fallback: Int): Int? {
@@ -15,6 +15,7 @@ import java.util.Locale
object SafDownloadHandler {
private val safDirLock = Any()
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
val req = JSONObject(requestJson)
@@ -31,15 +32,15 @@ object SafDownloadHandler {
val fileName = buildSafFileName(req, outputExt)
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName, outputExt) else fileName
val staleStagedFileName = buildStagedSafFileName(fileName, outputExt)
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
val existingDir = findDocumentDir(context, treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
if (useStagedOutput || deferSafPublish) {
existingDir.findFile(staleStagedFileName)?.delete()
deleteStaleStagedFiles(existingDir, fileName, outputExt)
}
val obj = JSONObject()
obj.put("success", true)
@@ -55,7 +56,7 @@ object SafDownloadHandler {
?: return errorJson("Failed to access SAF directory")
if (deferSafPublish) {
targetDir.findFile(staleStagedFileName)?.delete()
deleteStaleStagedFiles(targetDir, fileName, outputExt)
val workingExt = outputExt.ifBlank { ".tmp" }
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
@@ -89,7 +90,7 @@ object SafDownloadHandler {
}
}
var document = createOrReuseDocumentFile(targetDir, mimeType, stagedFileName)
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
?: return errorJson("Failed to create SAF file")
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
@@ -121,14 +122,14 @@ object SafDownloadHandler {
if (actualExt.isNotBlank() && actualExt != outputExt) {
val actualFileName = buildSafFileName(req, actualExt)
val actualStagedFileName = if (useStagedOutput) {
buildStagedSafFileName(actualFileName, actualExt)
buildStagedSafFileName(actualFileName)
} else {
actualFileName
}
val actualMimeType = mimeTypeForExt(actualExt)
val replacement = createOrReuseDocumentFile(
targetDir,
actualMimeType,
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
actualStagedFileName
) ?: throw IllegalStateException(
"failed to create SAF output with actual extension"
@@ -212,8 +213,9 @@ object SafDownloadHandler {
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
val finalName = sanitizeFilename(fileName)
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
val stagedName = buildStagedSafFileName(finalName, ext)
val document = createOrReuseDocumentFile(targetDir, mimeType, stagedName)
val stagedName = buildStagedSafFileName(finalName)
deleteStaleStagedFiles(targetDir, finalName, ext)
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
?: return null
stagedDocument = document
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
@@ -288,13 +290,17 @@ object SafDownloadHandler {
return safeName + normalizedExt
}
private fun buildStagedSafFileName(fileName: String, outputExt: String): String {
private fun buildStagedSafFileName(fileName: String): String {
val safeName = sanitizeFilename(fileName)
return "$safeName.partial"
}
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
val safeName = sanitizeFilename(fileName)
val ext = normalizeExt(outputExt)
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
}
val dot = safeName.lastIndexOf('.')
if (dot > 0 && dot < safeName.lastIndex) {
return safeName.substring(0, dot).trimEnd('.', ' ') +
@@ -304,6 +310,19 @@ object SafDownloadHandler {
return "$safeName.partial"
}
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
val stagedNames = linkedSetOf(
buildStagedSafFileName(fileName),
buildLegacyStagedSafFileName(fileName, outputExt)
)
for (stagedName in stagedNames) {
try {
parent.findFile(stagedName)?.delete()
} catch (_: Exception) {
}
}
}
private fun sanitizeFilename(name: String): String {
var sanitized = name
.replace("/", " ")
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
}
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.5.0",
"versionDate": "2026-05-06",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.0/SpotiFLAC-v4.5.0-ios-unsigned.ipa",
"version": "4.5.5",
"versionDate": "2026-05-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 37191956
"size": 34915749
}
]
}

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Before

Width:  |  Height:  |  Size: 539 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+38 -3
View File
@@ -308,16 +308,20 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
t.Fatalf("ReplayGain fields = %#v", fields)
}
qualityPath := filepath.Join(dir, "quality.m4a")
qualityPath := filepath.Join(dir, "quality-alac.m4a")
mvhd := make([]byte, 20)
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
sampleEntry := make([]byte, 32)
copy(sampleEntry[0:4], "mp4a")
copy(sampleEntry[0:4], "alac")
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
sampleEntry[28] = 0xAC
sampleEntry[29] = 0x44
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
alacConfig := make([]byte, 24)
alacConfig[5] = 24
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
t.Fatal(err)
}
@@ -327,6 +331,37 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
}
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
copy(sampleEntry[0:4], "mp4a")
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
}
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
zeroMvhd := make([]byte, 20)
eac3SampleEntry := make([]byte, 32)
copy(eac3SampleEntry[0:4], "ec-3")
eac3SampleEntry[28] = 0xBB
eac3SampleEntry[29] = 0x80
mdhd := make([]byte, 20)
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
eac3QualityFile := append(
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
buildM4AAtom("moov", append(
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
eac3SampleEntry...,
))...,
)
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
}
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
t.Fatal("short ALAC config should not parse")
}
+442
View File
@@ -0,0 +1,442 @@
package gobackend
import (
"encoding/json"
"sort"
"strings"
"sync"
)
type CrossExtensionShareResult struct {
ExtensionID string `json:"extension_id"`
DisplayName string `json:"display_name"`
Found bool `json:"found"`
URL string `json:"url,omitempty"`
ItemName string `json:"item_name,omitempty"`
ItemArtists string `json:"item_artists,omitempty"`
Error string `json:"error,omitempty"`
}
var crossExtensionShareResultCache = struct {
sync.RWMutex
entries map[string]string
order []string
}{
entries: make(map[string]string),
}
const crossExtensionShareResultCacheLimit = 128
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
var req struct {
Name string `json:"name"`
Artists string `json:"artists"`
Type string `json:"type"`
SourceExtensionID string `json:"source_extension_id"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", err
}
req.Name = strings.TrimSpace(req.Name)
req.Artists = strings.TrimSpace(req.Artists)
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
if req.Name == "" {
return "[]", nil
}
if req.Type == "" {
req.Type = "album"
}
providers := getExtensionManager().GetMetadataProviders()
work := make([]*extensionProviderWrapper, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
if provider.extension.ID == req.SourceExtensionID {
continue
}
work = append(work, provider)
}
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
return cached, nil
}
query := req.Name
if req.Artists != "" {
query += " " + req.Artists
}
results := make([]CrossExtensionShareResult, len(work))
var wg sync.WaitGroup
for i, provider := range work {
wg.Add(1)
go func(index int, p *extensionProviderWrapper) {
defer wg.Done()
results[index] = findCollectionForExtension(
p,
req.Type,
req.Name,
req.Artists,
query,
)
}(i, provider)
}
wg.Wait()
data, err := json.Marshal(results)
if err != nil {
return "[]", err
}
response := string(data)
if crossExtensionShareResultsCacheable(results) {
setCrossExtensionShareCache(cacheKey, response)
}
return response, nil
}
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
providerKeys := make([]string, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
ext := provider.extension
displayName := ""
if ext.Manifest != nil {
displayName = ext.Manifest.DisplayName
}
providerKeys = append(providerKeys, strings.Join([]string{
strings.TrimSpace(ext.ID),
strings.TrimSpace(displayName),
strings.TrimSpace(ext.SourceDir),
}, "\x1f"))
}
sort.Strings(providerKeys)
return strings.Join([]string{
normalizeLooseTitle(itemType),
normalizeLooseTitle(name),
normalizeLooseArtistName(artists),
strings.TrimSpace(sourceExtensionID),
strings.Join(providerKeys, "\x1e"),
}, "\x1d")
}
func getCrossExtensionShareCache(key string) string {
if key == "" {
return ""
}
crossExtensionShareResultCache.RLock()
defer crossExtensionShareResultCache.RUnlock()
return crossExtensionShareResultCache.entries[key]
}
func setCrossExtensionShareCache(key string, value string) {
if key == "" || value == "" {
return
}
crossExtensionShareResultCache.Lock()
defer crossExtensionShareResultCache.Unlock()
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
}
crossExtensionShareResultCache.entries[key] = value
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
oldest := crossExtensionShareResultCache.order[0]
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
delete(crossExtensionShareResultCache.entries, oldest)
}
}
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
for _, result := range results {
if result.Found {
continue
}
errText := strings.ToLower(strings.TrimSpace(result.Error))
if errText == "" ||
errText == "no results" ||
errText == "unsupported collection type" ||
strings.HasSuffix(errText, " not found") ||
strings.Contains(errText, "found without shareable link") {
continue
}
return false
}
return true
}
func findCollectionForExtension(
provider *extensionProviderWrapper,
itemType string,
name string,
artists string,
query string,
) CrossExtensionShareResult {
result := CrossExtensionShareResult{
ExtensionID: provider.extension.ID,
}
if provider.extension.Manifest != nil {
result.DisplayName = provider.extension.Manifest.DisplayName
}
if result.DisplayName == "" {
result.DisplayName = provider.extension.ID
}
searchResult, err := searchCollectionCandidates(provider, itemType, query)
if err != nil {
result.Error = err.Error()
return result
}
if searchResult == nil || len(searchResult.Tracks) == 0 {
result.Error = "no results"
return result
}
var best *ExtTrackMetadata
switch itemType {
case "artist":
best = bestArtistTrack(searchResult.Tracks, name)
case "album":
best = bestAlbumTrack(searchResult.Tracks, name, artists)
default:
result.Error = "unsupported collection type"
return result
}
if best == nil {
result.Error = itemType + " not found"
return result
}
url := resolveCollectionShareURL(provider.extension, itemType, best)
if url == "" {
result.Error = itemType + " found without shareable link"
return result
}
result.Found = true
result.URL = url
if itemType == "artist" {
result.ItemName = collectionArtistName(*best)
} else {
result.ItemName = collectionAlbumName(*best)
result.ItemArtists = best.Artists
}
return result
}
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
filter := ""
switch itemType {
case "album":
filter = "albums"
case "artist":
filter = "artists"
}
if filter != "" {
tracks, err := provider.CustomSearch(query, map[string]interface{}{
"filter": filter,
"limit": 10,
})
if err == nil && len(tracks) > 0 {
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
}
}
return provider.SearchTracks(query, 10)
}
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
targetAlbum := normalizeLooseTitle(albumName)
targetArtists := normalizeLooseArtistName(artists)
bestScore := 0
bestIndex := -1
for i := range tracks {
track := tracks[i]
album := normalizeLooseTitle(collectionAlbumName(track))
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
score := 0
if isCollectionItemType(track, "album") {
score += 25
}
if album == targetAlbum {
score += 100
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
score += 50
}
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
score += 30
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 50 {
return nil
}
return &tracks[bestIndex]
}
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
targetArtist := normalizeLooseArtistName(artistName)
bestScore := 0
bestIndex := -1
for i := range tracks {
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
score := 0
if isCollectionItemType(tracks[i], "artist") {
score += 25
}
if artist == targetArtist {
score += 100
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
score += 60
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 60 {
return nil
}
return &tracks[bestIndex]
}
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
if track == nil {
return ""
}
if itemType == "album" {
if isCollectionItemType(*track, "album") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.AlbumURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
return url
}
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
return url
}
return ""
}
if isCollectionItemType(*track, "artist") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.ArtistURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
return url
}
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
return url
}
return ""
}
func collectionAlbumName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "album") {
return track.Name
}
return track.AlbumName
}
func collectionArtistName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "artist") {
return track.Name
}
return track.Artists
}
func collectionID(track ExtTrackMetadata, itemType string) string {
if isCollectionItemType(track, itemType) {
return track.ID
}
return ""
}
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
}
func normalizeShareURL(value string) string {
trimmed := strings.TrimSpace(value)
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return trimmed
}
return ""
}
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
for key, value := range links {
if strings.Contains(strings.ToLower(key), preferredKey) {
if url := normalizeShareURL(value); url != "" {
return url
}
}
}
return ""
}
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return ""
}
id = stripProviderPrefix(strings.TrimSpace(id))
if id == "" {
return ""
}
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
if !ok {
return ""
}
rawTemplate, ok := templates[itemType].(string)
if !ok {
return ""
}
rawTemplate = strings.TrimSpace(rawTemplate)
if rawTemplate == "" {
return ""
}
return strings.ReplaceAll(rawTemplate, "{id}", id)
}
func stripProviderPrefix(id string) string {
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
return id[index+1:]
}
return id
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
+100
View File
@@ -0,0 +1,100 @@
package gobackend
import "testing"
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"album": "https://music.apple.com/us/album/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "1440783617",
Name: "Nevermind",
Artists: "Nirvana",
ItemType: "album",
},
}
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
if best == nil {
t.Fatal("expected album collection item to match")
}
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
t.Fatalf("album share URL = %q", url)
}
}
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"artist": "https://music.youtube.com/browse/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "UCrPe3hLA51968GwxHSZ1llw",
Name: "Nirvana",
ItemType: "artist",
},
}
best := bestArtistTrack(tracks, "Nirvana")
if best == nil {
t.Fatal("expected artist collection item to match")
}
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
t.Fatalf("artist share URL = %q", url)
}
}
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
apple := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "apple",
SourceDir: "/extensions/apple",
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
},
}
qobuz := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "qobuz",
SourceDir: "/extensions/qobuz",
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
},
}
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
if first != second {
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
}
}
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
cacheable := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "album not found"},
{ExtensionID: "tidal", Error: "no results"},
}
if !crossExtensionShareResultsCacheable(cacheable) {
t.Fatal("expected found and deterministic not-found results to be cacheable")
}
transient := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "request failed: timeout"},
}
if crossExtensionShareResultsCacheable(transient) {
t.Fatal("expected transient extension errors to skip cache")
}
}
+165 -53
View File
@@ -313,6 +313,7 @@ type DownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ActualExtension string `json:"actual_extension,omitempty"`
ActualContainer string `json:"actual_container,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
@@ -342,6 +343,7 @@ type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
AudioCodec string
Title string
Artist string
Album string
@@ -377,6 +379,7 @@ type reEnrichRequest struct {
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
LyricsMode string `json:"lyrics_mode,omitempty"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
@@ -412,6 +415,21 @@ func (r *reEnrichRequest) shouldUpdateField(field string) bool {
return false
}
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
// callers that do not send lyrics_mode are unaffected.
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
}
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
// next to the audio file. Only 'external' and 'both' request a sidecar.
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
return mode == "external" || mode == "both"
}
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
if req == nil {
return
@@ -576,7 +594,7 @@ func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[str
}
}
if req.shouldUpdateField("lyrics") {
if lyricsLRC != "" {
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
metadata["LYRICS"] = lyricsLRC
metadata["UNSYNCEDLYRICS"] = lyricsLRC
}
@@ -592,12 +610,24 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
downloadReq := reEnrichDownloadRequest(req)
currentISRC := strings.TrimSpace(req.ISRC)
currentAlbum := strings.TrimSpace(req.AlbumName)
effectiveTrackName := req.TrackName
if isPlaceholderReEnrichValue(effectiveTrackName) {
effectiveTrackName = ""
}
effectiveArtistName := req.ArtistName
if isPlaceholderReEnrichValue(effectiveArtistName) {
effectiveArtistName = ""
}
var best *ExtTrackMetadata
bestScore := -1 << 30
for i := range tracks {
track := &tracks[i]
score := 0
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
resolved := resolvedTrackInfo{
Title: track.Name,
@@ -605,22 +635,39 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
if !exactISRCMatch {
if effectiveTrackName != "" && !titleMatches {
continue
}
if effectiveArtistName != "" && !artistMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
continue
}
}
if verified {
score += 2000
}
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
if exactISRCMatch {
score += 10000
}
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
if titleMatches {
score += 400
}
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
if artistMatches {
score += 320
}
if currentAlbum != "" && track.AlbumName != "" {
switch {
case titlesMatch(currentAlbum, track.AlbumName):
case albumMatches:
score += 120
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
@@ -863,6 +910,7 @@ func buildDownloadSuccessResponse(
AlreadyExists: alreadyExists,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
AudioCodec: result.AudioCodec,
ActualExtension: result.ActualExtension,
ActualContainer: result.ActualContainer,
RequiresContainerConversion: result.RequiresContainerConversion,
@@ -920,7 +968,12 @@ func enrichResultQualityFromFile(result *DownloadResult) {
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
result.AudioCodec = quality.Codec
if quality.Codec != "" {
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
return
}
@@ -1101,7 +1154,7 @@ func CleanupConnections() {
func ReadFileMetadata(filePath string) (string, error) {
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
isMp3 := strings.HasSuffix(lower, ".mp3")
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
isApe := strings.HasSuffix(lower, ".ape")
@@ -1126,9 +1179,13 @@ func ReadFileMetadata(filePath string) (string, error) {
"composer": "",
"comment": "",
"duration": 0,
"format": "",
"audio_codec": "",
}
if isFlac {
result["format"] = "flac"
result["audio_codec"] = "flac"
metadata, err := ReadMetadata(filePath)
if err != nil {
// File may have wrong extension (e.g. opus saved as .flac).
@@ -1161,6 +1218,8 @@ func ReadFileMetadata(filePath string) (string, error) {
result["bitrate"] = quality.Bitrate / 1000
}
}
result["format"] = "opus"
result["audio_codec"] = "opus"
} else {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
@@ -1190,12 +1249,16 @@ func ReadFileMetadata(filePath string) (string, error) {
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.Codec != "" {
result["audio_codec"] = quality.Codec
}
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
}
} else if isM4A {
result["format"] = "m4a"
meta, err := ReadM4ATags(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
@@ -1227,8 +1290,17 @@ func ReadFileMetadata(filePath string) (string, error) {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
result["audio_codec"] = quality.Codec
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result["format"] = format
}
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
result["bitrate"] = quality.Bitrate
}
}
} else if isMp3 {
result["format"] = "mp3"
result["audio_codec"] = "mp3"
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
@@ -1265,6 +1337,8 @@ func ReadFileMetadata(filePath string) (string, error) {
}
}
} else if isOgg {
result["format"] = "opus"
result["audio_codec"] = "opus"
meta, err := ReadOggVorbisComments(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
@@ -1300,6 +1374,8 @@ func ReadFileMetadata(filePath string) (string, error) {
}
}
} else if isApe || isWv || isMpc {
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
result["audio_codec"] = result["format"]
// APE, WavPack, Musepack: read APEv2 tags
apeTag, apeErr := ReadAPETags(filePath)
if apeErr == nil && apeTag != nil {
@@ -1400,6 +1476,19 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
coverPath := strings.TrimSpace(fields["cover_path"])
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native_m4a_replaygain",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
if isFlac {
if err := EditFlacFields(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
@@ -1510,19 +1599,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
if isM4AFile && hasOnlyM4AReplayGainFields(fields) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native_m4a_replaygain",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
resp := map[string]any{
"success": true,
"method": "ffmpeg",
@@ -1532,6 +1608,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
func isMP4ContainerFile(filePath string) bool {
f, err := os.Open(filePath)
if err != nil {
return false
}
defer f.Close()
header := make([]byte, 12)
n, err := f.Read(header)
if err != nil || n < 8 {
return false
}
return string(header[4:8]) == "ftyp"
}
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
allowed := map[string]struct{}{
"replaygain_track_gain": {},
@@ -1865,6 +1956,11 @@ func normalizeExtensionTrackMetadataMap(
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"album_id": track.AlbumID,
"album_url": track.AlbumURL,
"artist_id": track.ArtistID,
"artist_url": track.ArtistURL,
"external_urls": track.ExternalURL,
"duration_ms": track.DurationMS,
"images": coverURL,
"cover_url": coverURL,
@@ -2283,37 +2379,7 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
}
func errorResponse(msg string) (string, error) {
errorType := "unknown"
lowerMsg := strings.ToLower(msg)
if strings.Contains(lowerMsg, "isp blocking") ||
strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") {
errorType = "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
errorType = "cancelled"
} else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
strings.Contains(lowerMsg, "failed to create file") ||
strings.Contains(lowerMsg, "failed to create directory") {
errorType = "permission"
} else if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") ||
strings.Contains(lowerMsg, "track not found") ||
strings.Contains(lowerMsg, "all services failed") {
errorType = "not_found"
} else if strings.Contains(lowerMsg, "rate limit") ||
strings.Contains(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
errorType = "rate_limit"
} else if strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "dial") {
errorType = "network"
}
errorType := classifyDownloadErrorType(msg)
resp := DownloadResponse{
Success: false,
@@ -2324,6 +2390,41 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil
}
func classifyDownloadErrorType(msg string) string {
lowerMsg := strings.ToLower(msg)
if strings.Contains(lowerMsg, "isp blocking") ||
strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") {
return "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
return "cancelled"
} else if strings.Contains(lowerMsg, "rate limit") ||
strings.Contains(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
return "rate_limit"
} else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
strings.Contains(lowerMsg, "failed to create file") ||
strings.Contains(lowerMsg, "failed to create directory") {
return "permission"
} else if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") ||
strings.Contains(lowerMsg, "track not found") ||
strings.Contains(lowerMsg, "all services failed") {
return "not_found"
} else if strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "dial") {
return "network"
}
return "unknown"
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
@@ -2670,7 +2771,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
metadata.ISRC = req.ISRC
}
if req.shouldUpdateField("lyrics") {
metadata.Lyrics = lyricsLRC
if req.lyricsEmbedEnabled() {
metadata.Lyrics = lyricsLRC
}
}
if req.shouldUpdateField("extra") {
metadata.Genre = req.Genre
@@ -2705,6 +2808,11 @@ func ReEnrichFile(requestJSON string) (string, error) {
"method": "native",
"success": true,
"enriched_metadata": enrichedMeta,
"lyrics": lyricsLRC,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
@@ -2720,6 +2828,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
"lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta,
"metadata": ffmpegMetadata,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
jsonBytes, _ := json.Marshal(result)
+28
View File
@@ -11,6 +11,26 @@ import (
"time"
)
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
if got != "rate_limit" {
t.Fatalf("expected rate_limit, got %q", got)
}
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
if err != nil {
t.Fatalf("errorResponse returned error: %v", err)
}
var response DownloadResponse
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
t.Fatalf("invalid response JSON: %v", err)
}
if response.ErrorType != "rate_limit" {
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
}
}
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
@@ -85,6 +105,14 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
}
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
t.Fatal(err)
}
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
}
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
t.Fatal("expected invalid metadata JSON")
}
+84
View File
@@ -407,6 +407,90 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
}
}
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "wrong-rich-metadata",
Name: "Different Song",
Artists: "Different Artist",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
TrackNumber: 4,
DiscNumber: 1,
ISRC: "WRONG1234567",
ProviderID: "deezer",
},
}
if best := selectBestReEnrichTrack(req, tracks); best != nil {
t.Fatalf("selected track = %q, want no match", best.ID)
}
}
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
ISRC: "USRC17607839",
DurationMs: 999999000,
}
tracks := []ExtTrackMetadata{
{
ID: "same-isrc",
Name: "Different Song",
Artists: "Different Artist",
DurationMS: 180000,
ISRC: "USRC17607839",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected exact ISRC candidate to be selected")
}
if best.ID != "same-isrc" {
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
}
}
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
req := reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "album-match",
Name: "Sign of the Times",
Artists: "Harry Styles",
AlbumName: "Harry Styles",
DurationMS: 180000,
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
}
if best.ID != "album-match" {
t.Fatalf("selected track = %q, want album-match", best.ID)
}
}
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song",
+58
View File
@@ -8,12 +8,14 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 60 * time.Second
)
type ExtensionHealthResult struct {
@@ -38,6 +40,16 @@ type ExtensionHealthCheckResult struct {
CheckedAt string `json:"checked_at"`
}
type cachedExtensionHealthResult struct {
result ExtensionHealthResult
expiresAt time.Time
}
var (
extensionHealthCacheMu sync.Mutex
extensionHealthCache = map[string]cachedExtensionHealthResult{}
)
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -53,6 +65,38 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
return string(bytes), nil
}
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return CheckExtensionHealth(ext)
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return CheckExtensionHealth(ext)
}
now := time.Now()
extensionHealthCacheMu.Lock()
cached, ok := extensionHealthCache[cacheKey]
if ok && now.Before(cached.expiresAt) {
extensionHealthCacheMu.Unlock()
return cached.result
}
extensionHealthCacheMu.Unlock()
result := CheckExtensionHealth(ext)
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: now.Add(ttl),
}
extensionHealthCacheMu.Unlock()
return result
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
now := time.Now().UTC().Format(time.RFC3339)
result := ExtensionHealthResult{
@@ -98,6 +142,20 @@ func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
return result
}
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
ttl := extensionHealthDefaultCache
for _, check := range checks {
if check.CacheTTLSeconds <= 0 {
continue
}
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
if checkTTL < ttl {
ttl = checkTTL
}
}
return ttl
}
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
method := strings.ToUpper(strings.TrimSpace(check.Method))
if method == "" {
+2 -2
View File
@@ -367,8 +367,8 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
jar, _ := newSimpleCookieJar()
runtime.cookieJar = jar
}
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second))
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout)
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
+117 -8
View File
@@ -22,6 +22,11 @@ type ExtTrackMetadata struct {
Artists string `json:"artists"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
ArtistURL string `json:"artist_url,omitempty"`
ExternalURL string `json:"external_urls,omitempty"`
DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"`
Images string `json:"images,omitempty"`
@@ -236,6 +241,7 @@ func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult
FilePath: strings.TrimSpace(result.FilePath),
BitDepth: result.BitDepth,
SampleRate: result.SampleRate,
AudioCodec: strings.TrimSpace(result.AudioCodec),
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
@@ -376,6 +382,64 @@ func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
return availability != nil && availability.SkipFallback
}
func fallbackRuntimeHealthStatus(ext *loadedExtension) string {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return "unknown"
}
status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status))
switch status {
case "online", "degraded", "offline":
return status
default:
return "unknown"
}
}
func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string {
if len(priority) == 0 || extManager == nil {
return priority
}
online := make([]string, 0, len(priority))
degraded := make([]string, 0, len(priority))
unknown := make([]string, 0, len(priority))
for _, rawProviderID := range priority {
providerID := strings.TrimSpace(rawProviderID)
if providerID == "" {
continue
}
if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) {
unknown = append(unknown, providerID)
continue
}
ext, err := extManager.GetExtension(providerID)
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
unknown = append(unknown, providerID)
continue
}
switch fallbackRuntimeHealthStatus(ext) {
case "online":
online = append(online, providerID)
case "degraded":
degraded = append(degraded, providerID)
case "offline":
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID)
default:
unknown = append(unknown, providerID)
}
}
result := make([]string, 0, len(online)+len(degraded)+len(unknown))
result = append(result, online...)
result = append(result, degraded...)
result = append(result, unknown...)
return result
}
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
if availability != nil {
if reason := strings.TrimSpace(availability.Reason); reason != "" {
@@ -390,10 +454,14 @@ func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
reason := resolveExtensionAvailabilityReason(availability, err)
errorType := classifyDownloadErrorType(reason)
if errorType == "unknown" {
errorType = "extension_error"
}
return &DownloadResponse{
Success: false,
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
ErrorType: "extension_error",
ErrorType: errorType,
Service: providerID,
}
}
@@ -420,6 +488,7 @@ type ExtDownloadResult struct {
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
@@ -678,6 +747,11 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
Artists: gojaObjectString(obj, "artists"),
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
AlbumID: gojaObjectString(obj, "album_id", "albumId"),
AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"),
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
Images: gojaObjectString(obj, "images"),
@@ -873,6 +947,7 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
Title: gojaObjectString(obj, "title"),
@@ -1783,7 +1858,9 @@ func isRetiredBuiltInDownloadProvider(providerID string) bool {
}
switch normalized {
case "deezer", "qobuz", "tidal":
return true
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
return manifest.IsDownloadProvider()
})
default:
return false
}
@@ -1796,12 +1873,36 @@ func isRetiredBuiltInMetadataProvider(providerID string) bool {
}
switch normalized {
case "deezer", "spotify", "qobuz", "tidal":
return true
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
return manifest.IsMetadataProvider()
})
default:
return false
}
}
func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool {
if providerID == "" || matches == nil {
return false
}
manager := getExtensionManager()
manager.mu.RLock()
defer manager.mu.RUnlock()
for id, ext := range manager.extensions {
if !strings.EqualFold(strings.TrimSpace(id), providerID) {
continue
}
if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil {
return false
}
return matches(ext.Manifest)
}
return false
}
func SetExtensionFallbackProviderIDs(providerIDs []string) {
extensionFallbackProviderIDsMu.Lock()
defer extensionFallbackProviderIDsMu.Unlock()
@@ -2371,6 +2472,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source)
for _, providerID := range priority {
if isDownloadCancelled(req.ItemID) {
return nil, ErrDownloadCancelled
@@ -2516,10 +2619,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if lastErr != nil {
errorType := classifyDownloadErrorType(lastErr.Error())
if errorType == "unknown" {
errorType = "not_found"
}
return &DownloadResponse{
Success: false,
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: "not_found",
ErrorType: errorType,
}, nil
}
@@ -2554,9 +2661,10 @@ func buildOutputPath(req DownloadRequest) string {
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
if filename == "" {
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
if strings.TrimSpace(filename) == "" {
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
}
filename = sanitizeFilename(filename)
ext := strings.TrimSpace(req.OutputExt)
if ext == "" {
@@ -2612,9 +2720,10 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
if filename == "" {
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
if strings.TrimSpace(filename) == "" {
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
}
filename = sanitizeFilename(filename)
outputExt := strings.TrimSpace(req.OutputExt)
if outputExt == "" {
+159
View File
@@ -10,6 +10,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -92,6 +93,125 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
}
}
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
manager := getExtensionManager()
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager.mu.Lock()
previous, hadPrevious := manager.extensions[ext.ID]
manager.extensions[ext.ID] = ext
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadPrevious {
manager.extensions[ext.ID] = previous
} else {
delete(manager.extensions, ext.ID)
}
manager.mu.Unlock()
}()
SetProviderPriority([]string{"deezer", "custom-ext"})
got := GetProviderPriority()
want := []string{"deezer", "custom-ext"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
manager := getExtensionManager()
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
amazon.ID = "amazon"
amazon.Manifest.Name = "amazon"
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "://bad",
Required: true,
}}
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
plain.ID = "plain"
plain.Manifest.Name = "plain"
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
deezer.ID = "deezer"
deezer.Manifest.Name = "deezer"
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "https://example.test/health",
}}
manager.mu.Lock()
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
previousPlain, hadPlain := manager.extensions[plain.ID]
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
manager.extensions[amazon.ID] = amazon
manager.extensions[plain.ID] = plain
manager.extensions[deezer.ID] = deezer
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadAmazon {
manager.extensions[amazon.ID] = previousAmazon
} else {
delete(manager.extensions, amazon.ID)
}
if hadPlain {
manager.extensions[plain.ID] = previousPlain
} else {
delete(manager.extensions, plain.ID)
}
if hadDeezer {
manager.extensions[deezer.ID] = previousDeezer
} else {
delete(manager.extensions, deezer.ID)
}
manager.mu.Unlock()
extensionHealthCacheMu.Lock()
delete(extensionHealthCache, deezer.ID)
extensionHealthCacheMu.Unlock()
}()
extensionHealthCacheMu.Lock()
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
result: ExtensionHealthResult{
ExtensionID: deezer.ID,
Status: "online",
CheckedAt: time.Now().UTC().Format(time.RFC3339),
},
expiresAt: time.Now().Add(time.Minute),
}
extensionHealthCacheMu.Unlock()
got := prioritizeFallbackProvidersByHealth(
[]string{"amazon", "plain", "deezer"},
manager,
"",
)
want := []string{"deezer", "plain"}
if len(got) != len(want) {
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
}
}
}
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
if normalized == nil {
@@ -286,6 +406,45 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
}
}
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := buildOutputPath(DownloadRequest{
TrackName: `Gehra Hua (From "Dhurandhar")`,
ArtistName: "Artist",
OutputDir: outputDir,
OutputExt: ".flac",
FilenameFormat: "{artist} - {title}",
})
base := filepath.Base(outputPath)
if strings.ContainsAny(base, `<>:"/\|?*`) {
t.Fatalf("output filename still contains illegal characters: %q", base)
}
if strings.Contains(base, `"`) {
t.Fatalf("output filename still contains straight double quote: %q", base)
}
}
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
SetAllowedDownloadDirs(nil)
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
TrackName: `Gehra Hua (From "Dhurandhar")`,
ArtistName: "Artist",
OutputFD: 123,
OutputExt: ".flac",
FilenameFormat: "{artist} - {title}",
}, ext)
base := filepath.Base(resolved)
if strings.ContainsAny(base, `<>:"/\|?*`) {
t.Fatalf("extension output filename still contains illegal characters: %q", base)
}
}
func TestShouldStopProviderFallback(t *testing.T) {
if shouldStopProviderFallback(nil) {
t.Fatal("nil availability should not stop fallback")
+10 -5
View File
@@ -140,8 +140,8 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
return runtime
}
@@ -247,13 +247,18 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
return req.WithContext(initDownloadCancel(itemID))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops.
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
// API calls can use response compression for faster metadata/search loads,
// while media downloads keep identity transfer semantics for progress/streaming.
transport := sharedTransport
if compressResponses {
transport = extensionAPITransport
}
client := &http.Client{
Transport: sharedTransport,
Transport: transport,
Timeout: timeout,
Jar: jar,
}
+1
View File
@@ -131,6 +131,7 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
"sample_rate": quality.SampleRate,
"total_samples": quality.TotalSamples,
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
"codec": quality.Codec,
})
}
+30 -4
View File
@@ -136,6 +136,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
var onProgress goja.Callable
var headers map[string]string
var chunkedDownload bool
trackItemBytes := true
var chunkSize int64
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export()
@@ -151,6 +152,15 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
onProgress = callable
}
}
if trackBytes, ok := opts["trackItemBytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
}
if chunked, ok := opts["chunked"]; ok {
switch v := chunked.(type) {
case bool:
@@ -194,7 +204,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
if chunkedDownload {
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress)
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
}
req, err := http.NewRequest("GET", urlStr, nil)
@@ -244,7 +254,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
contentLength := resp.ContentLength
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && contentLength > 0 {
SetItemBytesTotal(activeItemID, contentLength)
}
@@ -301,6 +311,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
}
if shouldTrackItemBytes {
if contentLength > 0 {
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
} else if written > 0 {
SetItemBytesReceived(activeItemID, written)
}
}
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
return r.vm.ToValue(map[string]interface{}{
@@ -313,7 +331,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
// fileDownloadChunked downloads a URL using sequential Range requests.
// This is needed for servers (like YouTube's googlevideo CDN) that reject
// non-ranged or large-range requests with 403 and require small chunk downloads.
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable) goja.Value {
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
// First, get the total content length with a small probe request
probeReq, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
@@ -383,7 +401,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
SetItemDownloading(activeItemID)
}
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && totalSize > 0 {
SetItemBytesTotal(activeItemID, totalSize)
}
@@ -526,6 +544,14 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
}
}
if shouldTrackItemBytes {
if totalSize > 0 {
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
} else if totalWritten > 0 {
SetItemBytesReceived(activeItemID, totalWritten)
}
}
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
return r.vm.ToValue(map[string]interface{}{
+1
View File
@@ -415,6 +415,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
"sampleRate": quality.SampleRate,
"totalSamples": quality.TotalSamples,
"duration": quality.Duration,
"codec": quality.Codec,
})
})
+11 -11
View File
@@ -10,20 +10,20 @@ require (
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/crypto v0.50.0
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
golang.org/x/net v0.53.0
golang.org/x/text v0.36.0
golang.org/x/crypto v0.52.0
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a
golang.org/x/net v0.55.0
golang.org/x/text v0.37.0
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/klauspost/compress v1.18.5 // indirect
golang.org/x/mod v0.35.0 // indirect
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
github.com/klauspost/compress v1.18.6 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)
+22 -22
View File
@@ -1,11 +1,11 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
@@ -18,10 +18,10 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TC
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
@@ -30,22 +30,22 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ=
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a h1:sEcsLeiCTTaHGWn+v81+PLAOzzOA9wmzNRqr1WfCmVY=
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+24 -11
View File
@@ -77,6 +77,26 @@ var sharedTransport = &http.Transport{
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
var extensionAPITransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: false,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
var metadataTransport = &http.Transport{
@@ -95,6 +115,7 @@ var metadataTransport = &http.Transport{
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
var sharedClient = &http.Client{
@@ -131,6 +152,7 @@ func GetDownloadClient() *http.Client {
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
extensionAPITransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
}
@@ -143,6 +165,7 @@ func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
networkCompatibilityMu.Unlock()
applyTLSCompatibility(sharedTransport, insecureTLS)
applyTLSCompatibility(extensionAPITransport, insecureTLS)
applyTLSCompatibility(metadataTransport, insecureTLS)
CloseIdleConnections()
@@ -156,17 +179,7 @@ func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
}
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
if insecureTLS {
cfg := &tls.Config{InsecureSkipVerify: true}
if transport.TLSClientConfig != nil {
cfg = transport.TLSClientConfig.Clone()
cfg.InsecureSkipVerify = true
}
transport.TLSClientConfig = cfg
return
}
transport.TLSClientConfig = nil
transport.TLSClientConfig = newTLSCompatibilityConfig(insecureTLS)
}
type compatibilityTransport struct {
+25
View File
@@ -1,6 +1,8 @@
package gobackend
import (
"crypto/x509"
"encoding/pem"
"errors"
"io"
"net/http"
@@ -25,11 +27,34 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if GetSharedClient() == nil || GetDownloadClient() == nil {
t.Fatal("expected shared clients")
}
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
t.Fatal("expected supplemental TLS root pool")
}
block, _ := pem.Decode([]byte(isrgRootX2PEM))
if block == nil {
t.Fatal("failed to decode ISRG Root X2")
}
rootX2, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("failed to parse ISRG Root X2: %v", err)
}
if _, err := rootX2.Verify(x509.VerifyOptions{
Roots: supplementalRootCAs(),
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); err != nil {
t.Fatalf("ISRG Root X2 should verify with supplemental roots: %v", err)
}
SetNetworkCompatibilityOptions(true, true)
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
t.Fatalf("network opts = %#v", opts)
}
if !sharedTransport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("expected insecure TLS config to be applied")
}
SetNetworkCompatibilityOptions(false, false)
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("expected secure TLS config to be restored")
}
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
t.Fatal("GET should fallback")
}
+5 -2
View File
@@ -42,9 +42,12 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return nil, err
}
opts := GetNetworkCompatibilityOptions()
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
RootCAs: supplementalRootCAs(),
InsecureSkipVerify: opts.InsecureTLS,
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
}, utls.HelloChrome_Auto)
if err := tlsConn.Handshake(); err != nil {
+61 -2
View File
@@ -68,6 +68,8 @@ var (
var supportedAudioFormats = map[string]bool{
".flac": true,
".m4a": true,
".mp4": true,
".aac": true,
".mp3": true,
".opus": true,
".ogg": true,
@@ -87,6 +89,19 @@ type scannedCueFileInfo struct {
audioPath string
}
func isLibraryStagingFile(path string) bool {
name := strings.ToLower(filepath.Base(path))
if strings.HasSuffix(name, ".partial") {
return true
}
for ext := range supportedAudioFormats {
if strings.HasSuffix(name, ".partial"+ext) {
return true
}
}
return false
}
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
var files []libraryAudioFileInfo
@@ -104,6 +119,9 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
if entry.IsDir() {
return nil
}
if isLibraryStagingFile(path) {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if !supportedAudioFormats[ext] {
@@ -314,7 +332,7 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
switch ext {
case ".flac":
return scanFLACFile(filePath, result, displayNameHint)
case ".m4a":
case ".m4a", ".mp4", ".aac":
return scanM4AFile(filePath, result, displayNameHint)
case ".mp3":
return scanMP3File(filePath, result, displayNameHint)
@@ -394,7 +412,6 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
metadata, err := ReadM4ATags(filePath)
if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
if metadata != nil {
@@ -421,12 +438,54 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate
}
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result.Format = format
if isLosslessLibraryFormat(format) {
result.Bitrate = 0
}
}
}
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func libraryFormatForM4ACodec(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "flac":
return "flac"
case "alac":
return "alac"
case "eac3", "ec-3":
return "eac3"
case "ac3", "ac-3":
return "ac3"
case "ac4", "ac-4":
return "ac4"
case "aac", "mp4a":
return "m4a"
default:
return ""
}
}
func isLosslessLibraryFormat(format string) bool {
switch strings.ToLower(strings.TrimSpace(format)) {
case "flac", "alac":
return true
default:
return false
}
}
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
@@ -42,6 +42,14 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil {
t.Fatal(err)
}
legacyPartialPath := filepath.Join(albumDir, "Artist - Song.partial.flac")
if err := os.WriteFile(legacyPartialPath, []byte("partial flac"), 0600); err != nil {
t.Fatal(err)
}
newPartialPath := filepath.Join(albumDir, "Artist - Song.flac.partial")
if err := os.WriteFile(newPartialPath, []byte("partial flac"), 0600); err != nil {
t.Fatal(err)
}
files, err := collectLibraryAudioFiles(dir, make(chan struct{}))
if err != nil {
@@ -50,6 +58,11 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
if len(files) < 4 {
t.Fatalf("files = %#v", files)
}
for _, file := range files {
if file.path == legacyPartialPath || file.path == newPartialPath {
t.Fatalf("staging file should be ignored: %#v", files)
}
}
cancelCh := make(chan struct{})
close(cancelCh)
if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil {
+77 -7
View File
@@ -26,6 +26,11 @@ const (
LyricsProviderMusixmatch = "musixmatch"
LyricsProviderAppleMusic = "apple_music"
LyricsProviderQQMusic = "qqmusic"
LyricsProviderSpotify = "spotify"
LyricsProviderDeezer = "deezer"
LyricsProviderYouTube = "youtube"
LyricsProviderKugou = "kugou"
LyricsProviderGenius = "genius"
)
var DefaultLyricsProviders = []string{
@@ -68,6 +73,7 @@ type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
AppleElrcWordSync bool `json:"apple_elrc_word_sync"`
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
}
@@ -75,6 +81,7 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{
IncludeTranslationNetease: false,
IncludeRomanizationNetease: false,
MultiPersonWordByWord: true,
AppleElrcWordSync: false,
MusixmatchLanguage: "",
}
@@ -100,6 +107,11 @@ func SetLyricsProviderOrder(providers []string) {
LyricsProviderMusixmatch: true,
LyricsProviderAppleMusic: true,
LyricsProviderQQMusic: true,
LyricsProviderSpotify: true,
LyricsProviderDeezer: true,
LyricsProviderYouTube: true,
LyricsProviderKugou: true,
LyricsProviderGenius: true,
}
var valid []string
@@ -130,10 +142,15 @@ func GetLyricsProviderOrder() []string {
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": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"},
{"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"},
{"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"},
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
}
}
@@ -151,12 +168,18 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
lyricsFetchOptionsMu.Lock()
defer lyricsFetchOptionsMu.Unlock()
changed := lyricsFetchOptions != normalized
lyricsFetchOptions = normalized
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
if changed {
globalLyricsCache.ClearAll()
}
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v apple_elrc=%v musixmatch_lang=%q\n",
normalized.IncludeTranslationNetease,
normalized.IncludeRomanizationNetease,
normalized.MultiPersonWordByWord,
normalized.AppleElrcWordSync,
normalized.MusixmatchLanguage,
)
}
@@ -530,9 +553,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
if err != nil && primaryArtist != artistName {
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
}
case LyricsProviderQQMusic:
@@ -542,6 +565,53 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
case LyricsProviderSpotify:
spotifyClient := NewSpotifyLyricsClient()
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderDeezer:
deezerClient := NewDeezerLyricsClient()
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
case LyricsProviderYouTube:
youtubeClient := NewYouTubeLyricsClient()
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderKugou:
kugouClient := NewKugouLyricsClient()
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderGenius:
geniusClient := NewGeniusLyricsClient()
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
default:
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
continue
+224 -33
View File
@@ -7,7 +7,9 @@ import (
"math"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
@@ -15,6 +17,8 @@ type AppleMusicClient struct {
httpClient *http.Client
}
const appleMusicCatalogBaseURL = "https://amp-api.music.apple.com/v1/catalog/us"
type appleMusicSearchResult struct {
ID string `json:"id"`
SongName string `json:"songName"`
@@ -23,9 +27,33 @@ type appleMusicSearchResult struct {
Duration int `json:"duration"`
}
type appleMusicCatalogSearchResponse struct {
Results struct {
Songs *struct {
Data []struct {
ID string `json:"id"`
} `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"`
DurationInMillis int `json:"durationInMillis"`
} `json:"attributes"`
} `json:"songs"`
} `json:"resources"`
}
type paxResponse struct {
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"` // List of lyric lines
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"`
ELRC string `json:"elrc"`
ELRCMultiPerson string `json:"elrcMultiPerson"`
Plain string `json:"plain"`
TTMLContent string `json:"ttmlContent"`
}
type paxLyrics struct {
@@ -44,6 +72,11 @@ type paxLyricDetail struct {
EndTime *int `json:"endtime"`
}
var (
appleMusicTokenMu sync.Mutex
appleMusicCachedToken string
)
func NewAppleMusicClient() *AppleMusicClient {
return &AppleMusicClient{
httpClient: NewMetadataHTTPClient(20 * time.Second),
@@ -100,36 +133,164 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
return &results[bestIndex]
}
func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
appleMusicTokenMu.Lock()
defer appleMusicTokenMu.Unlock()
if appleMusicCachedToken != "" {
return appleMusicCachedToken, nil
}
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
if err != nil {
return "", fmt.Errorf("failed to create apple music page request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch apple music page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("apple music page returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read apple music page: %w", err)
}
indexPath := regexp.MustCompile(`/assets/index~[^"' <]+\.js`).FindString(string(body))
if indexPath == "" {
return "", fmt.Errorf("apple music index script not found")
}
jsReq, err := http.NewRequest("GET", "https://beta.music.apple.com"+indexPath, nil)
if err != nil {
return "", fmt.Errorf("failed to create apple music script request: %w", err)
}
jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
jsResp, err := c.httpClient.Do(jsReq)
if err != nil {
return "", fmt.Errorf("failed to fetch apple music script: %w", err)
}
defer jsResp.Body.Close()
if jsResp.StatusCode != http.StatusOK {
return "", fmt.Errorf("apple music script returned HTTP %d", jsResp.StatusCode)
}
jsBody, err := io.ReadAll(jsResp.Body)
if err != nil {
return "", fmt.Errorf("failed to read apple music script: %w", err)
}
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
if token == "" {
return "", fmt.Errorf("apple music token not found")
}
appleMusicCachedToken = token
return token, nil
}
func clearAppleMusicToken() {
appleMusicTokenMu.Lock()
defer appleMusicTokenMu.Unlock()
appleMusicCachedToken = ""
}
func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusicSearchResult, error) {
params := url.Values{}
params.Set("term", query)
params.Set("types", "songs")
params.Set("limit", "25")
params.Set("l", "en-US")
params.Set("platform", "web")
params.Set("format[resources]", "map")
params.Set("include[songs]", "artists")
params.Set("extend", "artistUrl")
searchURL := appleMusicCatalogBaseURL + "/search?" + params.Encode()
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create apple music catalog 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", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("x-apple-renewal", "true")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("apple music catalog search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("apple music catalog search unauthorized")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
}
var searchResp appleMusicCatalogSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode apple music catalog response: %w", err)
}
if searchResp.Results.Songs == nil || searchResp.Resources == nil {
return nil, nil
}
results := make([]appleMusicSearchResult, 0, len(searchResp.Results.Songs.Data))
for _, item := range searchResp.Results.Songs.Data {
detail, ok := searchResp.Resources.Songs[item.ID]
if !ok {
continue
}
attr := detail.Attributes
results = append(results, appleMusicSearchResult{
ID: item.ID,
SongName: attr.Name,
ArtistName: attr.ArtistName,
AlbumName: attr.AlbumName,
Duration: attr.DurationInMillis,
})
}
return results, nil
}
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("empty search query")
}
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
req, err := http.NewRequest("GET", searchURL, nil)
token, err := c.getAppleMusicToken()
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
return "", err
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
clearAppleMusicToken()
token, tokenErr := c.getAppleMusicToken()
if tokenErr != nil {
return "", tokenErr
}
searchResp, err = c.searchSongWithToken(token, strings.TrimSpace(query))
}
if err != nil {
return "", fmt.Errorf("apple music search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
}
var searchResp []appleMusicSearchResult
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return "", fmt.Errorf("failed to decode apple music response: %w", err)
return "", err
}
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
@@ -173,25 +334,50 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
return bodyStr, nil
}
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
var stringPayload string
if err := json.Unmarshal([]byte(rawJSON), &stringPayload); err == nil {
stringPayload = strings.TrimSpace(stringPayload)
if stringPayload != "" {
return stringPayload, nil
}
}
var paxResp paxResponse
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil &&
(paxResp.Content != nil ||
strings.TrimSpace(paxResp.ELRCMultiPerson) != "" ||
strings.TrimSpace(paxResp.ELRC) != "" ||
strings.TrimSpace(paxResp.Plain) != "" ||
strings.TrimSpace(paxResp.TTMLContent) != "") {
if preserveWordTiming && multiPersonWordByWord && strings.TrimSpace(paxResp.ELRCMultiPerson) != "" {
return strings.TrimSpace(paxResp.ELRCMultiPerson), nil
}
if preserveWordTiming && strings.TrimSpace(paxResp.ELRC) != "" {
return strings.TrimSpace(paxResp.ELRC), nil
}
if strings.TrimSpace(paxResp.Plain) != "" && len(paxResp.Content) == 0 {
return strings.TrimSpace(paxResp.Plain), nil
}
if len(paxResp.Content) == 0 {
return "", fmt.Errorf("unsupported apple music lyrics payload")
}
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
}
var directLyrics []paxLyrics
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord, preserveWordTiming), nil
}
return "", fmt.Errorf("failed to parse pax lyrics response")
}
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail, preserveWordTiming bool) {
lastStart := ""
for _, syllable := range details {
if syllable.Timestamp != nil {
if preserveWordTiming && syllable.Timestamp != nil {
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
if start != lastStart {
builder.WriteString(start)
@@ -204,13 +390,13 @@ func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
builder.WriteString(" ")
}
if syllable.EndTime != nil {
if preserveWordTiming && syllable.EndTime != nil {
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
}
}
}
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool, preserveWordTiming bool) string {
var sb strings.Builder
for i, line := range content {
@@ -230,11 +416,11 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
}
}
appendPaxLyricDetail(&sb, line.Text)
appendPaxLyricDetail(&sb, line.Text, preserveWordTiming)
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
sb.WriteString("\n[bg:")
appendPaxLyricDetail(&sb, line.BackgroundText)
appendPaxLyricDetail(&sb, line.BackgroundText, preserveWordTiming)
sb.WriteString("]")
}
} else {
@@ -253,6 +439,7 @@ func (c *AppleMusicClient) FetchLyrics(
artistName string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
songID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
@@ -267,8 +454,12 @@ func (c *AppleMusicClient) FetchLyrics(
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
}
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
if err != nil {
trimmedRaw := strings.TrimSpace(rawLyrics)
if strings.HasPrefix(trimmedRaw, "{") || strings.HasPrefix(trimmedRaw, "[") {
return nil, err
}
lrcText = rawLyrics
}
+565
View File
@@ -0,0 +1,565 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
type SpotifyLyricsClient struct {
httpClient *http.Client
}
type DeezerLyricsClient struct {
httpClient *http.Client
}
type YouTubeLyricsClient struct {
httpClient *http.Client
}
type KugouLyricsClient struct {
httpClient *http.Client
}
type GeniusLyricsClient struct {
httpClient *http.Client
}
type spotifyLyricsSearchResult struct {
TrackID string `json:"trackId"`
Name string `json:"name"`
ArtistName string `json:"artistName"`
Duration string `json:"duration"`
}
type youtubeLyricsSearchResult struct {
VideoID string `json:"videoId"`
Title string `json:"title"`
Author string `json:"author"`
Duration string `json:"duration"`
}
type kugouLyricsSearchResult struct {
Hash string `json:"hash"`
Title string `json:"title"`
Artist string `json:"artist"`
Duration float64 `json:"duration"`
}
type geniusSearchResponse struct {
Response struct {
Sections []struct {
Hits []struct {
Type string `json:"type"`
Result struct {
Title string `json:"title"`
ArtistNames string `json:"artist_names"`
PrimaryArtistNames string `json:"primary_artist_names"`
URL string `json:"url"`
} `json:"result"`
} `json:"hits"`
} `json:"sections"`
} `json:"response"`
}
type paxsenixLyricsObject struct {
Type string `json:"type"`
Content []paxLyrics `json:"content"`
Lyrics []paxLyrics `json:"lyrics"`
LyricsText string `json:"lyrics_text"`
PlainLyrics string `json:"plain_lyrics"`
}
func NewSpotifyLyricsClient() *SpotifyLyricsClient {
return &SpotifyLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func NewDeezerLyricsClient() *DeezerLyricsClient {
return &DeezerLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func NewYouTubeLyricsClient() *YouTubeLyricsClient {
return &YouTubeLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func NewKugouLyricsClient() *KugouLyricsClient {
return &KugouLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func NewGeniusLyricsClient() *GeniusLyricsClient {
return &GeniusLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func fetchPaxsenixBody(httpClient *http.Client, endpoint string, params url.Values) (string, error) {
fullURL := endpoint
if len(params) > 0 {
fullURL += "?" + params.Encode()
}
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
trimmed := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK {
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, errMsg)
}
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("%s", errMsg)
}
if trimmed == "" {
return "", fmt.Errorf("empty response")
}
return trimmed, nil
}
func parsePaxsenixLyricsPayload(raw, provider string, multiPersonWordByWord bool) (*LyricsResponse, error) {
var lrcPayload string
if err := json.Unmarshal([]byte(raw), &lrcPayload); err == nil {
lrcPayload = strings.TrimSpace(lrcPayload)
if lrcPayload == "" {
return nil, fmt.Errorf("%s returned empty lyrics", provider)
}
return lyricsResponseFromText(lrcPayload, provider), nil
}
var rawObject map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &rawObject); err == nil {
for _, key := range []string{"lyrics", "lyric", "lyrics_text", "plain_lyrics"} {
var value string
if rawValue, ok := rawObject[key]; ok && json.Unmarshal(rawValue, &value) == nil {
value = strings.TrimSpace(value)
if value != "" {
return lyricsResponseFromText(value, provider), nil
}
}
}
}
var payload paxsenixLyricsObject
if err := json.Unmarshal([]byte(raw), &payload); err == nil {
switch {
case strings.TrimSpace(payload.LyricsText) != "":
return lyricsResponseFromText(payload.LyricsText, provider), nil
case len(payload.Lyrics) > 0:
return lyricsResponseFromText(formatPaxContent("Syllable", payload.Lyrics, multiPersonWordByWord, true), provider), nil
case len(payload.Content) > 0:
lyricsType := payload.Type
if lyricsType == "" {
lyricsType = "Syllable"
}
return lyricsResponseFromText(formatPaxContent(lyricsType, payload.Content, multiPersonWordByWord, true), provider), nil
case strings.TrimSpace(payload.PlainLyrics) != "":
return lyricsResponseFromText(payload.PlainLyrics, provider), nil
}
}
trimmed := strings.TrimSpace(raw)
if trimmed != "" && !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
return lyricsResponseFromText(trimmed, provider), nil
}
return nil, fmt.Errorf("failed to decode %s lyrics response", provider)
}
func lyricsResponseFromText(text, provider string) *LyricsResponse {
lines := parseSyncedLyrics(text)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: provider,
Source: provider,
}
}
plainLines := plainTextLyricsLines(text)
if len(plainLines) > 0 {
return &LyricsResponse{
Lines: plainLines,
SyncType: "UNSYNCED",
PlainLyrics: text,
Provider: provider,
Source: provider,
}
}
return &LyricsResponse{Provider: provider, Source: provider}
}
func normalizeSpotifyLyricsID(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(strings.ToLower(raw), "deezer:") {
return ""
}
if strings.HasPrefix(strings.ToLower(raw), "spotify:") {
parts := strings.Split(raw, ":")
raw = parts[len(parts)-1]
}
if strings.Contains(raw, "spotify.com/track/") {
raw = extractSpotifyIDFromURL(raw)
}
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
if regexpSpotifyTrackID.MatchString(raw) {
return raw
}
return ""
}
var regexpSpotifyTrackID = regexp.MustCompile(`^[A-Za-z0-9]{22}$`)
func (c *SpotifyLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/search", params)
if err != nil {
return "", fmt.Errorf("spotify search failed: %w", err)
}
var results []spotifyLyricsSearchResult
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode spotify search: %w", err)
}
best := selectBestSpotifyLyricsSearchResult(results, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.TrackID) == "" {
return "", fmt.Errorf("no songs found on spotify")
}
return strings.TrimSpace(best.TrackID), nil
}
func selectBestSpotifyLyricsSearchResult(results []spotifyLyricsSearchResult, trackName, artistName string, durationSec float64) *spotifyLyricsSearchResult {
if len(results) == 0 {
return nil
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Name, result.ArtistName, parseClockDuration(result.Duration), trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
return &results[bestIndex]
}
func (c *SpotifyLyricsClient) FetchLyricsByID(trackID string) (*LyricsResponse, error) {
params := url.Values{}
params.Set("id", trackID)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/lyrics", params)
if err != nil {
return nil, fmt.Errorf("spotify lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Spotify", false)
}
func (c *SpotifyLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
trackID := normalizeSpotifyLyricsID(spotifyID)
if trackID == "" {
var err error
trackID, err = c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
}
return c.FetchLyricsByID(trackID)
}
func normalizeDeezerLyricsID(raw string) string {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(strings.ToLower(raw), "deezer:") {
raw = strings.TrimSpace(raw[len("deezer:"):])
}
if strings.Contains(raw, "deezer.com/") {
raw = extractDeezerIDFromURL(raw)
}
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
if _, err := strconv.ParseInt(raw, 10, 64); err == nil {
return raw
}
return ""
}
func (c *DeezerLyricsClient) FetchLyricsByID(trackID string, multiPersonWordByWord bool) (*LyricsResponse, error) {
params := url.Values{}
params.Set("id", trackID)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/deezer/lyrics", params)
if err != nil {
return nil, fmt.Errorf("deezer lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Deezer", multiPersonWordByWord)
}
func (c *DeezerLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
deezerID := normalizeDeezerLyricsID(spotifyID)
if deezerID == "" {
spotifyTrackID := normalizeSpotifyLyricsID(spotifyID)
if spotifyTrackID == "" {
return nil, fmt.Errorf("deezer provider needs a deezer id or spotify id")
}
resolvedID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyTrackID)
if err != nil {
return nil, fmt.Errorf("failed to resolve deezer id: %w", err)
}
deezerID = normalizeDeezerLyricsID(resolvedID)
}
if deezerID == "" {
return nil, fmt.Errorf("deezer id unavailable")
}
return c.FetchLyricsByID(deezerID, true)
}
func (c *YouTubeLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/search", params)
if err != nil {
return "", fmt.Errorf("youtube search failed: %w", err)
}
var results []youtubeLyricsSearchResult
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode youtube search: %w", err)
}
best := selectBestYouTubeLyricsSearchResult(results, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.VideoID) == "" {
return "", fmt.Errorf("no songs found on youtube")
}
return strings.TrimSpace(best.VideoID), nil
}
func selectBestYouTubeLyricsSearchResult(results []youtubeLyricsSearchResult, trackName, artistName string, durationSec float64) *youtubeLyricsSearchResult {
if len(results) == 0 {
return nil
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Title, result.Author, parseClockDuration(result.Duration), trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
return &results[bestIndex]
}
func (c *YouTubeLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
videoID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
params := url.Values{}
params.Set("id", videoID)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/lyrics", params)
if err != nil {
return nil, fmt.Errorf("youtube lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "YouTube", false)
}
func (c *KugouLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/search", params)
if err != nil {
return "", fmt.Errorf("kugou search failed: %w", err)
}
var results []kugouLyricsSearchResult
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode kugou search: %w", err)
}
best := selectBestKugouLyricsSearchResult(results, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.Hash) == "" {
return "", fmt.Errorf("no songs found on kugou")
}
return strings.TrimSpace(best.Hash), nil
}
func selectBestKugouLyricsSearchResult(results []kugouLyricsSearchResult, trackName, artistName string, durationSec float64) *kugouLyricsSearchResult {
if len(results) == 0 {
return nil
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Title, result.Artist, result.Duration, trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
return &results[bestIndex]
}
func (c *KugouLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
hash, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
params := url.Values{}
params.Set("id", hash)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/lyrics", params)
if err != nil {
return nil, fmt.Errorf("kugou lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Kugou", false)
}
func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
params.Set("per_page", "10")
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
if err != nil {
return "", fmt.Errorf("genius search failed: %w", err)
}
var results geniusSearchResponse
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode genius search: %w", err)
}
bestURL := ""
bestScore := -1
for _, section := range results.Response.Sections {
for _, hit := range section.Hits {
if hit.Type != "song" || strings.TrimSpace(hit.Result.URL) == "" {
continue
}
artist := hit.Result.PrimaryArtistNames
if strings.TrimSpace(artist) == "" {
artist = hit.Result.ArtistNames
}
score := scoreLyricsSearchCandidate(hit.Result.Title, artist, 0, trackName, artistName, durationSec)
if score > bestScore {
bestScore = score
bestURL = strings.TrimSpace(hit.Result.URL)
}
}
}
if bestURL == "" {
return "", fmt.Errorf("no songs found on genius")
}
return bestURL, nil
}
func (c *GeniusLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
geniusURL, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
params := url.Values{}
params.Set("url", geniusURL)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/genius/lyrics", params)
if err != nil {
return nil, fmt.Errorf("genius lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Genius", false)
}
func scoreLyricsSearchCandidate(candidateTrack, candidateArtist string, candidateDuration float64, trackName, artistName string, durationSec float64) int {
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
candidateTrack = strings.ToLower(strings.TrimSpace(simplifyTrackName(candidateTrack)))
candidateArtist = strings.ToLower(strings.TrimSpace(normalizeArtistName(candidateArtist)))
score := 0
switch {
case candidateTrack == normalizedTrack:
score += 50
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
score += 25
}
switch {
case candidateArtist == normalizedArtist:
score += 60
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
score += 30
}
if durationSec > 0 && candidateDuration > 0 {
diff := math.Abs(candidateDuration - durationSec)
if diff <= durationToleranceSec {
score += 20
}
}
return score
}
func parseClockDuration(value string) float64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
parts := strings.Split(value, ":")
total := 0
for _, part := range parts {
n, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil {
return 0
}
total = total*60 + n
}
return float64(total)
}
+2 -2
View File
@@ -87,7 +87,7 @@ func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (st
if len(response.Lyrics) == 0 {
return "", fmt.Errorf("qq metadata lyrics response was empty")
}
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord, true), nil
}
func (c *QQMusicClient) FetchLyrics(
@@ -106,7 +106,7 @@ func (c *QQMusicClient) FetchLyrics(
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
if err != nil {
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, true); fallbackErr == nil {
lrcText = fallback
} else {
lrcText = rawLyrics
+96 -7
View File
@@ -131,14 +131,18 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
}
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
clearAppleMusicToken()
defer clearAppleMusicToken()
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/apple-music/search"):
if req.URL.Query().Get("q") == "bad" {
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`error`)), Request: req}, nil
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":"apple-2","songName":"Other","artistName":"Other","duration":1000},{"id":"apple-1","songName":"Song","artistName":"Artist","albumName":"Album","duration":180000}]`)), Request: req}, nil
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil
default:
@@ -156,13 +160,30 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
if err != nil || !strings.Contains(rawApple, "Syllable") {
t.Fatalf("apple raw = %q/%v", rawApple, err)
}
appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true)
appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true, true)
if err != nil || appleLyrics.SyncType != "LINE_SYNCED" || appleLyrics.Provider != "Apple Music" {
t.Fatalf("apple lyrics = %#v/%v", appleLyrics, err)
}
if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false); err != nil || !strings.Contains(plain, "Plain") {
if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false, false); err != nil || !strings.Contains(plain, "Plain") {
t.Fatalf("direct pax = %q/%v", plain, err)
}
lineOnly, err := formatPaxLyricsToLRC(paxJSON, true, false)
if err != nil {
t.Fatalf("line-only pax = %v", err)
}
if strings.Contains(lineOnly, "<00:") {
t.Fatalf("line-only pax should not include inline word timing: %q", lineOnly)
}
elrc, err := formatPaxLyricsToLRC(paxJSON, true, true)
if err != nil {
t.Fatalf("elrc pax = %v", err)
}
if !strings.Contains(elrc, "<00:") {
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
}
if preferred, err := formatPaxLyricsToLRC(`{"elrcMultiPerson":"[00:01.00]v1:<00:01.00>Hello","content":[{"timestamp":1000,"text":[{"text":"Fallback","part":false}]}]}`, true, true); err != nil || !strings.Contains(preferred, "Hello") {
t.Fatalf("preferred apple elrc = %q/%v", preferred, err)
}
if _, err := apple.SearchSong("", "", 0); err == nil {
t.Fatal("expected empty apple search error")
}
@@ -233,4 +254,72 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
if _, err := formatQQLyricsMetadataToLRC(`{"lyrics":[]}`, false); err == nil {
t.Fatal("expected empty QQ metadata error")
}
spotify := &SpotifyLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/spotify/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"trackId":"spotify-1","name":"Song","artistName":"Artist","duration":"03:00"}]`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/spotify/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]Spotify"`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
spotifyLyrics, err := spotify.FetchLyrics("", "Song", "Artist", 180)
if err != nil || spotifyLyrics.Provider != "Spotify" || spotifyLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("spotify lyrics = %#v/%v", spotifyLyrics, err)
}
deezer := &DeezerLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics":[{"timestamp":1000,"text":[{"text":"Deezer","part":false}]}]}`)), Request: req}, nil
})}}
deezerLyrics, err := deezer.FetchLyricsByID("123", false)
if err != nil || deezerLyrics.Provider != "Deezer" || deezerLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("deezer lyrics = %#v/%v", deezerLyrics, err)
}
youtube := &YouTubeLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/youtube/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"videoId":"yt-1","title":"Song","author":"Artist","duration":"3:00"}]`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/youtube/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]YouTube"`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
youtubeLyrics, err := youtube.FetchLyrics("Song", "Artist", 180)
if err != nil || youtubeLyrics.Provider != "YouTube" || youtubeLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("youtube lyrics = %#v/%v", youtubeLyrics, err)
}
kugou := &KugouLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/kugou/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"hash":"kg-1","title":"Song","artist":"Artist","duration":180}]`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/kugou/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics_text":"[00:01.00]Kugou"}`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
kugouLyrics, err := kugou.FetchLyrics("Song", "Artist", 180)
if err != nil || kugouLyrics.Provider != "Kugou" || kugouLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("kugou lyrics = %#v/%v", kugouLyrics, err)
}
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/api/search/multi"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"response":{"sections":[{"hits":[{"type":"song","result":{"title":"Song","primary_artist_names":"Artist","url":"https://genius.com/artist-song-lyrics"}}]}]}}`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/genius/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
geniusLyrics, err := genius.FetchLyrics("Song", "Artist", 180)
if err != nil || geniusLyrics.Provider != "Genius" || geniusLyrics.SyncType != "UNSYNCED" {
t.Fatalf("genius lyrics = %#v/%v", geniusLyrics, err)
}
}
+201 -31
View File
@@ -872,7 +872,7 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac") {
lyrics, err := extractLyricsFromM4A(filePath)
if err == nil && strings.TrimSpace(lyrics) != "" {
return lyrics, nil
@@ -1578,10 +1578,12 @@ func looksLikeEmbeddedLyrics(value string) bool {
}
type AudioQuality struct {
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"`
Duration int `json:"duration"`
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"`
Duration int `json:"duration"`
Bitrate int `json:"bitrate,omitempty"` // kbps, estimated for compressed MP4-family streams
Codec string `json:"codec,omitempty"`
}
func GetAudioQuality(filePath string) (AudioQuality, error) {
@@ -1632,6 +1634,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
SampleRate: sampleRate,
TotalSamples: totalSamples,
Duration: duration,
Codec: "flac",
}, nil
}
@@ -1695,9 +1698,11 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
// [26:28] reserved
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23])
bitDepth := 0
codec := normalizeM4AAudioCodec(atomType)
if atomType == "alac" {
bitDepth = int(buf[22])<<8 | int(buf[23])
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
if alacBitDepth > 0 {
bitDepth = alacBitDepth
@@ -1706,24 +1711,75 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
sampleRate = alacSampleRate
}
}
} else if atomType == "fLaC" {
bitDepth = int(buf[22])<<8 | int(buf[23])
if flacBitDepth, flacSampleRate, flacTotalSamples, ok := readMP4FLACSpecificConfig(f, sampleOffset, fileSize); ok {
if flacBitDepth > 0 {
bitDepth = flacBitDepth
}
if flacSampleRate > 0 {
sampleRate = flacSampleRate
}
if flacTotalSamples > 0 && sampleRate > 0 && duration <= 0 {
duration = int(flacTotalSamples / int64(sampleRate))
}
}
}
if bitDepth <= 0 {
bitDepth = 16
bitrate := estimateAudioBitrateKbps(fileSize, duration)
if bitrate > 0 && bitrate < 16 {
bitrate = 0
}
return AudioQuality{
BitDepth: bitDepth,
SampleRate: sampleRate,
Duration: duration,
Bitrate: bitrate,
Codec: codec,
}, nil
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate, Duration: duration}, nil
func normalizeM4AAudioCodec(atomType string) string {
switch atomType {
case "mp4a":
return "aac"
case "alac":
return "alac"
case "fLaC":
return "flac"
case "ec-3":
return "eac3"
case "ac-3":
return "ac3"
case "ac-4":
return "ac4"
default:
return strings.TrimSpace(atomType)
}
}
func estimateAudioBitrateKbps(fileSize int64, durationSeconds int) int {
if fileSize <= 0 || durationSeconds <= 0 {
return 0
}
return int(math.Round(float64(fileSize*8) / float64(durationSeconds) / 1000.0))
}
func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
childStart := moovHeader.offset + moovHeader.headerSize
childSize := moovHeader.size - moovHeader.headerSize
mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize)
if err != nil || !found {
return 0
if err == nil && found {
if duration := readMP4DurationAtomSeconds(f, mvhdHeader, fileSize); duration > 0 {
return duration
}
}
payloadOffset := mvhdHeader.offset + mvhdHeader.headerSize
return readM4ATrackDurationSeconds(f, moovHeader, fileSize)
}
func readMP4DurationAtomSeconds(f *os.File, header atomHeader, fileSize int64) int {
payloadOffset := header.offset + header.headerSize
versionBuf := make([]byte, 1)
if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil {
return 0
@@ -1754,6 +1810,53 @@ func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) i
return int(math.Round(float64(duration) / float64(timescale)))
}
func readM4ATrackDurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
childStart := moovHeader.offset + moovHeader.headerSize
childSize := moovHeader.size - moovHeader.headerSize
bestDuration := 0
_ = walkMP4AtomsInRange(f, childStart, childSize, fileSize, func(header atomHeader) bool {
if header.typ == "mdhd" {
if duration := readMP4DurationAtomSeconds(f, header, fileSize); duration > bestDuration {
bestDuration = duration
}
return false
}
return header.typ == "trak" || header.typ == "mdia"
})
return bestDuration
}
func walkMP4AtomsInRange(f *os.File, start, size, fileSize int64, visit func(atomHeader) bool) error {
if size <= 0 {
return nil
}
end := start + size
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return err
}
atomSize := header.size
if atomSize == 0 {
atomSize = end - pos
}
if atomSize < header.headerSize {
return fmt.Errorf("invalid atom size for %s", header.typ)
}
header.size = atomSize
if visit(header) {
childStart := header.offset + header.headerSize
childSize := header.size - header.headerSize
if err := walkMP4AtomsInRange(f, childStart, childSize, fileSize, visit); err != nil {
return err
}
}
pos += atomSize
}
return nil
}
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
if sampleOffset < 4 {
return 0, 0, false
@@ -1788,6 +1891,79 @@ func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int,
return parseALACSpecificConfig(payload)
}
func readMP4FLACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, int64, bool) {
if sampleOffset < 4 {
return 0, 0, 0, false
}
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
if err != nil {
return 0, 0, 0, false
}
childStart := sampleOffset + 32
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
if childStart >= childEnd {
return 0, 0, 0, false
}
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "dfLa", fileSize)
if err != nil || !found {
return 0, 0, 0, false
}
payloadSize := configHeader.size - configHeader.headerSize
if payloadSize <= 0 {
return 0, 0, 0, false
}
payload := make([]byte, payloadSize)
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
return 0, 0, 0, false
}
return parseMP4FLACSpecificConfig(payload)
}
func parseMP4FLACSpecificConfig(payload []byte) (int, int, int64, bool) {
if len(payload) >= 4 && string(payload[:4]) == "fLaC" {
payload = payload[4:]
} else if len(payload) >= 4 {
// FLACSpecificBox starts with a full-box version/flags field.
payload = payload[4:]
}
for len(payload) >= 4 {
blockType := payload[0] & 0x7F
blockLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3])
if blockLen < 0 || len(payload) < 4+blockLen {
return 0, 0, 0, false
}
block := payload[4 : 4+blockLen]
if blockType == 0 && len(block) >= 34 {
bitDepth, sampleRate, totalSamples := parseFLACStreamInfoQuality(block[:34])
return bitDepth, sampleRate, totalSamples, bitDepth > 0 || sampleRate > 0
}
payload = payload[4+blockLen:]
}
return 0, 0, 0, false
}
func parseFLACStreamInfoQuality(streamInfo []byte) (int, int, int64) {
if len(streamInfo) < 18 {
return 0, 0, 0
}
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
bitsPerSample := (((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4)) + 1
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 |
int64(streamInfo[16])<<8 |
int64(streamInfo[17])
return bitsPerSample, sampleRate, totalSamples
}
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
if len(payload) < 24 {
return 0, 0, false
@@ -1882,8 +2058,14 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
const chunkSize = 64 * 1024
patternMP4A := []byte("mp4a")
patternALAC := []byte("alac")
patterns := [][]byte{
[]byte("mp4a"),
[]byte("alac"),
[]byte("fLaC"),
[]byte("ec-3"),
[]byte("ac-3"),
[]byte("ac-4"),
}
var tail []byte
readPos := start
@@ -1904,26 +2086,14 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
}
data := append(tail, buf[:n]...)
mp4aIdx := bytes.Index(data, patternMP4A)
alacIdx := bytes.Index(data, patternALAC)
bestIdx := -1
bestType := ""
switch {
case mp4aIdx >= 0 && alacIdx >= 0:
if mp4aIdx <= alacIdx {
bestIdx = mp4aIdx
bestType = "mp4a"
} else {
bestIdx = alacIdx
bestType = "alac"
for _, pattern := range patterns {
idx := bytes.Index(data, pattern)
if idx >= 0 && (bestIdx < 0 || idx < bestIdx) {
bestIdx = idx
bestType = string(pattern)
}
case mp4aIdx >= 0:
bestIdx = mp4aIdx
bestType = "mp4a"
case alacIdx >= 0:
bestIdx = alacIdx
bestType = "alac"
}
if bestIdx >= 0 {
+50
View File
@@ -47,3 +47,53 @@ func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
t.Fatal("expected short ALAC payload to be rejected")
}
}
func TestM4ACodecFormatMapping(t *testing.T) {
cases := map[string]string{
"mp4a": "aac",
"alac": "alac",
"fLaC": "flac",
"ec-3": "eac3",
"ac-3": "ac3",
"ac-4": "ac4",
}
for atomType, want := range cases {
if got := normalizeM4AAudioCodec(atomType); got != want {
t.Fatalf("normalizeM4AAudioCodec(%q) = %q, want %q", atomType, got, want)
}
}
if got := libraryFormatForM4ACodec("flac"); got != "flac" {
t.Fatalf("libraryFormatForM4ACodec(flac) = %q", got)
}
if got := libraryFormatForM4ACodec("eac3"); got != "eac3" {
t.Fatalf("libraryFormatForM4ACodec(eac3) = %q", got)
}
if got := libraryFormatForM4ACodec("aac"); got != "m4a" {
t.Fatalf("libraryFormatForM4ACodec(aac) = %q", got)
}
}
func TestParseMP4FLACSpecificConfig(t *testing.T) {
streamInfo := make([]byte, 34)
sampleRate := 48000
bitsPerSample := 24
totalSamples := int64(48000 * 180)
streamInfo[10] = byte(sampleRate >> 12)
streamInfo[11] = byte(sampleRate >> 4)
streamInfo[12] = byte((sampleRate&0x0F)<<4 | ((bitsPerSample-1)>>4)&0x01)
streamInfo[13] = byte(((bitsPerSample-1)&0x0F)<<4 | int((totalSamples>>32)&0x0F))
streamInfo[14] = byte(totalSamples >> 24)
streamInfo[15] = byte(totalSamples >> 16)
streamInfo[16] = byte(totalSamples >> 8)
streamInfo[17] = byte(totalSamples)
payload := append([]byte{0, 0, 0, 0, 0, 0, 0, 34}, streamInfo...)
bitDepth, parsedRate, parsedSamples, ok := parseMP4FLACSpecificConfig(payload)
if !ok {
t.Fatal("expected MP4 FLAC config to parse")
}
if bitDepth != bitsPerSample || parsedRate != sampleRate || parsedSamples != totalSamples {
t.Fatalf("FLAC config = %d/%d/%d", bitDepth, parsedRate, parsedSamples)
}
}
+82
View File
@@ -0,0 +1,82 @@
package gobackend
import (
"crypto/tls"
"crypto/x509"
"sync"
)
const isrgRootX1PEM = `-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----`
const isrgRootX2PEM = `-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----`
var (
supplementalRootCAsOnce sync.Once
supplementalRootCAsPool *x509.CertPool
)
func supplementalRootCAs() *x509.CertPool {
supplementalRootCAsOnce.Do(func() {
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
for _, pem := range []string{isrgRootX1PEM, isrgRootX2PEM} {
pool.AppendCertsFromPEM([]byte(pem))
}
supplementalRootCAsPool = pool
})
return supplementalRootCAsPool
}
func newTLSCompatibilityConfig(insecureTLS bool) *tls.Config {
return &tls.Config{
RootCAs: supplementalRootCAs(),
InsecureSkipVerify: insecureTLS,
}
}
+1 -1
View File
@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
<string>14.0</string>
</dict>
</plist>
+3 -3
View File
@@ -346,7 +346,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -472,7 +472,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -523,7 +523,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+4 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
class AppInfo {
static const String version = '4.5.1';
static const String buildNumber = '128';
static const String version = '4.5.6';
static const String buildNumber = '133';
static const String fullVersion = '$version+$buildNumber';
static String get displayVersion => kDebugMode ? 'Internal' : version;
@@ -17,6 +17,8 @@ class AppInfo {
static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl =
'https://github.com/afkarxyz/SpotiFLAC';
static const String remoteConfigApiUrl =
'https://api.zarz.moe/v1/spotiflac-mobile/config';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
+658 -2
View File
@@ -837,7 +837,7 @@ abstract class AppLocalizations {
/// App description in header card
///
/// In en, this message translates to:
/// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'**
/// **'Search music metadata, manage extensions, and organize your library.'**
String get aboutAppDescription;
/// Section header for artist albums
@@ -1404,6 +1404,12 @@ abstract class AppLocalizations {
/// **'No tracks found'**
String get errorNoTracksFound;
/// Subtitle shown under the empty search result state on the home screen
///
/// In en, this message translates to:
/// **'Try another keyword'**
String get searchEmptyResultSubtitle;
/// Error title - URL not handled by any extension or service
///
/// In en, this message translates to:
@@ -2286,6 +2292,12 @@ abstract class AppLocalizations {
/// **'Copy lyrics'**
String get trackCopyLyrics;
/// Label showing the lyrics source/provider
///
/// In en, this message translates to:
/// **'Source: {source}'**
String trackLyricsSource(String source);
/// Message when lyrics not found
///
/// In en, this message translates to:
@@ -2838,6 +2850,18 @@ abstract class AppLocalizations {
/// **'Best compatibility, ~10MB per track'**
String get downloadLossyMp3Subtitle;
/// Tidal lossy format option - AAC in M4A container at 320kbps
///
/// In en, this message translates to:
/// **'AAC/M4A 320kbps'**
String get downloadLossyAac;
/// Subtitle for AAC/M4A 320kbps Tidal lossy option
///
/// In en, this message translates to:
/// **'Best mobile compatibility, M4A container'**
String get downloadLossyAacSubtitle;
/// Tidal lossy format option - Opus 256kbps
///
/// In en, this message translates to:
@@ -4299,7 +4323,7 @@ abstract class AppLocalizations {
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
/// **'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
@@ -5209,6 +5233,24 @@ abstract class AppLocalizations {
/// **'Standard lyrics without speaker labels'**
String get downloadAppleQqMultiPersonDisabled;
/// Setting for preserving Apple Music word-by-word eLRC timestamps
///
/// In en, this message translates to:
/// **'Apple Music eLRC Word Sync'**
String get downloadAppleElrcWordSync;
/// Subtitle when Apple Music eLRC word sync is enabled
///
/// In en, this message translates to:
/// **'Raw word-by-word timestamps preserved'**
String get downloadAppleElrcWordSyncEnabled;
/// Subtitle when Apple Music eLRC word sync is disabled
///
/// In en, this message translates to:
/// **'Safer line-by-line Apple Music lyrics'**
String get downloadAppleElrcWordSyncDisabled;
/// Setting for Musixmatch lyrics translation language
///
/// In en, this message translates to:
@@ -5563,6 +5605,24 @@ abstract class AppLocalizations {
/// **'Sample Rate'**
String get audioAnalysisSampleRate;
/// Audio codec metric label
///
/// In en, this message translates to:
/// **'Codec'**
String get audioAnalysisCodec;
/// Audio container metric label
///
/// In en, this message translates to:
/// **'Container'**
String get audioAnalysisContainer;
/// Decoded sample format metric label
///
/// In en, this message translates to:
/// **'Decoded Format'**
String get audioAnalysisDecodedFormat;
/// Bit depth metric label
///
/// In en, this message translates to:
@@ -5611,12 +5671,60 @@ abstract class AppLocalizations {
/// **'RMS'**
String get audioAnalysisRms;
/// Integrated loudness metric label
///
/// In en, this message translates to:
/// **'LUFS'**
String get audioAnalysisLufs;
/// True peak metric label
///
/// In en, this message translates to:
/// **'True Peak'**
String get audioAnalysisTruePeak;
/// Clipping metric label
///
/// In en, this message translates to:
/// **'Clipping'**
String get audioAnalysisClipping;
/// Displayed when no clipped samples were detected
///
/// In en, this message translates to:
/// **'No clipping'**
String get audioAnalysisNoClipping;
/// Estimated spectral cutoff metric label
///
/// In en, this message translates to:
/// **'Spectral Cutoff'**
String get audioAnalysisSpectralCutoff;
/// Per-channel audio analysis section label
///
/// In en, this message translates to:
/// **'Per-channel Stats'**
String get audioAnalysisChannelStats;
/// Total samples metric label
///
/// In en, this message translates to:
/// **'Samples'**
String get audioAnalysisSamples;
/// Tooltip/label for the button that re-runs the audio analysis, discarding cached results
///
/// In en, this message translates to:
/// **'Re-analyze'**
String get audioAnalysisRescan;
/// Loading text while audio is being re-analyzed after an explicit refresh
///
/// In en, this message translates to:
/// **'Re-analyzing audio...'**
String get audioAnalysisRescanning;
/// Extensions page - subtitle for built-in search provider option
///
/// In en, this message translates to:
@@ -6107,6 +6215,18 @@ abstract class AppLocalizations {
/// **'Download completed'**
String get queueDownloadCompleted;
/// Title shown on a failed queue item when the download service rate limits requests
///
/// In en, this message translates to:
/// **'Service rate limited'**
String get queueRateLimitTitle;
/// Explanation shown on a failed queue item when the download service rate limits requests
///
/// In en, this message translates to:
/// **'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.'**
String get queueRateLimitMessage;
/// Accessibility label for picking an accent color
///
/// In en, this message translates to:
@@ -6416,6 +6536,542 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Choose which extensions can be used as fallback'**
String get downloadFallbackExtensionsSubtitle;
/// Hint text for the edit metadata date field
///
/// In en, this message translates to:
/// **'YYYY-MM-DD or YYYY'**
String get editMetadataFieldDateHint;
/// Label for total tracks field in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Track Total'**
String get editMetadataFieldTrackTotal;
/// Label for total discs field in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Disc Total'**
String get editMetadataFieldDiscTotal;
/// Label for composer field in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Composer'**
String get editMetadataFieldComposer;
/// Label for comment field in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Comment'**
String get editMetadataFieldComment;
/// Expandable section label for advanced metadata fields
///
/// In en, this message translates to:
/// **'Advanced'**
String get editMetadataAdvanced;
/// Filter option - items missing track number
///
/// In en, this message translates to:
/// **'Missing track number'**
String get libraryFilterMetadataMissingTrackNumber;
/// Filter option - items missing disc number
///
/// In en, this message translates to:
/// **'Missing disc number'**
String get libraryFilterMetadataMissingDiscNumber;
/// Filter option - items missing artist
///
/// In en, this message translates to:
/// **'Missing artist'**
String get libraryFilterMetadataMissingArtist;
/// Filter option - items with an invalid ISRC format
///
/// In en, this message translates to:
/// **'Incorrect ISRC format'**
String get libraryFilterMetadataIncorrectIsrcFormat;
/// Filter option - items missing record label
///
/// In en, this message translates to:
/// **'Missing label'**
String get libraryFilterMetadataMissingLabel;
/// Confirmation message for deleting selected playlists
///
/// In en, this message translates to:
/// **'Delete {count} {count, plural, =1{playlist} other{playlists}}?'**
String collectionDeletePlaylistsMessage(int count);
/// Snackbar after deleting selected playlists
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{playlist} other{playlists}} deleted'**
String collectionPlaylistsDeleted(int count);
/// Snackbar after adding multiple tracks to a playlist
///
/// In en, this message translates to:
/// **'Added {count} {count, plural, =1{track} other{tracks}} to {playlistName}'**
String collectionAddedTracksToPlaylist(int count, String playlistName);
/// Snackbar after adding multiple tracks to a playlist when some were already present
///
/// In en, this message translates to:
/// **'Added {count} {count, plural, =1{track} other{tracks}} to {playlistName} ({alreadyCount} already in playlist)'**
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
);
/// Generic item count label
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{item} other{items}}'**
String itemCount(int count);
/// Snackbar summary after batch metadata re-enrichment finishes with failures
///
/// In en, this message translates to:
/// **'Metadata re-enriched successfully ({successCount}/{total}) - Failed: {failedCount}'**
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
);
/// Button label for deleting selected tracks
///
/// In en, this message translates to:
/// **'Delete {count} {count, plural, =1{track} other{tracks}}'**
String selectionDeleteTracksCount(int count);
/// Queue status while downloading with speed
///
/// In en, this message translates to:
/// **'Downloading - {speed} MB/s'**
String queueDownloadSpeedStatus(String speed);
/// Queue status before download progress is available
///
/// In en, this message translates to:
/// **'Starting...'**
String get queueDownloadStarting;
/// Accessibility label for selecting a track
///
/// In en, this message translates to:
/// **'Select track'**
String get a11ySelectTrack;
/// Accessibility label for deselecting a track
///
/// In en, this message translates to:
/// **'Deselect track'**
String get a11yDeselectTrack;
/// Accessibility label for playing a local library track
///
/// In en, this message translates to:
/// **'Play {trackName} by {artistName}'**
String a11yPlayTrackByArtist(String trackName, String artistName);
/// Store extension result count
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{extension} other{extensions}}'**
String storeExtensionsCount(int count);
/// Store compatibility badge for minimum app version
///
/// In en, this message translates to:
/// **'Requires v{version}+'**
String storeRequiresVersion(String version);
/// Generic action button label
///
/// In en, this message translates to:
/// **'Go'**
String get actionGo;
/// Header for log issue analysis summary
///
/// In en, this message translates to:
/// **'Issue Summary'**
String get logIssueSummary;
/// Total error count in log issue analysis
///
/// In en, this message translates to:
/// **'Total errors: {count}'**
String logTotalErrors(int count);
/// Affected domains in log issue analysis
///
/// In en, this message translates to:
/// **'Affected: {domains}'**
String logAffectedDomains(String domains);
/// Library scan status when a scan was cancelled
///
/// In en, this message translates to:
/// **'Scan cancelled'**
String get libraryScanCancelled;
/// Library scan status subtitle after cancellation
///
/// In en, this message translates to:
/// **'You can retry the scan when ready.'**
String get libraryScanCancelledSubtitle;
/// Library count note for downloaded history items excluded from the local list
///
/// In en, this message translates to:
/// **'{count} from Downloads history (excluded from list)'**
String libraryDownloadsHistoryExcluded(int count);
/// Setting title for Android native download worker
///
/// In en, this message translates to:
/// **'Native download worker'**
String get downloadNativeWorker;
/// Setting subtitle for Android native download worker
///
/// In en, this message translates to:
/// **'Beta Android service worker for extension downloads'**
String get downloadNativeWorkerSubtitle;
/// Badge label for beta features
///
/// In en, this message translates to:
/// **'BETA'**
String get badgeBeta;
/// Extension detail section header for service status
///
/// In en, this message translates to:
/// **'Service Status'**
String get extensionServiceStatus;
/// Extension capability label for service health checks
///
/// In en, this message translates to:
/// **'Service health'**
String get extensionServiceHealth;
/// Extension service health check count
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{check} other{checks}} configured'**
String extensionHealthChecksConfigured(int count);
/// Hint for an OAuth login link field before connecting Spotify
///
/// In en, this message translates to:
/// **'Tap Connect to Spotify to fill this field.'**
String get extensionOauthConnectHint;
/// Timestamp for the latest extension service health check
///
/// In en, this message translates to:
/// **'Last checked {time}'**
String extensionLastChecked(String time);
/// Tooltip for refreshing extension service health status
///
/// In en, this message translates to:
/// **'Refresh status'**
String get extensionRefreshStatus;
/// Extension detail section title for custom URL handling
///
/// In en, this message translates to:
/// **'Custom URL Handling'**
String get extensionCustomUrlHandling;
/// Extension detail subtitle for custom URL handling
///
/// In en, this message translates to:
/// **'This extension can handle links from these sites'**
String get extensionCustomUrlHandlingSubtitle;
/// Extension detail hint explaining share-to-app URL handling
///
/// In en, this message translates to:
/// **'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.'**
String get extensionCustomUrlHandlingShareHint;
/// Count of settings exposed by an extension quality option
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{setting} other{settings}}'**
String extensionSettingsCount(int count);
/// Extension service health status - online
///
/// In en, this message translates to:
/// **'Online'**
String get extensionHealthOnline;
/// Extension service health status - degraded
///
/// In en, this message translates to:
/// **'Degraded'**
String get extensionHealthDegraded;
/// Extension service health status - offline
///
/// In en, this message translates to:
/// **'Offline'**
String get extensionHealthOffline;
/// Extension service health status - not configured
///
/// In en, this message translates to:
/// **'Not configured'**
String get extensionHealthNotConfigured;
/// Extension service health status - unknown
///
/// In en, this message translates to:
/// **'Unknown'**
String get extensionHealthUnknown;
/// Label for a required extension service health check
///
/// In en, this message translates to:
/// **'required'**
String get extensionHealthRequired;
/// Value shown when an extension setting has no value
///
/// In en, this message translates to:
/// **'Not set'**
String get extensionSettingNotSet;
/// Fallback error when an extension action fails without details
///
/// In en, this message translates to:
/// **'Action failed'**
String get extensionActionFailed;
/// Hint for editing an extension setting value
///
/// In en, this message translates to:
/// **'Enter value'**
String get extensionEnterValue;
/// Tooltip for online extension service
///
/// In en, this message translates to:
/// **'Service online'**
String get extensionHealthServiceOnline;
/// Tooltip for degraded extension service
///
/// In en, this message translates to:
/// **'Service degraded'**
String get extensionHealthServiceDegraded;
/// Tooltip for offline extension service
///
/// In en, this message translates to:
/// **'Service offline'**
String get extensionHealthServiceOffline;
/// Tooltip for unknown extension service health
///
/// In en, this message translates to:
/// **'Service status unknown'**
String get extensionHealthServiceUnknown;
/// Audio channel layout label - stereo
///
/// In en, this message translates to:
/// **'Stereo'**
String get audioAnalysisStereo;
/// Audio channel layout label - mono
///
/// In en, this message translates to:
/// **'Mono'**
String get audioAnalysisMono;
/// Button label to open a track in a named music service
///
/// In en, this message translates to:
/// **'Open in {serviceName}'**
String trackOpenInService(String serviceName);
/// Lyrics source label for embedded lyrics
///
/// In en, this message translates to:
/// **'Embedded'**
String get trackLyricsEmbeddedSource;
/// Fallback album name when metadata is missing
///
/// In en, this message translates to:
/// **'Unknown Album'**
String get unknownAlbum;
/// Fallback artist name when metadata is missing
///
/// In en, this message translates to:
/// **'Unknown Artist'**
String get unknownArtist;
/// Audio permission type label
///
/// In en, this message translates to:
/// **'Audio'**
String get permissionAudio;
/// Storage permission type label
///
/// In en, this message translates to:
/// **'Storage'**
String get permissionStorage;
/// Notification permission type label
///
/// In en, this message translates to:
/// **'Notification'**
String get permissionNotification;
/// Error when the selected folder is invalid
///
/// In en, this message translates to:
/// **'Invalid folder selected'**
String get errorInvalidFolderSelected;
/// Error when persistent folder access cannot be saved
///
/// In en, this message translates to:
/// **'Could not keep access to the selected folder'**
String get errorCouldNotKeepFolderAccess;
/// Store detail value when any app version is accepted
///
/// In en, this message translates to:
/// **'Any'**
String get storeAnyVersion;
/// Store extension category - metadata
///
/// In en, this message translates to:
/// **'Metadata'**
String get storeCategoryMetadata;
/// Store extension category - download
///
/// In en, this message translates to:
/// **'Download'**
String get storeCategoryDownload;
/// Store extension category - utility
///
/// In en, this message translates to:
/// **'Utility'**
String get storeCategoryUtility;
/// Store extension category - lyrics
///
/// In en, this message translates to:
/// **'Lyrics'**
String get storeCategoryLyrics;
/// Store extension category - integration
///
/// In en, this message translates to:
/// **'Integration'**
String get storeCategoryIntegration;
/// Section header for all artist releases
///
/// In en, this message translates to:
/// **'Releases'**
String get artistReleases;
/// Button to clear selected fields for auto-fill
///
/// In en, this message translates to:
/// **'None'**
String get editMetadataSelectNone;
/// Button to retry every failed download in the queue
///
/// In en, this message translates to:
/// **'Retry {count} failed'**
String queueRetryAllFailed(int count);
/// Settings switch title for storing completed downloads in history
///
/// In en, this message translates to:
/// **'Save download history'**
String get settingsSaveDownloadHistory;
/// Settings switch subtitle for storing completed downloads in history
///
/// In en, this message translates to:
/// **'Keep completed downloads in history and library views'**
String get settingsSaveDownloadHistorySubtitle;
/// Confirmation dialog title shown before disabling download history
///
/// In en, this message translates to:
/// **'Turn off download history?'**
String get dialogDisableHistoryTitle;
/// Confirmation dialog message shown before disabling download history
///
/// In en, this message translates to:
/// **'Existing history will be cleared. Downloaded files will not be deleted.'**
String get dialogDisableHistoryMessage;
/// Confirmation action to disable download history and clear existing entries
///
/// In en, this message translates to:
/// **'Turn off and clear'**
String get dialogDisableAndClear;
/// Title and tooltip for finding the current collection in other services
///
/// In en, this message translates to:
/// **'Open in Other Services'**
String get openInOtherServices;
/// Empty state when no extensions can be searched for cross-service links
///
/// In en, this message translates to:
/// **'No other compatible services'**
String get shareSheetNoExtensions;
/// Cross-service share sheet row subtitle when a service has no match
///
/// In en, this message translates to:
/// **'Not found'**
String get shareSheetNotFound;
/// Tooltip for copying a cross-service link
///
/// In en, this message translates to:
/// **'Copy Link'**
String get shareSheetCopyLink;
/// Snackbar after copying a cross-service link
///
/// In en, this message translates to:
/// **'{service} link copied'**
String shareSheetLinkCopied(Object service);
}
class _AppLocalizationsDelegate
+442 -1
View File
@@ -405,7 +405,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Alben';
@@ -754,6 +754,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get errorNoTracksFound => 'Keine Titel gefunden';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link wurde nicht erkannt';
@@ -1237,6 +1240,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trackCopyLyrics => 'Lyrics kopieren';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable =>
'Lyrics sind für diesen Titel nicht verfügbar';
@@ -1547,6 +1555,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadLossyMp3Subtitle =>
'Beste Kompatibilität, ~10MB pro Titel';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3049,6 +3064,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3295,6 +3321,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit-Tiefe';
@@ -3319,9 +3354,33 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Proben';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3640,6 +3699,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3833,4 +3899,379 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+443 -2
View File
@@ -397,7 +397,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -742,6 +742,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -1220,6 +1223,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1531,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -2408,7 +2423,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -3015,6 +3030,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3260,6 +3286,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3284,9 +3319,33 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3611,6 +3670,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3804,4 +3870,379 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+449 -2
View File
@@ -397,7 +397,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -742,6 +742,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -1220,6 +1223,11 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1531,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3030,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3260,6 +3286,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3284,9 +3319,33 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3605,6 +3664,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3798,6 +3864,381 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -4196,7 +4637,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get aboutAppDescription =>
'Descargar pistas de Spotify en alta calidad (sin pérdida) de Tidal y Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Álbumes';
@@ -7049,6 +7490,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
+442 -1
View File
@@ -399,7 +399,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -745,6 +745,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -1223,6 +1226,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1526,6 +1534,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3018,6 +3033,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3264,6 +3290,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3288,9 +3323,33 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3609,6 +3668,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3802,4 +3868,379 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+442 -1
View File
@@ -397,7 +397,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -742,6 +742,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -1220,6 +1223,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1531,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3030,17 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3261,6 +3287,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3285,9 +3320,33 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3606,6 +3665,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3799,4 +3865,379 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+442 -1
View File
@@ -400,7 +400,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutAppDescription =>
'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Album';
@@ -745,6 +745,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
@override
String get searchEmptyResultSubtitle => 'Coba kata kunci lain';
@override
String get errorUrlNotRecognized => 'Tautan tidak dikenali';
@@ -1226,6 +1229,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackCopyLyrics => 'Salin lirik';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lirik tidak tersedia untuk lagu ini';
@@ -1531,6 +1539,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3024,6 +3039,17 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3270,6 +3296,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3294,9 +3329,33 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3597,6 +3656,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Layanan sedang membatasi permintaan';
@override
String get queueRateLimitMessage =>
'Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3790,4 +3856,379 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'Tidak ada';
@override
String queueRetryAllFailed(int count) {
return 'Coba ulang $count gagal';
}
@override
String get settingsSaveDownloadHistory => 'Simpan riwayat unduhan';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Simpan unduhan selesai di riwayat dan tampilan pustaka';
@override
String get dialogDisableHistoryTitle => 'Matikan riwayat unduhan?';
@override
String get dialogDisableHistoryMessage =>
'Riwayat yang ada akan dihapus. File unduhan tidak akan dihapus.';
@override
String get dialogDisableAndClear => 'Matikan dan hapus';
@override
String get openInOtherServices => 'Buka di Layanan Lain';
@override
String get shareSheetNoExtensions => 'Tidak ada layanan lain yang kompatibel';
@override
String get shareSheetNotFound => 'Tidak ditemukan';
@override
String get shareSheetCopyLink => 'Salin Tautan';
@override
String shareSheetLinkCopied(Object service) {
return 'Tautan $service disalin';
}
}
+442 -1
View File
@@ -393,7 +393,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'アルバム';
@@ -737,6 +737,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get errorNoTracksFound => 'トラックがありません';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -1214,6 +1217,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackCopyLyrics => '歌詞をコピー';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません';
@@ -1513,6 +1521,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3002,6 +3017,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3248,6 +3274,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3272,9 +3307,33 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3593,6 +3652,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3786,4 +3852,379 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+442 -1
View File
@@ -385,7 +385,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => '앨범';
@@ -724,6 +724,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get errorNoTracksFound => '트랙을 찾을 수 없습니다';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -1200,6 +1203,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1503,6 +1511,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -2995,6 +3010,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3241,6 +3267,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3265,9 +3300,33 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3586,6 +3645,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3779,4 +3845,379 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+442 -1
View File
@@ -397,7 +397,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -742,6 +742,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -1220,6 +1223,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1531,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3030,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3261,6 +3287,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3285,9 +3320,33 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3606,6 +3665,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3799,4 +3865,379 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+449 -2
View File
@@ -397,7 +397,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -742,6 +742,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -1220,6 +1223,11 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1531,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3030,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3260,6 +3286,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3284,9 +3319,33 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3605,6 +3664,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3798,6 +3864,381 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -4195,7 +4636,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Álbuns';
@@ -7042,6 +7483,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
+442 -1
View File
@@ -403,7 +403,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutAppDescription =>
'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Альбомы';
@@ -755,6 +755,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get errorNoTracksFound => 'Треки не найдены';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Ссылка не распознана';
@@ -1238,6 +1241,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get trackCopyLyrics => 'Копировать текст';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable =>
'Текст песни недоступен для этого трека';
@@ -1547,6 +1555,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3074,6 +3089,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3320,6 +3346,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3344,9 +3379,33 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3665,6 +3724,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3858,4 +3924,379 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+442 -1
View File
@@ -405,7 +405,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Spotify parçalarını Tidal ve Qobuz aracılığıyla kayıpsız kalitede indirin.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albümler';
@@ -750,6 +750,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get errorNoTracksFound => 'Parça bulunamadı';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Bağlantı tanınamadı';
@@ -1234,6 +1237,11 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackCopyLyrics => 'Şarkı sözlerini kopyala';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Bu parça için şarkı sözü mevcut değil';
@@ -1540,6 +1548,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadLossyMp3Subtitle =>
'En iyi uyumluluk, parça başına ~10 Mb';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3041,6 +3056,17 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3287,6 +3313,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3311,9 +3346,33 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3632,6 +3691,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3825,4 +3891,379 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+442 -1
View File
@@ -407,7 +407,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get aboutAppDescription =>
'Кінцеві точки потокового передавання Tidal Hi-Res FLAC. Ключовий елемент пазлу музики без втрат.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Альбоми';
@@ -755,6 +755,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get errorNoTracksFound => 'Треків не знайдено';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Посилання не розпізнано';
@@ -1240,6 +1243,11 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get trackCopyLyrics => 'Скопіювати тексти пісень';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable =>
'Текст пісні для цього треку недоступний';
@@ -1548,6 +1556,13 @@ class AppLocalizationsUk extends AppLocalizations {
String get downloadLossyMp3Subtitle =>
'Найкраща сумісність, ~10 МБ на доріжку';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256 кбіт/с';
@@ -3067,6 +3082,17 @@ class AppLocalizationsUk extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Спрощене послівне форматування';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Мова Musixmatch';
@@ -3316,6 +3342,15 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Частота дискретизації';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Глибина бітів';
@@ -3340,9 +3375,33 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Семпли';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Пошук за допомогою$providerName';
@@ -3665,6 +3724,13 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3858,4 +3924,379 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+456 -3
View File
@@ -397,7 +397,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -742,6 +742,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -1220,6 +1223,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1531,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3030,17 @@ class AppLocalizationsZh extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3260,6 +3286,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3284,9 +3319,33 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3605,6 +3664,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -3798,6 +3864,381 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
/// The translations for Chinese, as used in China (`zh_CN`).
@@ -4176,7 +4617,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -7008,6 +7449,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -7651,7 +8098,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -10485,6 +10932,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+555 -2
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -961,6 +961,10 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"searchEmptyResultSubtitle": "Try another keyword",
"@searchEmptyResultSubtitle": {
"description": "Subtitle shown under the empty search result state on the home screen"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
@@ -1587,6 +1591,15 @@
"@trackCopyLyrics": {
"description": "Action - copy lyrics to clipboard"
},
"trackLyricsSource": "Source: {source}",
"@trackLyricsSource": {
"description": "Label showing the lyrics source/provider",
"placeholders": {
"source": {
"type": "String"
}
}
},
"trackLyricsNotAvailable": "Lyrics not available for this track",
"@trackLyricsNotAvailable": {
"description": "Message when lyrics not found"
@@ -2001,6 +2014,14 @@
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
},
"downloadLossyAac": "AAC/M4A 320kbps",
"@downloadLossyAac": {
"description": "Tidal lossy format option - AAC in M4A container at 320kbps"
},
"downloadLossyAacSubtitle": "Best mobile compatibility, M4A container",
"@downloadLossyAacSubtitle": {
"description": "Subtitle for AAC/M4A 320kbps Tidal lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
@@ -3170,7 +3191,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
"trackConvertFormatSubtitle": "Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -3969,6 +3990,18 @@
"@downloadAppleQqMultiPersonDisabled": {
"description": "Subtitle when multi-person lyrics is off"
},
"downloadAppleElrcWordSync": "Apple Music eLRC Word Sync",
"@downloadAppleElrcWordSync": {
"description": "Setting for preserving Apple Music word-by-word eLRC timestamps"
},
"downloadAppleElrcWordSyncEnabled": "Raw word-by-word timestamps preserved",
"@downloadAppleElrcWordSyncEnabled": {
"description": "Subtitle when Apple Music eLRC word sync is enabled"
},
"downloadAppleElrcWordSyncDisabled": "Safer line-by-line Apple Music lyrics",
"@downloadAppleElrcWordSyncDisabled": {
"description": "Subtitle when Apple Music eLRC word sync is disabled"
},
"downloadMusixmatchLanguage": "Musixmatch Language",
"@downloadMusixmatchLanguage": {
"description": "Setting for Musixmatch lyrics translation language"
@@ -4243,6 +4276,18 @@
"@audioAnalysisSampleRate": {
"description": "Sample rate metric label"
},
"audioAnalysisCodec": "Codec",
"@audioAnalysisCodec": {
"description": "Audio codec metric label"
},
"audioAnalysisContainer": "Container",
"@audioAnalysisContainer": {
"description": "Audio container metric label"
},
"audioAnalysisDecodedFormat": "Decoded Format",
"@audioAnalysisDecodedFormat": {
"description": "Decoded sample format metric label"
},
"audioAnalysisBitDepth": "Bit Depth",
"@audioAnalysisBitDepth": {
"description": "Bit depth metric label"
@@ -4275,10 +4320,42 @@
"@audioAnalysisRms": {
"description": "RMS level metric label"
},
"audioAnalysisLufs": "LUFS",
"@audioAnalysisLufs": {
"description": "Integrated loudness metric label"
},
"audioAnalysisTruePeak": "True Peak",
"@audioAnalysisTruePeak": {
"description": "True peak metric label"
},
"audioAnalysisClipping": "Clipping",
"@audioAnalysisClipping": {
"description": "Clipping metric label"
},
"audioAnalysisNoClipping": "No clipping",
"@audioAnalysisNoClipping": {
"description": "Displayed when no clipped samples were detected"
},
"audioAnalysisSpectralCutoff": "Spectral Cutoff",
"@audioAnalysisSpectralCutoff": {
"description": "Estimated spectral cutoff metric label"
},
"audioAnalysisChannelStats": "Per-channel Stats",
"@audioAnalysisChannelStats": {
"description": "Per-channel audio analysis section label"
},
"audioAnalysisSamples": "Samples",
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
@@ -4735,6 +4812,14 @@
"@queueDownloadCompleted": {
"description": "Accessibility label for completed download state in queue"
},
"queueRateLimitTitle": "Service rate limited",
"@queueRateLimitTitle": {
"description": "Title shown on a failed queue item when the download service rate limits requests"
},
"queueRateLimitMessage": "This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.",
"@queueRateLimitMessage": {
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
},
"appearanceSelectAccentColor": "Select accent color {hex}",
"@appearanceSelectAccentColor": {
"description": "Accessibility label for picking an accent color",
@@ -5001,5 +5086,473 @@
"downloadFallbackExtensionsSubtitle": "Choose which extensions can be used as fallback",
"@downloadFallbackExtensionsSubtitle": {
"description": "Subtitle for fallback extensions item"
},
"editMetadataFieldDateHint": "YYYY-MM-DD or YYYY",
"@editMetadataFieldDateHint": {
"description": "Hint text for the edit metadata date field"
},
"editMetadataFieldTrackTotal": "Track Total",
"@editMetadataFieldTrackTotal": {
"description": "Label for total tracks field in the edit metadata sheet"
},
"editMetadataFieldDiscTotal": "Disc Total",
"@editMetadataFieldDiscTotal": {
"description": "Label for total discs field in the edit metadata sheet"
},
"editMetadataFieldComposer": "Composer",
"@editMetadataFieldComposer": {
"description": "Label for composer field in the edit metadata sheet"
},
"editMetadataFieldComment": "Comment",
"@editMetadataFieldComment": {
"description": "Label for comment field in the edit metadata sheet"
},
"editMetadataAdvanced": "Advanced",
"@editMetadataAdvanced": {
"description": "Expandable section label for advanced metadata fields"
},
"libraryFilterMetadataMissingTrackNumber": "Missing track number",
"@libraryFilterMetadataMissingTrackNumber": {
"description": "Filter option - items missing track number"
},
"libraryFilterMetadataMissingDiscNumber": "Missing disc number",
"@libraryFilterMetadataMissingDiscNumber": {
"description": "Filter option - items missing disc number"
},
"libraryFilterMetadataMissingArtist": "Missing artist",
"@libraryFilterMetadataMissingArtist": {
"description": "Filter option - items missing artist"
},
"libraryFilterMetadataIncorrectIsrcFormat": "Incorrect ISRC format",
"@libraryFilterMetadataIncorrectIsrcFormat": {
"description": "Filter option - items with an invalid ISRC format"
},
"libraryFilterMetadataMissingLabel": "Missing label",
"@libraryFilterMetadataMissingLabel": {
"description": "Filter option - items missing record label"
},
"collectionDeletePlaylistsMessage": "Delete {count} {count, plural, =1{playlist} other{playlists}}?",
"@collectionDeletePlaylistsMessage": {
"description": "Confirmation message for deleting selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionPlaylistsDeleted": "{count} {count, plural, =1{playlist} other{playlists}} deleted",
"@collectionPlaylistsDeleted": {
"description": "Snackbar after deleting selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedTracksToPlaylist": "Added {count} {count, plural, =1{track} other{tracks}} to {playlistName}",
"@collectionAddedTracksToPlaylist": {
"description": "Snackbar after adding multiple tracks to a playlist",
"placeholders": {
"count": {
"type": "int"
},
"playlistName": {
"type": "String"
}
}
},
"collectionAddedTracksToPlaylistWithExisting": "Added {count} {count, plural, =1{track} other{tracks}} to {playlistName} ({alreadyCount} already in playlist)",
"@collectionAddedTracksToPlaylistWithExisting": {
"description": "Snackbar after adding multiple tracks to a playlist when some were already present",
"placeholders": {
"count": {
"type": "int"
},
"playlistName": {
"type": "String"
},
"alreadyCount": {
"type": "int"
}
}
},
"itemCount": "{count} {count, plural, =1{item} other{items}}",
"@itemCount": {
"description": "Generic item count label",
"placeholders": {
"count": {
"type": "int"
}
}
},
"trackReEnrichSuccessWithFailures": "Metadata re-enriched successfully ({successCount}/{total}) - Failed: {failedCount}",
"@trackReEnrichSuccessWithFailures": {
"description": "Snackbar summary after batch metadata re-enrichment finishes with failures",
"placeholders": {
"successCount": {
"type": "int"
},
"total": {
"type": "int"
},
"failedCount": {
"type": "int"
}
}
},
"selectionDeleteTracksCount": "Delete {count} {count, plural, =1{track} other{tracks}}",
"@selectionDeleteTracksCount": {
"description": "Button label for deleting selected tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueDownloadSpeedStatus": "Downloading - {speed} MB/s",
"@queueDownloadSpeedStatus": {
"description": "Queue status while downloading with speed",
"placeholders": {
"speed": {
"type": "String"
}
}
},
"queueDownloadStarting": "Starting...",
"@queueDownloadStarting": {
"description": "Queue status before download progress is available"
},
"a11ySelectTrack": "Select track",
"@a11ySelectTrack": {
"description": "Accessibility label for selecting a track"
},
"a11yDeselectTrack": "Deselect track",
"@a11yDeselectTrack": {
"description": "Accessibility label for deselecting a track"
},
"a11yPlayTrackByArtist": "Play {trackName} by {artistName}",
"@a11yPlayTrackByArtist": {
"description": "Accessibility label for playing a local library track",
"placeholders": {
"trackName": {
"type": "String"
},
"artistName": {
"type": "String"
}
}
},
"storeExtensionsCount": "{count} {count, plural, =1{extension} other{extensions}}",
"@storeExtensionsCount": {
"description": "Store extension result count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"storeRequiresVersion": "Requires v{version}+",
"@storeRequiresVersion": {
"description": "Store compatibility badge for minimum app version",
"placeholders": {
"version": {
"type": "String"
}
}
},
"actionGo": "Go",
"@actionGo": {
"description": "Generic action button label"
},
"logIssueSummary": "Issue Summary",
"@logIssueSummary": {
"description": "Header for log issue analysis summary"
},
"logTotalErrors": "Total errors: {count}",
"@logTotalErrors": {
"description": "Total error count in log issue analysis",
"placeholders": {
"count": {
"type": "int"
}
}
},
"logAffectedDomains": "Affected: {domains}",
"@logAffectedDomains": {
"description": "Affected domains in log issue analysis",
"placeholders": {
"domains": {
"type": "String"
}
}
},
"libraryScanCancelled": "Scan cancelled",
"@libraryScanCancelled": {
"description": "Library scan status when a scan was cancelled"
},
"libraryScanCancelledSubtitle": "You can retry the scan when ready.",
"@libraryScanCancelledSubtitle": {
"description": "Library scan status subtitle after cancellation"
},
"libraryDownloadsHistoryExcluded": "{count} from Downloads history (excluded from list)",
"@libraryDownloadsHistoryExcluded": {
"description": "Library count note for downloaded history items excluded from the local list",
"placeholders": {
"count": {
"type": "int"
}
}
},
"downloadNativeWorker": "Native download worker",
"@downloadNativeWorker": {
"description": "Setting title for Android native download worker"
},
"downloadNativeWorkerSubtitle": "Beta Android service worker for extension downloads",
"@downloadNativeWorkerSubtitle": {
"description": "Setting subtitle for Android native download worker"
},
"badgeBeta": "BETA",
"@badgeBeta": {
"description": "Badge label for beta features"
},
"extensionServiceStatus": "Service Status",
"@extensionServiceStatus": {
"description": "Extension detail section header for service status"
},
"extensionServiceHealth": "Service health",
"@extensionServiceHealth": {
"description": "Extension capability label for service health checks"
},
"extensionHealthChecksConfigured": "{count} {count, plural, =1{check} other{checks}} configured",
"@extensionHealthChecksConfigured": {
"description": "Extension service health check count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"extensionOauthConnectHint": "Tap Connect to Spotify to fill this field.",
"@extensionOauthConnectHint": {
"description": "Hint for an OAuth login link field before connecting Spotify"
},
"extensionLastChecked": "Last checked {time}",
"@extensionLastChecked": {
"description": "Timestamp for the latest extension service health check",
"placeholders": {
"time": {
"type": "String"
}
}
},
"extensionRefreshStatus": "Refresh status",
"@extensionRefreshStatus": {
"description": "Tooltip for refreshing extension service health status"
},
"extensionCustomUrlHandling": "Custom URL Handling",
"@extensionCustomUrlHandling": {
"description": "Extension detail section title for custom URL handling"
},
"extensionCustomUrlHandlingSubtitle": "This extension can handle links from these sites",
"@extensionCustomUrlHandlingSubtitle": {
"description": "Extension detail subtitle for custom URL handling"
},
"extensionCustomUrlHandlingShareHint": "Share links from these sites to SpotiFLAC Mobile and this extension will handle them.",
"@extensionCustomUrlHandlingShareHint": {
"description": "Extension detail hint explaining share-to-app URL handling"
},
"extensionSettingsCount": "{count} {count, plural, =1{setting} other{settings}}",
"@extensionSettingsCount": {
"description": "Count of settings exposed by an extension quality option",
"placeholders": {
"count": {
"type": "int"
}
}
},
"extensionHealthOnline": "Online",
"@extensionHealthOnline": {
"description": "Extension service health status - online"
},
"extensionHealthDegraded": "Degraded",
"@extensionHealthDegraded": {
"description": "Extension service health status - degraded"
},
"extensionHealthOffline": "Offline",
"@extensionHealthOffline": {
"description": "Extension service health status - offline"
},
"extensionHealthNotConfigured": "Not configured",
"@extensionHealthNotConfigured": {
"description": "Extension service health status - not configured"
},
"extensionHealthUnknown": "Unknown",
"@extensionHealthUnknown": {
"description": "Extension service health status - unknown"
},
"extensionHealthRequired": "required",
"@extensionHealthRequired": {
"description": "Label for a required extension service health check"
},
"extensionSettingNotSet": "Not set",
"@extensionSettingNotSet": {
"description": "Value shown when an extension setting has no value"
},
"extensionActionFailed": "Action failed",
"@extensionActionFailed": {
"description": "Fallback error when an extension action fails without details"
},
"extensionEnterValue": "Enter value",
"@extensionEnterValue": {
"description": "Hint for editing an extension setting value"
},
"extensionHealthServiceOnline": "Service online",
"@extensionHealthServiceOnline": {
"description": "Tooltip for online extension service"
},
"extensionHealthServiceDegraded": "Service degraded",
"@extensionHealthServiceDegraded": {
"description": "Tooltip for degraded extension service"
},
"extensionHealthServiceOffline": "Service offline",
"@extensionHealthServiceOffline": {
"description": "Tooltip for offline extension service"
},
"extensionHealthServiceUnknown": "Service status unknown",
"@extensionHealthServiceUnknown": {
"description": "Tooltip for unknown extension service health"
},
"audioAnalysisStereo": "Stereo",
"@audioAnalysisStereo": {
"description": "Audio channel layout label - stereo"
},
"audioAnalysisMono": "Mono",
"@audioAnalysisMono": {
"description": "Audio channel layout label - mono"
},
"trackOpenInService": "Open in {serviceName}",
"@trackOpenInService": {
"description": "Button label to open a track in a named music service",
"placeholders": {
"serviceName": {
"type": "String"
}
}
},
"trackLyricsEmbeddedSource": "Embedded",
"@trackLyricsEmbeddedSource": {
"description": "Lyrics source label for embedded lyrics"
},
"unknownAlbum": "Unknown Album",
"@unknownAlbum": {
"description": "Fallback album name when metadata is missing"
},
"unknownArtist": "Unknown Artist",
"@unknownArtist": {
"description": "Fallback artist name when metadata is missing"
},
"permissionAudio": "Audio",
"@permissionAudio": {
"description": "Audio permission type label"
},
"permissionStorage": "Storage",
"@permissionStorage": {
"description": "Storage permission type label"
},
"permissionNotification": "Notification",
"@permissionNotification": {
"description": "Notification permission type label"
},
"errorInvalidFolderSelected": "Invalid folder selected",
"@errorInvalidFolderSelected": {
"description": "Error when the selected folder is invalid"
},
"errorCouldNotKeepFolderAccess": "Could not keep access to the selected folder",
"@errorCouldNotKeepFolderAccess": {
"description": "Error when persistent folder access cannot be saved"
},
"storeAnyVersion": "Any",
"@storeAnyVersion": {
"description": "Store detail value when any app version is accepted"
},
"storeCategoryMetadata": "Metadata",
"@storeCategoryMetadata": {
"description": "Store extension category - metadata"
},
"storeCategoryDownload": "Download",
"@storeCategoryDownload": {
"description": "Store extension category - download"
},
"storeCategoryUtility": "Utility",
"@storeCategoryUtility": {
"description": "Store extension category - utility"
},
"storeCategoryLyrics": "Lyrics",
"@storeCategoryLyrics": {
"description": "Store extension category - lyrics"
},
"storeCategoryIntegration": "Integration",
"@storeCategoryIntegration": {
"description": "Store extension category - integration"
},
"artistReleases": "Releases",
"@artistReleases": {
"description": "Section header for all artist releases"
},
"editMetadataSelectNone": "None",
"@editMetadataSelectNone": {
"description": "Button to clear selected fields for auto-fill"
},
"queueRetryAllFailed": "Retry {count} failed",
"@queueRetryAllFailed": {
"description": "Button to retry every failed download in the queue",
"placeholders": {
"count": {
"type": "int"
}
}
},
"settingsSaveDownloadHistory": "Save download history",
"@settingsSaveDownloadHistory": {
"description": "Settings switch title for storing completed downloads in history"
},
"settingsSaveDownloadHistorySubtitle": "Keep completed downloads in history and library views",
"@settingsSaveDownloadHistorySubtitle": {
"description": "Settings switch subtitle for storing completed downloads in history"
},
"dialogDisableHistoryTitle": "Turn off download history?",
"@dialogDisableHistoryTitle": {
"description": "Confirmation dialog title shown before disabling download history"
},
"dialogDisableHistoryMessage": "Existing history will be cleared. Downloaded files will not be deleted.",
"@dialogDisableHistoryMessage": {
"description": "Confirmation dialog message shown before disabling download history"
},
"dialogDisableAndClear": "Turn off and clear",
"@dialogDisableAndClear": {
"description": "Confirmation action to disable download history and clear existing entries"
},
"openInOtherServices": "Open in Other Services",
"@openInOtherServices": {
"description": "Title and tooltip for finding the current collection in other services"
},
"shareSheetNoExtensions": "No other compatible services",
"@shareSheetNoExtensions": {
"description": "Empty state when no extensions can be searched for cross-service links"
},
"shareSheetNotFound": "Not found",
"@shareSheetNotFound": {
"description": "Cross-service share sheet row subtitle when a service has no match"
},
"shareSheetCopyLink": "Copy Link",
"@shareSheetCopyLink": {
"description": "Tooltip for copying a cross-service link"
},
"shareSheetLinkCopied": "{service} link copied",
"@shareSheetLinkCopied": {
"description": "Snackbar after copying a cross-service link",
"placeholders": {
"service": {}
}
}
}
+9 -1
View File
@@ -398,7 +398,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Descargar pistas de Spotify en alta calidad (sin pérdida) de Tidal y Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+77 -1
View File
@@ -458,7 +458,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -905,6 +905,10 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"searchEmptyResultSubtitle": "Coba kata kunci lain",
"@searchEmptyResultSubtitle": {
"description": "Subtitle shown under the empty search result state on the home screen"
},
"errorUrlNotRecognized": "Tautan tidak dikenali",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
@@ -4204,6 +4208,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
@@ -4606,5 +4618,69 @@
"downloadFallbackExtensionsSubtitle": "Choose which extensions can be used as fallback",
"@downloadFallbackExtensionsSubtitle": {
"description": "Subtitle for fallback extensions item"
},
"queueRateLimitTitle": "Layanan sedang membatasi permintaan",
"@queueRateLimitTitle": {
"description": "Title shown on a failed queue item when the download service rate limits requests"
},
"queueRateLimitMessage": "Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.",
"@queueRateLimitMessage": {
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
},
"editMetadataSelectNone": "Tidak ada",
"@editMetadataSelectNone": {
"description": "Button to clear selected fields for auto-fill"
},
"queueRetryAllFailed": "Coba ulang {count} gagal",
"@queueRetryAllFailed": {
"description": "Button to retry every failed download in the queue",
"placeholders": {
"count": {
"type": "int"
}
}
},
"settingsSaveDownloadHistory": "Simpan riwayat unduhan",
"@settingsSaveDownloadHistory": {
"description": "Settings switch title for storing completed downloads in history"
},
"settingsSaveDownloadHistorySubtitle": "Simpan unduhan selesai di riwayat dan tampilan pustaka",
"@settingsSaveDownloadHistorySubtitle": {
"description": "Settings switch subtitle for storing completed downloads in history"
},
"dialogDisableHistoryTitle": "Matikan riwayat unduhan?",
"@dialogDisableHistoryTitle": {
"description": "Confirmation dialog title shown before disabling download history"
},
"dialogDisableHistoryMessage": "Riwayat yang ada akan dihapus. File unduhan tidak akan dihapus.",
"@dialogDisableHistoryMessage": {
"description": "Confirmation dialog message shown before disabling download history"
},
"dialogDisableAndClear": "Matikan dan hapus",
"@dialogDisableAndClear": {
"description": "Confirmation action to disable download history and clear existing entries"
},
"openInOtherServices": "Buka di Layanan Lain",
"@openInOtherServices": {
"description": "Title and tooltip for finding the current collection in other services"
},
"shareSheetNoExtensions": "Tidak ada layanan lain yang kompatibel",
"@shareSheetNoExtensions": {
"description": "Empty state when no extensions can be searched for cross-service links"
},
"shareSheetNotFound": "Tidak ditemukan",
"@shareSheetNotFound": {
"description": "Cross-service share sheet row subtitle when a service has no match"
},
"shareSheetCopyLink": "Salin Tautan",
"@shareSheetCopyLink": {
"description": "Tooltip for copying a cross-service link"
},
"shareSheetLinkCopied": "Tautan {service} disalin",
"@shareSheetLinkCopied": {
"description": "Snackbar after copying a cross-service link",
"placeholders": {
"service": {}
}
}
}
+9 -1
View File
@@ -438,7 +438,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -398,7 +398,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -438,7 +438,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Spotify parçalarını Tidal ve Qobuz aracılığıyla kayıpsız kalitede indirin.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4027,6 +4027,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"downloadSingleFilenameFormat": "Single Dosya Adı Formatı",
"@downloadSingleFilenameFormat": {
"description": "Setting for output filename pattern for singles/EPs"
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Кінцеві точки потокового передавання Tidal Hi-Res FLAC. Ключовий елемент пазлу музики без втрат.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Пошук за допомогою{providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -398,7 +398,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+1 -1
View File
@@ -89,7 +89,7 @@ class DownloadItem {
case DownloadErrorType.notFound:
return 'Song not found on any service';
case DownloadErrorType.rateLimit:
return 'Rate limit reached, try again later';
return 'Service rate limit reached. Wait before retrying.';
case DownloadErrorType.network:
return 'Connection failed, check your internet';
case DownloadErrorType.permission:
+11 -1
View File
@@ -47,7 +47,7 @@ class AppSettings {
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
@@ -81,6 +81,8 @@ class AppSettings {
lyricsIncludeRomanizationNetease; // Append romanized lyrics (Netease)
final bool
lyricsMultiPersonWordByWord; // Enable v1/v2 + [bg:] tags for Apple/QQ syllable lyrics
final bool
lyricsAppleElrcWordSync; // Preserve Apple Music inline word timestamps for eLRC-capable players
final String
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
@@ -89,6 +91,7 @@ class AppSettings {
final bool
deduplicateDownloads; // Skip downloading tracks already present in history
final bool saveDownloadHistory; // Record completed downloads in local history
const AppSettings({
this.defaultService = '',
@@ -146,9 +149,11 @@ class AppSettings {
this.lyricsIncludeTranslationNetease = false,
this.lyricsIncludeRomanizationNetease = false,
this.lyricsMultiPersonWordByWord = false,
this.lyricsAppleElrcWordSync = false,
this.musixmatchLanguage = '',
this.lastSeenVersion = '',
this.deduplicateDownloads = true,
this.saveDownloadHistory = true,
});
AppSettings copyWith({
@@ -210,9 +215,11 @@ class AppSettings {
bool? lyricsIncludeTranslationNetease,
bool? lyricsIncludeRomanizationNetease,
bool? lyricsMultiPersonWordByWord,
bool? lyricsAppleElrcWordSync,
String? musixmatchLanguage,
String? lastSeenVersion,
bool? deduplicateDownloads,
bool? saveDownloadHistory,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -291,9 +298,12 @@ class AppSettings {
this.lyricsIncludeRomanizationNetease,
lyricsMultiPersonWordByWord:
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
lyricsAppleElrcWordSync:
lyricsAppleElrcWordSync ?? this.lyricsAppleElrcWordSync,
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
);
}
+4
View File
@@ -78,9 +78,11 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['lyricsIncludeRomanizationNetease'] as bool? ?? false,
lyricsMultiPersonWordByWord:
json['lyricsMultiPersonWordByWord'] as bool? ?? false,
lyricsAppleElrcWordSync: json['lyricsAppleElrcWordSync'] as bool? ?? false,
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(
@@ -142,7 +144,9 @@ Map<String, dynamic> _$AppSettingsToJson(
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease,
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
'lyricsAppleElrcWordSync': instance.lyricsAppleElrcWordSync,
'musixmatchLanguage': instance.musixmatchLanguage,
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'saveDownloadHistory': instance.saveDownloadHistory,
};
+2 -2
View File
@@ -46,7 +46,7 @@ class ThemeSettings {
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings(
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
themeMode: themeModeFromString(json[kThemeModeKey] as String?),
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
useAmoled: json[kUseAmoledKey] as bool? ?? false,
@@ -68,7 +68,7 @@ class ThemeSettings {
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
}
ThemeMode _themeModeFromString(String? value) {
ThemeMode themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere(
(e) => e.name == value,
File diff suppressed because it is too large Load Diff
@@ -54,11 +54,6 @@ class LocalLibraryState {
_isrcSet = isrcSet ?? const <String>{},
_filePathById = filePathById ?? const <String, String>{};
@Deprecated(
'LocalLibraryState no longer owns full track rows. Use DB-backed page providers.',
)
List<LocalLibraryItem> get items => const <LocalLibraryItem>[];
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
bool hasTrack(String trackName, String artistName) {
+12
View File
@@ -108,6 +108,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
'include_translation_netease': state.lyricsIncludeTranslationNetease,
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
'musixmatch_language': state.musixmatchLanguage,
}).catchError((Object e) {
_log.w('Failed to sync lyrics fetch options to backend: $e');
@@ -367,6 +368,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncLyricsSettingsToBackend();
}
void setLyricsAppleElrcWordSync(bool enabled) {
state = state.copyWith(lyricsAppleElrcWordSync: enabled);
_saveSettings();
_syncLyricsSettingsToBackend();
}
void setMusixmatchLanguage(String languageCode) {
state = state.copyWith(
musixmatchLanguage: languageCode.trim().toLowerCase(),
@@ -593,6 +600,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(deduplicateDownloads: enabled);
_saveSettings();
}
void setSaveDownloadHistory(bool enabled) {
state = state.copyWith(saveDownloadHistory: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+1 -9
View File
@@ -25,7 +25,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
final useAmoled = prefs.getBool(kUseAmoledKey);
state = ThemeSettings(
themeMode: _themeModeFromString(modeString),
themeMode: themeModeFromString(modeString),
useDynamicColor: useDynamic ?? true,
seedColorValue: seedColor ?? kDefaultSeedColor,
useAmoled: useAmoled ?? false,
@@ -71,12 +71,4 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
state = state.copyWith(useAmoled: value);
await _saveToStorage();
}
ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere(
(e) => e.name == value,
orElse: () => ThemeMode.system,
);
}
}
+57 -12
View File
@@ -21,6 +21,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -566,18 +567,24 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(tracks.length),
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
Flexible(
child: FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(
tracks.length,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
),
@@ -608,6 +615,23 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
onPressed: () => Navigator.pop(context),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: context.l10n.openInOtherServices,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
),
onPressed: () => _showShareSheet(context, tracks, artistName),
),
),
],
);
}
@@ -846,6 +870,27 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
void _showShareSheet(
BuildContext context,
List<Track> tracks,
String? artistName,
) {
final sourceExtensionId = _directMetadataProviderId() ?? '';
final resolvedArtists =
artistName ??
tracks.firstOrNull?.albumArtist ??
tracks.firstOrNull?.artistName ??
'';
CrossExtensionShareSheet.show(
context,
name: widget.albumName,
artists: resolvedArtists,
type: 'album',
sourceExtensionId: sourceExtensionId,
);
}
Future<void> _loveAll(List<Track> tracks) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
final state = ref.read(libraryCollectionsProvider);
+29 -1
View File
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -507,7 +508,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (releases.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
'Releases',
context.l10n.artistReleases,
releases,
colorScheme,
),
@@ -1333,6 +1334,33 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
onPressed: () => Navigator.pop(context),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: context.l10n.openInOtherServices,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
),
onPressed: () => _showShareSheet(context),
),
),
],
);
}
void _showShareSheet(BuildContext context) {
CrossExtensionShareSheet.show(
context,
name: widget.artistName,
artists: '',
type: 'artist',
sourceExtensionId: _directMetadataProviderId() ?? '',
);
}
+55 -47
View File
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
@@ -944,40 +945,39 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null) sourceFormats.add(ext);
final sourceFormat = convertibleAudioSourceFormat(
storedFormat: item.format,
filePath: item.filePath,
fileName: item.safFileName,
);
if (sourceFormat != null) sourceFormats.add(sourceFormat);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
final formats = audioConversionTargetFormats
.where(
(target) => sourceFormats.any(
(source) => canConvertAudioFormat(
sourceFormat: source,
targetFormat: target,
),
),
)
.toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
return '320k';
}
String selectedBitrate = isLosslessTarget
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
: defaultBitrateForFormat(selectedFormat);
showModalBottomSheet<void>(
context: context,
@@ -1039,9 +1039,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
@@ -1137,23 +1137,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext == null || ext == targetFormat) continue;
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
final sourceFormat = convertibleAudioSourceFormat(
storedFormat: item.format,
filePath: item.filePath,
fileName: item.safFileName,
);
if (sourceFormat == null ||
!canConvertAudioFormat(
sourceFormat: sourceFormat,
targetFormat: targetFormat,
)) {
continue;
}
selected.add(item);
}
@@ -1316,6 +1311,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
@@ -1350,14 +1346,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
continue;
}
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
if (!isSameContentUri(item.filePath, safUri)) {
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
}
await historyDb.updateFilePath(
item.id,
safUri,
newSafFileName: newFileName,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
}
@@ -1374,6 +1377,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
item.id,
newPath,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
}
+117 -11
View File
@@ -46,6 +46,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
final _urlController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery;
String? _activeSearchInput;
bool _isResettingSearchSurface = false;
late final ProviderSubscription<TrackState> _trackStateSub;
late final ProviderSubscription<bool> _extensionInitSub;
late final ProviderSubscription<bool> _homeFeedExtSub;
@@ -557,9 +559,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (text.isEmpty) {
_liveSearchDebounce?.cancel();
_activeSearchInput = null;
_lastSearchQuery = null;
if (!_isResettingSearchSurface) {
_resetSearchSurface(clearText: false);
}
return;
}
if (_activeSearchInput != null && _activeSearchInput != text) {
_activeSearchInput = null;
}
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
if (text.startsWith('http') || text.startsWith('spotify:')) return;
@@ -638,10 +649,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
final searchKey =
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
if (_lastSearchQuery == searchKey) return;
if (_lastSearchQuery == searchKey) {
_activeSearchInput = query;
ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty);
if (mounted) setState(() {});
return;
}
_lastSearchQuery = searchKey;
_activeSearchInput = query;
_searchSortOption = _SearchSortOption.defaultOrder;
_invalidateSearchSortCaches();
ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty);
final isExtensionEnabled =
searchProvider != null &&
@@ -686,12 +704,26 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
Future<void> _clearAndRefresh() async {
_liveSearchDebounce?.cancel();
_pendingLiveSearchQuery = null;
_urlController.clear();
_searchFocusNode.unfocus();
_lastSearchQuery = null;
ref.read(trackProvider.notifier).clear();
_resetSearchSurface();
}
void _resetSearchSurface({bool clearText = true}) {
if (_isResettingSearchSurface) return;
_isResettingSearchSurface = true;
try {
_liveSearchDebounce?.cancel();
_pendingLiveSearchQuery = null;
_lastSearchQuery = null;
_activeSearchInput = null;
FocusManager.instance.primaryFocus?.unfocus();
if (clearText && _urlController.text.isNotEmpty) {
_urlController.clear();
}
ref.read(trackProvider.notifier).clear();
if (mounted) setState(() {});
} finally {
_isResettingSearchSurface = false;
}
}
Future<void> _fetchMetadata() async {
@@ -1114,6 +1146,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
);
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final searchError = ref.watch(trackProvider.select((s) => s.error));
final hasSearchedBefore = ref.watch(
settingsProvider.select((s) => s.hasSearchedBefore),
);
@@ -1153,6 +1186,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
final isSearchFocused = _searchFocusNode.hasFocus;
final hasShortSearchInput =
hasSearchInput && searchText.length < _minLiveSearchChars;
final hasSearchError = hasSearchInput && searchError != null;
final hasActiveSearchSurface =
hasSearchInput &&
(_activeSearchInput == searchText ||
hasActualResults ||
isLoading ||
hasSearchError);
final showEmptySearchResult =
hasActiveSearchSurface &&
!hasActualResults &&
!isLoading &&
searchError == null;
final isShowingRecentAccess = ref.watch(
trackProvider.select((s) => s.isShowingRecentAccess),
);
@@ -1166,7 +1211,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
final showRecentAccess =
recentModeRequested &&
(!hasSearchInput || hasShortSearchInput || !hasActualResults) &&
(!hasSearchInput ||
hasShortSearchInput ||
(!hasActualResults &&
!hasSearchError &&
!hasActiveSearchSurface)) &&
!isLoading;
final isSearchProviderLoading =
!extensionReadiness.isInitialized && extensionReadiness.error == null;
@@ -1180,6 +1229,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final showExplore =
!hasActualResults &&
!isLoading &&
!hasActiveSearchSurface &&
!showRecentAccess &&
!homeFeedDisabled &&
(hasHomeFeedExtension || hasExploreContent) &&
@@ -1299,7 +1349,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
if (hasActualResults && !showRecentAccess)
if (hasActiveSearchSurface &&
!showRecentAccess &&
!showEmptySearchResult)
Consumer(
builder: (context, ref, _) {
final currentSearchProvider = ref.watch(
@@ -1466,7 +1518,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty) ||
isLoading ||
error != null;
error != null ||
hasActiveSearchSurface;
return SliverMainAxisGroup(
slivers: _buildSearchResults(
@@ -1478,6 +1531,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
error: error,
colorScheme: colorScheme,
hasResults: hasResults,
showEmptySearchResult: showEmptySearchResult,
searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@@ -1549,7 +1603,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo-transparant.png',
'assets/images/logo-transparent.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
@@ -2611,6 +2665,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
Widget _buildEmptySearchResultWidget(ColorScheme colorScheme) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 340),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 86,
height: 86,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.surfaceContainerHighest,
),
child: Icon(
Icons.manage_search,
size: 46,
color: colorScheme.primary,
),
),
const SizedBox(height: 16),
Text(
context.l10n.errorNoTracksFound,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
context.l10n.searchEmptyResultSubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
String _sortOptionLabel(_SearchSortOption option) {
switch (option) {
case _SearchSortOption.defaultOrder:
@@ -2888,6 +2983,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
required String? error,
required ColorScheme colorScheme,
required bool hasResults,
required bool showEmptySearchResult,
required String? searchExtensionId,
required bool showLocalLibraryIndicator,
required Map<String, (double, double)> thumbnailSizesByExtensionId,
@@ -2934,6 +3030,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
child: LinearProgressIndicator(),
),
),
if (showEmptySearchResult && !hasActualData)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 96),
child: _buildEmptySearchResultWidget(colorScheme),
),
),
),
];
bool sortButtonShown = false;
+9 -5
View File
@@ -508,8 +508,10 @@ class _CollectionItemWidget extends StatelessWidget {
item.artistName.isNotEmpty
? item.artistName
: (isPlaylist
? 'Playlist'
: (isArtist ? 'Artist' : 'Album')),
? context.l10n.recentTypePlaylist
: (isArtist
? context.l10n.recentTypeArtist
: context.l10n.recentTypeAlbum)),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -607,7 +609,7 @@ class _SearchArtistItemWidget extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
'Artist',
context.l10n.recentTypeArtist,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -707,7 +709,7 @@ class _SearchAlbumItemWidget extends StatelessWidget {
ClickableArtistName(
artistName: album.artists.isNotEmpty
? album.artists
: 'Album',
: context.l10n.recentTypeAlbum,
coverUrl: album.imageUrl,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -806,7 +808,9 @@ class _SearchPlaylistItemWidget extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
playlist.owner.isNotEmpty ? playlist.owner : 'Playlist',
playlist.owner.isNotEmpty
? playlist.owner
: context.l10n.recentTypePlaylist,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),

Some files were not shown because too many files have changed in this diff Show More