mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
229 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 107d9ca007 | |||
| 4633c7253a | |||
| 8ace180fa8 | |||
| b9c3f2f0dd | |||
| 81b0eede8c | |||
| eb0cdbeba8 | |||
| ee212a0e48 | |||
| 2073516666 | |||
| 9d479b61d6 | |||
| 203e6bc4eb | |||
| 5f1ffbee4e | |||
| b29dc63337 | |||
| 29699117dc | |||
| 3c75f9ecc6 | |||
| 79340703c1 | |||
| df23e3f96c | |||
| d9f788ddeb | |||
| 62afbdcaaa | |||
| 6c578cfd78 | |||
| a17abec799 | |||
| 2a71b70a34 | |||
| 03f77daf19 | |||
| 270b0c1af6 | |||
| 317bb523a4 | |||
| 2c8ad87b7e | |||
| 5e06729029 | |||
| 21bcfe1157 | |||
| 3aeaaaf4f2 | |||
| 3a9d1395db | |||
| 90c46d99d4 | |||
| 96f44fefd4 | |||
| 38a0a76b69 | |||
| 7fc73b6038 | |||
| 6b61dbc2da | |||
| fd3158fd15 | |||
| ff7135bf2c | |||
| 74bac570c7 | |||
| 5f999035c3 | |||
| fa7b5a3559 | |||
| 187821b2ae | |||
| 1435ba9658 | |||
| 62e2e1703c | |||
| 21a732379b | |||
| 8ac035d146 | |||
| d7e7fb065e | |||
| 11d3b8ab3b | |||
| 566e5996bc | |||
| 51618c7dbd | |||
| bdff3a6135 | |||
| ef7cd4ff5d | |||
| 431e437dee | |||
| cebd43e75a | |||
| 17bfbf95f2 | |||
| dad525be40 | |||
| 7dd0dbd594 | |||
| a0bf423a50 | |||
| 288b060983 | |||
| 5ba60d4fd0 | |||
| 07dae97fe6 | |||
| b210f67728 | |||
| 728d1d58c2 | |||
| 6b9650d451 | |||
| 72ae9072bf | |||
| e82263dc14 | |||
| f03b218775 | |||
| c840b59ae1 | |||
| 1213fc449a | |||
| ca21bb0f0c | |||
| 00555b2df6 | |||
| efca120470 | |||
| a178c3943a | |||
| 01ed1f20ad | |||
| e2bd67083e | |||
| 31fb0a87c9 | |||
| ac4d9fc602 | |||
| 8b1b581dbe | |||
| ebdaa24cfc | |||
| 5633e3adf8 | |||
| fcae5e066d | |||
| c312aea75f | |||
| 1e6e19ecd2 | |||
| 0866b04766 | |||
| 78cef8d58e | |||
| ce84aee8da | |||
| 1ba1665215 | |||
| 60fb18c8e2 | |||
| c042b490b8 | |||
| f544b46d97 | |||
| 70759724fe | |||
| fbfe252df6 | |||
| 2c3def8c7b | |||
| 47e67e8299 | |||
| ec15516230 | |||
| 462013bc2a | |||
| 6b5e53864d | |||
| a8a47589c8 | |||
| b9d567d421 | |||
| 81c77af558 | |||
| 1121680da6 | |||
| d31f2e8894 | |||
| 5895a59cb2 | |||
| 3e5e8d7a42 | |||
| 518a7fd2cf | |||
| 6c832d1754 | |||
| d898b5f23e | |||
| c38a1428f1 | |||
| 759eeccc1f | |||
| d0bc3b203c | |||
| 831b68b6cc | |||
| a06111f445 | |||
| 31fdd30c13 | |||
| e207ef89d5 | |||
| 1261da2e5b | |||
| 0c917bc41e | |||
| f525d6c7e6 | |||
| ed7c67a622 | |||
| 99281df5fb | |||
| 24c2fd6a15 | |||
| ec3fe34dc0 | |||
| 56f36da5f9 | |||
| 9bbd774175 | |||
| 020ac32ee6 | |||
| 67a72210ac | |||
| 020f41fd1e | |||
| 820eb8cc32 | |||
| 47fa5c2009 | |||
| 9b0c929423 | |||
| 93105a45fe | |||
| d8b2f4d367 | |||
| f1478bb2ca | |||
| 8b3c377688 | |||
| 8c98b02dca | |||
| 3743e35e8a | |||
| 05a02de4a9 | |||
| c28378cbb5 | |||
| b2bef63b6b | |||
| 6513e14b21 | |||
| fd53755ad6 | |||
| 1dbacb3027 | |||
| 910d9a7662 | |||
| 09bd8c6b21 | |||
| 908d108858 | |||
| 3135993cf4 | |||
| 7a315b5fd4 | |||
| 4bd6dcc3d7 | |||
| 3f7fa19cdf | |||
| 867ec4d125 | |||
| 164467f3a2 | |||
| fc9a2ddc2a | |||
| 543cb45c11 | |||
| c49e5adc52 | |||
| 0fedd446ca | |||
| 0c7b8a68d9 | |||
| 6dd6accbcc | |||
| ca67f7f79d | |||
| 1aa12c5857 | |||
| 80707fc438 | |||
| ff121dfeb8 | |||
| c3aa6a441b | |||
| 496d32e35b | |||
| 291fa58757 | |||
| eddbc2f986 | |||
| 81b8281d2c | |||
| 57f87d9a4c | |||
| c9d0c57d86 | |||
| 54ab5a9243 | |||
| 17b6b27cd7 | |||
| ed131ca1fd | |||
| 190d65cdee | |||
| dbf2e337f0 | |||
| 12e76bed4f | |||
| e00db80dae | |||
| 5de0aa8145 | |||
| 91ffb25027 | |||
| 6bcbdfedf0 | |||
| 3f42128cb9 | |||
| ccb8f98df5 | |||
| 591a597333 | |||
| 22f52f4af2 | |||
| ceaaff8c9b | |||
| a318495046 | |||
| 8ffc6d3821 | |||
| 2036e46da0 | |||
| b82000e87c | |||
| 144906fd8f | |||
| 8a109e9013 | |||
| ba05f6b470 | |||
| 2f80ae7e84 | |||
| e248fef130 | |||
| 174724ddd3 | |||
| 730945d892 | |||
| 4abdce8c58 | |||
| 0d98ada479 | |||
| 5d4fc10ab7 | |||
| e37dfeb080 | |||
| eddae2a9dd | |||
| 6bd7eec615 | |||
| b240e91290 | |||
| 4e0149df29 | |||
| 065872e686 | |||
| 7ab0f5b7c8 | |||
| fd31682242 | |||
| 56c8b62fcf | |||
| c3f879346a | |||
| 6da65ed033 | |||
| 553c6b6c4a | |||
| ac5f74a48f | |||
| 2d22d85c49 | |||
| 3edfe8e8bb | |||
| 6f9722e05b | |||
| 066d35967e | |||
| 2b932cff70 | |||
| a32487ad88 | |||
| bd4946db37 | |||
| 69f143dd9d | |||
| 15408bfa1c | |||
| edc715021d | |||
| 392472b027 | |||
| 69741fa47c | |||
| 484720bcda | |||
| f3cc51fb06 | |||
| 452ea7084a | |||
| bba059fc44 | |||
| 3f75cace2b | |||
| 556c0e1db2 | |||
| 9897d3102e | |||
| 88dfb88bcc | |||
| 75bfe9b3bf | |||
| f4fe74f972 |
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.21"
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.21"
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
@@ -194,7 +194,7 @@ jobs:
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../ios/Frameworks
|
||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||
gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
@@ -249,23 +249,6 @@ jobs:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
||||
- name: Use iOS pubspec with FFmpeg plugin
|
||||
run: |
|
||||
cp pubspec.yaml pubspec_android_backup.yaml
|
||||
cp pubspec_ios.yaml pubspec.yaml
|
||||
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
|
||||
|
||||
# Swap FFmpeg service for iOS
|
||||
- name: Use iOS FFmpeg service
|
||||
run: |
|
||||
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
|
||||
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
|
||||
# Update class name in the swapped file
|
||||
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
|
||||
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
|
||||
echo "Swapped to iOS FFmpeg service"
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -507,11 +490,13 @@ jobs:
|
||||
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||
|
||||
# Send message first (using HTML parse mode)
|
||||
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
|
||||
# Use || true to ensure file uploads continue even if message fails
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-d text="${MESSAGE}" \
|
||||
-d parse_mode="HTML" \
|
||||
-d disable_web_page_preview="true"
|
||||
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" \
|
||||
--data-urlencode "parse_mode=HTML" \
|
||||
--data-urlencode "disable_web_page_preview=true" || true
|
||||
|
||||
# Upload arm64 APK to channel
|
||||
if [ -f "$ARM64_APK" ]; then
|
||||
|
||||
@@ -72,3 +72,4 @@ flutter_*.log
|
||||
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
|
||||
+49
-2
@@ -1,8 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [3.2.1] - 2026-01-22
|
||||
## [3.3.5] - 2026-02-01
|
||||
|
||||
> **Note:** Next release will use `year.month.day` format (e.g., 26.2.1) and is scheduled for early February. Developer is taking a short break!
|
||||
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
|
||||
### Added
|
||||
|
||||
- **Export Failed Downloads**: Export failed downloads to TXT file for easy lookup on other platforms
|
||||
- **Auto-Export Setting**: Option to automatically export failed downloads when queue finishes
|
||||
|
||||
### Fixed
|
||||
|
||||
- **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion
|
||||
- **Service Selection Ignored**: Fixed bug where selecting Qobuz/Amazon from service picker was ignored and always used Tidal instead
|
||||
- **iOS iCloud Drive Permission Error**: Block iCloud Drive folder selection on iOS (Go backend cannot access iCloud due to sandboxing)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Amazon Fallback Only**: Amazon Music is now grayed out in service picker and can only be used as fallback provider
|
||||
|
||||
---
|
||||
|
||||
## [3.3.1] - 2026-02-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/zarzet/SpotiFLAC-Mobile/issues/96))
|
||||
- **IDHS Fallback**: Fallback link resolver when SongLink fails (rate limited 8 req/min)
|
||||
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
|
||||
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
|
||||
- **Album/Playlist Search**: Deezer search now includes albums and playlists
|
||||
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re*Index.(ot_inc))
|
||||
- **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed
|
||||
- **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks
|
||||
|
||||
### Changed
|
||||
|
||||
- **Amazon Download API**: Switched to AfkarXYZ API
|
||||
- **Qobuz Download API**: Added Jumo API as fallback
|
||||
- **Search Results**: Reduced artist limit from 5 to 2
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
|
||||
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
|
||||
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/zarzet/SpotiFLAC-Mobile/issues/112))
|
||||
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
|
||||
|
||||
---
|
||||
|
||||
## [3.2.1] - 2026-01-22
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/3257155286587a3596ad5d4380d4576a684aa3d37a5b19a615914a845fbe57f3)
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
@@ -52,8 +52,6 @@ Want to create your own extension? Check out the [Extension Development Guide](h
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
|
||||
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
|
||||
|
||||
## Telegram
|
||||
|
||||
<p align="center">
|
||||
@@ -61,7 +59,7 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
|
||||
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
|
||||
</a>
|
||||
|
||||
<a href="https://t.me/spotiflacchat">
|
||||
<a href="https://t.me/spotiflac_chat">
|
||||
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
|
||||
</a>
|
||||
</p>
|
||||
@@ -86,6 +84,14 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
|
||||
**Q: Why is download not working in my country?**
|
||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
|
||||
_If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._
|
||||
|
||||
[](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
|
||||
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
@@ -100,3 +106,8 @@ You are solely responsible for:
|
||||
3. Any legal consequences resulting from the misuse of this tool.
|
||||
|
||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+74
-2
@@ -5,6 +5,7 @@
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
-keep class io.flutter.embedding.** { *; }
|
||||
|
||||
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||
-dontwarn com.google.android.play.core.splitcompat.**
|
||||
@@ -14,13 +15,22 @@
|
||||
# Ignore missing javax.xml.stream (not used on Android)
|
||||
-dontwarn javax.xml.stream.**
|
||||
|
||||
# Go backend (gobackend.aar)
|
||||
# Go backend (gobackend.aar) - CRITICAL for release builds
|
||||
-keep class gobackend.** { *; }
|
||||
-keep class go.** { *; }
|
||||
-keep interface gobackend.** { *; }
|
||||
-keepclassmembers class gobackend.** { *; }
|
||||
|
||||
# Go mobile binding internals
|
||||
-keep class org.golang.** { *; }
|
||||
-dontwarn org.golang.**
|
||||
|
||||
# FFmpeg Kit
|
||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||
-keep class com.arthenica.smartexception.** { *; }
|
||||
# FFmpeg Kit (new fork package)
|
||||
-keep class com.antonkarpenko.ffmpegkit.** { *; }
|
||||
-keep class com.antonkarpenko.smartexception.** { *; }
|
||||
|
||||
# Apache Tika (if used by FFmpeg)
|
||||
-dontwarn org.apache.tika.**
|
||||
@@ -30,15 +40,77 @@
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Kotlin coroutines
|
||||
# Kotlin coroutines - expanded rules
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.coroutines.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
-keepclassmembernames class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# Kotlin serialization
|
||||
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||
-dontwarn kotlin.**
|
||||
-keep class kotlin.** { *; }
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
# Keep MainActivity and related classes
|
||||
-keep class com.zarz.spotiflac.** { *; }
|
||||
|
||||
# Prevent R8 from removing metadata
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
|
||||
# JSON parsing (used by Go backend responses)
|
||||
-keep class org.json.** { *; }
|
||||
|
||||
# Shared Preferences
|
||||
-keep class androidx.datastore.** { *; }
|
||||
-dontwarn androidx.datastore.**
|
||||
|
||||
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
|
||||
# Path Provider
|
||||
-keep class io.flutter.plugins.pathprovider.** { *; }
|
||||
-keep class dev.flutter.pigeon.** { *; }
|
||||
|
||||
# Local Notifications
|
||||
-keep class com.dexterous.** { *; }
|
||||
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||
|
||||
# Receive Sharing Intent
|
||||
-keep class com.kasem.receive_sharing_intent.** { *; }
|
||||
|
||||
# Permission Handler
|
||||
-keep class com.baseflow.permissionhandler.** { *; }
|
||||
|
||||
# File Picker
|
||||
-keep class com.mr.flutter.plugin.filepicker.** { *; }
|
||||
|
||||
# URL Launcher
|
||||
-keep class io.flutter.plugins.urllauncher.** { *; }
|
||||
|
||||
# Share Plus
|
||||
-keep class dev.fluttercommunity.plus.share.** { *; }
|
||||
|
||||
# Device Info Plus
|
||||
-keep class dev.fluttercommunity.plus.device_info.** { *; }
|
||||
|
||||
# Open File
|
||||
-keep class com.crazecoder.openfile.** { *; }
|
||||
|
||||
# Sqflite
|
||||
-keep class com.tekartik.sqflite.** { *; }
|
||||
|
||||
# Dynamic Color
|
||||
-keep class io.material.** { *; }
|
||||
|
||||
# Keep all Flutter plugin registrants
|
||||
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
|
||||
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
|
||||
|
||||
@@ -1,23 +1,154 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterShellArgs
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import gobackend.Gobackend
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
companion object {
|
||||
// Minimum API level we consider "safe" for Impeller (Android 10+)
|
||||
private const val SAFE_API_FOR_IMPELLER = 29
|
||||
|
||||
// Known problematic GPU patterns (lowercase)
|
||||
private val PROBLEMATIC_GPU_PATTERNS = listOf(
|
||||
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
|
||||
"adreno (tm) 4", // Adreno 400 series - some have issues
|
||||
"mali-4", // Mali-400 series - old ARM GPUs
|
||||
"mali-t6", // Mali-T600 series
|
||||
"mali-t7", // Mali-T700 series (some)
|
||||
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
|
||||
"powervr ge8320", // PowerVR GE8320 - known issues
|
||||
"gc1000", // Vivante GC1000
|
||||
"gc2000", // Vivante GC2000
|
||||
)
|
||||
|
||||
// Known problematic chipsets/hardware (lowercase)
|
||||
private val PROBLEMATIC_CHIPSETS = listOf(
|
||||
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
|
||||
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
|
||||
"mt8768", // MediaTek tablet chip
|
||||
"mp0873", // MediaTek variant
|
||||
"msm8974", // Snapdragon 800/801 with Adreno 330
|
||||
"msm8226", // Snapdragon 400 with Adreno 305
|
||||
"msm8926", // Snapdragon 400 with Adreno 305
|
||||
"apq8084", // Snapdragon 805 (some issues)
|
||||
)
|
||||
|
||||
// Known problematic device models (lowercase)
|
||||
private val PROBLEMATIC_MODELS = listOf(
|
||||
"sm-t220", // Samsung Tab A7 Lite
|
||||
"sm-t225", // Samsung Tab A7 Lite LTE
|
||||
"hammerhead", // Nexus 5 (Adreno 330)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Override Flutter shell args to disable Impeller on problematic devices.
|
||||
* This is called before the Flutter engine starts.
|
||||
*/
|
||||
override fun getFlutterShellArgs(): FlutterShellArgs {
|
||||
val args = super.getFlutterShellArgs()
|
||||
|
||||
if (shouldDisableImpeller()) {
|
||||
// Log for debugging
|
||||
android.util.Log.i("SpotiFLAC", "Legacy/problematic GPU detected: Disabling Impeller for ${Build.MODEL}")
|
||||
android.util.Log.i("SpotiFLAC", "Device: ${Build.MANUFACTURER} ${Build.MODEL}, SDK: ${Build.VERSION.SDK_INT}")
|
||||
android.util.Log.i("SpotiFLAC", "Hardware: ${Build.HARDWARE}, Board: ${Build.BOARD}")
|
||||
|
||||
// Disable Impeller, forcing Skia renderer
|
||||
args.add("--enable-impeller=false")
|
||||
} else {
|
||||
android.util.Log.i("SpotiFLAC", "Using Impeller renderer for ${Build.MODEL}")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device should use Skia instead of Impeller.
|
||||
* Returns true for devices with old/problematic GPUs or old Android versions.
|
||||
*/
|
||||
private fun shouldDisableImpeller(): Boolean {
|
||||
val hardware = Build.HARDWARE.lowercase(Locale.ROOT)
|
||||
val board = Build.BOARD.lowercase(Locale.ROOT)
|
||||
val model = Build.MODEL.lowercase(Locale.ROOT)
|
||||
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
||||
|
||||
// 1. Check for explicitly problematic device models
|
||||
for (problematicModel in PROBLEMATIC_MODELS) {
|
||||
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
||||
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for problematic chipsets
|
||||
for (chipset in PROBLEMATIC_CHIPSETS) {
|
||||
if (hardware.contains(chipset) || board.contains(chipset)) {
|
||||
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
|
||||
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
|
||||
// For older Android, check GPU renderer if available
|
||||
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||
|
||||
// Check for known problematic GPUs
|
||||
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||
if (gpuRenderer.contains(pattern)) {
|
||||
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// For very old Android (< 8.0), always use Skia as Vulkan support is spotty
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. For Android 10+, still check for known problematic GPUs
|
||||
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||
if (gpuRenderer.contains(pattern)) {
|
||||
android.util.Log.i("SpotiFLAC", "Matched problematic GPU: $pattern")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get GPU renderer string.
|
||||
* Note: This may return empty on some devices before OpenGL context is created.
|
||||
*/
|
||||
private fun getGpuRenderer(): String {
|
||||
return try {
|
||||
// This might not work before GL context is created,
|
||||
// but worth trying for additional detection
|
||||
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Update the intent so receive_sharing_intent can access the new data
|
||||
@@ -278,9 +409,10 @@ class MainActivity: FlutterActivity() {
|
||||
"searchDeezerAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -767,37 +899,5 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FFmpeg method channel
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FFMPEG_CHANNEL).setMethodCallHandler { call, result ->
|
||||
scope.launch {
|
||||
try {
|
||||
when (call.method) {
|
||||
"execute" -> {
|
||||
val command = call.argument<String>("command") ?: ""
|
||||
val session = withContext(Dispatchers.IO) {
|
||||
FFmpegKit.execute(command)
|
||||
}
|
||||
val returnCode = session.returnCode
|
||||
val output = session.output ?: ""
|
||||
result.success(mapOf(
|
||||
"success" to ReturnCode.isSuccess(returnCode),
|
||||
"returnCode" to (returnCode?.value ?: -1),
|
||||
"output" to output
|
||||
))
|
||||
}
|
||||
"getVersion" -> {
|
||||
val session = withContext(Dispatchers.IO) {
|
||||
FFmpegKit.execute("-version")
|
||||
}
|
||||
result.success(session.output ?: "unknown")
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("FFMPEG_ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
||||
class FFmpegServiceIOS {
|
||||
/// Execute FFmpeg command and return result
|
||||
static Future<FFmpegResultIOS> _execute(String command) async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
final output = await session.getOutput() ?? '';
|
||||
return FFmpegResultIOS(
|
||||
success: ReturnCode.isSuccess(returnCode),
|
||||
returnCode: returnCode?.getValue() ?? -1,
|
||||
output: output,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('FFmpeg execute error: $e');
|
||||
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert M4A (DASH segments) to FLAC
|
||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
} catch (_) {}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC to MP3
|
||||
/// If deleteOriginal is true, deletes the FLAC file after conversion
|
||||
static Future<String?> convertFlacToMp3(
|
||||
String inputPath, {
|
||||
String bitrate = '320k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
// Convert in same folder, just change extension
|
||||
final outputPath = inputPath.replaceAll('.flac', '.mp3');
|
||||
|
||||
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
// Delete original FLAC if requested
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC to M4A
|
||||
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
||||
final dir = File(inputPath).parent.path;
|
||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||
await Directory(outputDir).create(recursive: true);
|
||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
||||
|
||||
String command;
|
||||
if (codec == 'alac') {
|
||||
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
} else {
|
||||
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
}
|
||||
|
||||
final result = await _execute(command);
|
||||
if (result.success) return outputPath;
|
||||
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed cover art to FLAC file
|
||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
||||
final tempOutput = '$flacPath.tmp';
|
||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(flacPath).delete();
|
||||
await File(tempOutput).rename(flacPath);
|
||||
return flacPath;
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace file after cover embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) await tempFile.delete();
|
||||
} catch (_) {}
|
||||
|
||||
_log.e('Cover embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover art to FLAC file
|
||||
/// Returns the file path on success, null on failure
|
||||
static Future<String?> embedMetadata({
|
||||
required String flacPath,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempOutput = '$flacPath.tmp';
|
||||
|
||||
// Construct command
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$flacPath" ');
|
||||
|
||||
// Add cover input if available
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
// Map audio stream
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
|
||||
// Map cover stream if available
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v copy ');
|
||||
cmdBuffer.write('-disposition:v attached_pic ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
|
||||
// Copy audio codec (don't re-encode)
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
// Add text metadata
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
// Sanitize value: escape double quotes
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg command: $command');
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(flacPath).delete();
|
||||
await File(tempOutput).rename(flacPath);
|
||||
return flacPath;
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace file after metadata embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp file if exists
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover art to MP3 file using ID3v2 tags
|
||||
/// Returns the file path on success, null on failure
|
||||
static Future<String?> embedMetadataToMp3({
|
||||
required String mp3Path,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempOutput = '$mp3Path.tmp';
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$mp3Path" ');
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v:0 copy ');
|
||||
cmdBuffer.write('-id3v2_version 3 ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
if (metadata != null) {
|
||||
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
|
||||
final id3Metadata = _convertToId3Tags(metadata);
|
||||
id3Metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg MP3 embed command: $command');
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(mp3Path).delete();
|
||||
await File(tempOutput).rename(mp3Path);
|
||||
_log.d('MP3 metadata embedded successfully');
|
||||
return mp3Path;
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace MP3 file after metadata embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
|
||||
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
||||
final id3Map = <String, String>{};
|
||||
|
||||
for (final entry in vorbisMetadata.entries) {
|
||||
final key = entry.key.toUpperCase();
|
||||
final value = entry.value;
|
||||
|
||||
// Map Vorbis comments to ID3v2 frame names
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
id3Map['artist'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
id3Map['album'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
id3Map['album_artist'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
id3Map['track'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
id3Map['disc'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
case 'YEAR':
|
||||
id3Map['date'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
id3Map['TSRC'] = value; // ID3v2 ISRC frame
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
id3Map['lyrics'] = value;
|
||||
break;
|
||||
default:
|
||||
// Pass through other tags as-is
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return id3Map;
|
||||
}
|
||||
|
||||
/// Check if FFmpeg is available
|
||||
static Future<bool> isAvailable() async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute('-version');
|
||||
final returnCode = await session.getReturnCode();
|
||||
return ReturnCode.isSuccess(returnCode);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get FFmpeg version info
|
||||
static Future<String?> getVersion() async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute('-version');
|
||||
return await session.getOutput();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegResultIOS {
|
||||
final bool success;
|
||||
final int returnCode;
|
||||
final String output;
|
||||
|
||||
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
||||
}
|
||||
+90
-307
@@ -3,7 +3,6 @@ package gobackend
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -12,79 +11,29 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
regions []string
|
||||
lastAPICallTime time.Time
|
||||
apiCallCount int
|
||||
apiCallResetTime time.Time
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
globalAmazonDownloader *AmazonDownloader
|
||||
amazonDownloaderOnce sync.Once
|
||||
amazonRateLimitMu sync.Mutex
|
||||
)
|
||||
|
||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||
type DoubleDoubleSubmitResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type DoubleDoubleStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
FriendlyStatus string `json:"friendlyStatus"`
|
||||
URL string `json:"url"`
|
||||
Current struct {
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
} `json:"current"`
|
||||
}
|
||||
|
||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||
|
||||
foundFirst := strings.Split(normFound, ",")[0]
|
||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||
foundFirst = strings.TrimSpace(foundFirst)
|
||||
|
||||
if expectedFirst == foundFirst {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||
return true
|
||||
}
|
||||
|
||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||
foundASCII := amazonIsASCIIString(foundArtist)
|
||||
if expectedASCII != foundASCII {
|
||||
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||
type AfkarXYZResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
DirectLink string `json:"direct_link"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func amazonIsASCIIString(s string) bool {
|
||||
@@ -99,234 +48,64 @@ func amazonIsASCIIString(s string) bool {
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||
regions: []string{"us", "eu"}, // Same regions as PC
|
||||
apiCallResetTime: time.Now(),
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||
}
|
||||
})
|
||||
return globalAmazonDownloader
|
||||
}
|
||||
|
||||
// waitForRateLimit implements rate limiting similar to PC version
|
||||
func (a *AmazonDownloader) waitForRateLimit() {
|
||||
amazonRateLimitMu.Lock()
|
||||
defer amazonRateLimitMu.Unlock()
|
||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
|
||||
now := time.Now()
|
||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||
|
||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = now
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
if a.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = time.Now()
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if !a.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
a.lastAPICallTime = time.Now()
|
||||
a.apiCallCount++
|
||||
}
|
||||
|
||||
// Uses same service as PC version (doubledouble.top)
|
||||
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
||||
// DoubleDouble service regions (same as PC)
|
||||
// Format: https://{region}.doubledouble.top
|
||||
var apis []string
|
||||
for _, region := range a.regions {
|
||||
apis = append(apis, fmt.Sprintf("https://%s.doubledouble.top", region))
|
||||
}
|
||||
return apis
|
||||
}
|
||||
|
||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||
// This uses submit → poll → download mechanism
|
||||
// Internal function - not exported to gomobile
|
||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) {
|
||||
var lastError error
|
||||
|
||||
for _, region := range a.regions {
|
||||
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
a.waitForRateLimit()
|
||||
|
||||
req, err := http.NewRequest("GET", submitURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
fmt.Println("[Amazon] Submitting download request...")
|
||||
|
||||
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
||||
var resp *http.Response
|
||||
maxRetries := 3
|
||||
for retry := 0; retry < maxRetries; retry++ {
|
||||
resp, err = a.client.Do(req)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||
break
|
||||
}
|
||||
|
||||
if resp.StatusCode == 429 { // Too Many Requests
|
||||
resp.Body.Close()
|
||||
if retry < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
lastError = fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||
break
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||
break
|
||||
}
|
||||
|
||||
// Success - break retry loop
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil || lastError != nil {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var submitResp DoubleDoubleSubmitResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !submitResp.Success || submitResp.ID == "" {
|
||||
lastError = fmt.Errorf("submit request failed")
|
||||
continue
|
||||
}
|
||||
|
||||
downloadID := submitResp.ID
|
||||
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
||||
|
||||
// Step 2: Poll for completion
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
fmt.Println("[Amazon] Waiting for download to complete...")
|
||||
|
||||
maxWait := 300 * time.Second // 5 minutes max wait
|
||||
elapsed := time.Duration(0)
|
||||
pollInterval := 3 * time.Second
|
||||
|
||||
for elapsed < maxWait {
|
||||
time.Sleep(pollInterval)
|
||||
elapsed += pollInterval
|
||||
|
||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
statusResp, err := a.client.Do(statusReq)
|
||||
if err != nil {
|
||||
fmt.Printf("\r[Amazon] Status check failed, retrying...")
|
||||
continue
|
||||
}
|
||||
|
||||
if statusResp.StatusCode != 200 {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var status DoubleDoubleStatusResponse
|
||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\r[Amazon] Invalid JSON response, retrying...")
|
||||
continue
|
||||
}
|
||||
statusResp.Body.Close()
|
||||
|
||||
if status.Status == "done" {
|
||||
fmt.Println("\n[Amazon] Download ready!")
|
||||
|
||||
fileURL := status.URL
|
||||
if strings.HasPrefix(fileURL, "./") {
|
||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||
} else if strings.HasPrefix(fileURL, "/") {
|
||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
||||
}
|
||||
|
||||
trackName := status.Current.Name
|
||||
artist := status.Current.Artist
|
||||
|
||||
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||
return fileURL, trackName, artist, nil
|
||||
|
||||
} else if status.Status == "error" {
|
||||
errorMsg := status.FriendlyStatus
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Unknown error"
|
||||
}
|
||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
||||
break
|
||||
} else {
|
||||
// Still processing
|
||||
friendlyStatus := status.FriendlyStatus
|
||||
if friendlyStatus == "" {
|
||||
friendlyStatus = status.Status
|
||||
}
|
||||
fmt.Printf("\r[Amazon] %s...", friendlyStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed >= maxWait {
|
||||
lastError = fmt.Errorf("download timeout")
|
||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
||||
continue
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
||||
}
|
||||
var apiResp AfkarXYZResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
||||
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
|
||||
}
|
||||
|
||||
fileName := apiResp.Data.FileName
|
||||
if fileName == "" {
|
||||
fileName = "track.flac"
|
||||
}
|
||||
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
fileName = reg.ReplaceAllString(fileName, "")
|
||||
|
||||
GoLog("[Amazon] AfkarXYZ returned: %s (%.2f MB)\n", fileName, float64(apiResp.Data.FileSize)/(1024*1024))
|
||||
|
||||
return apiResp.Data.DirectLink, fileName, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -378,7 +157,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
// Flush buffer before checking for errors
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
@@ -398,13 +176,12 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
// Verify file size if Content-Length was provided
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||
GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -422,7 +199,6 @@ type AmazonDownloadResult struct {
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// Uses DoubleDouble service (same as PC version)
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
@@ -434,8 +210,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
@@ -458,21 +233,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Download using DoubleDouble service (same as PC)
|
||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||
// Download using AfkarXYZ API
|
||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||
}
|
||||
|
||||
// Verify artist matches
|
||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||
}
|
||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||
|
||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
@@ -519,14 +288,13 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Log track info from DoubleDouble (for debugging)
|
||||
if trackName != "" && artistName != "" {
|
||||
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||
}
|
||||
|
||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||
actualTrackNum := req.TrackNumber
|
||||
actualDiscNum := req.DiscNumber
|
||||
actualDate := req.ReleaseDate
|
||||
actualAlbum := req.AlbumName
|
||||
actualTitle := req.TrackName
|
||||
actualArtist := req.ArtistName
|
||||
|
||||
if metaErr == nil && existingMeta != nil {
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
@@ -537,40 +305,55 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
actualDiscNum = existingMeta.DiscNumber
|
||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||
}
|
||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||
actualDate = existingMeta.Date
|
||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||
}
|
||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||
actualAlbum = existingMeta.Album
|
||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||
}
|
||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||
}
|
||||
|
||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
||||
// But preserve track/disc numbers from file if they were better
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
Title: actualTitle,
|
||||
Artist: actualArtist,
|
||||
Album: actualAlbum,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
Date: actualDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre, // From Deezer album metadata
|
||||
Label: req.Label, // From Deezer album metadata
|
||||
Copyright: req.Copyright, // From Deezer album metadata
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
// Use cover data from parallel fetch
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
existingCover, coverErr := ExtractCoverArt(outputPath)
|
||||
if coverErr == nil && len(existingCover) > 0 {
|
||||
coverData = existingCover
|
||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
|
||||
}
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed" // default
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
@@ -587,14 +370,14 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
}
|
||||
|
||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
|
||||
+288
-97
@@ -55,7 +55,7 @@ func GetDeezerClient() *DeezerClient {
|
||||
type deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"` // in seconds
|
||||
Duration int `json:"duration"`
|
||||
TrackPosition int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
@@ -121,7 +121,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
AlbumArtist: track.Artist.Name,
|
||||
DurationMS: track.Duration * 1000,
|
||||
Images: albumImage,
|
||||
ReleaseDate: releaseDate, // Added this
|
||||
ReleaseDate: releaseDate,
|
||||
TrackNumber: track.TrackPosition,
|
||||
DiscNumber: track.DiskNumber,
|
||||
ExternalURL: track.Link,
|
||||
@@ -182,11 +182,38 @@ type deezerPlaylistFull struct {
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||
|
||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
albumLimit := 5
|
||||
playlistLimit := 5
|
||||
|
||||
if filter != "" {
|
||||
switch filter {
|
||||
case "track":
|
||||
trackLimit = 50
|
||||
artistLimit = 0
|
||||
albumLimit = 0
|
||||
playlistLimit = 0
|
||||
case "artist":
|
||||
trackLimit = 0
|
||||
artistLimit = 20
|
||||
albumLimit = 0
|
||||
playlistLimit = 0
|
||||
case "album":
|
||||
trackLimit = 0
|
||||
artistLimit = 0
|
||||
albumLimit = 20
|
||||
playlistLimit = 0
|
||||
case "playlist":
|
||||
trackLimit = 0
|
||||
artistLimit = 0
|
||||
albumLimit = 0
|
||||
playlistLimit = 20
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d:%d:%s", query, trackLimit, artistLimit, albumLimit, playlistLimit, filter)
|
||||
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
@@ -197,69 +224,189 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
result := &SearchAllResult{
|
||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
|
||||
}
|
||||
|
||||
// Search tracks - NO ISRC fetch for performance
|
||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||
if trackLimit > 0 {
|
||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||
|
||||
var trackResp struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||
}
|
||||
|
||||
if trackResp.Error != nil {
|
||||
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||
|
||||
for _, track := range trackResp.Data {
|
||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||
}
|
||||
|
||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||
|
||||
var artistResp struct {
|
||||
Data []deezerArtist `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||
if artistResp.Error != nil {
|
||||
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||
} else {
|
||||
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||
for _, artist := range artistResp.Data {
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||
Name: artist.Name,
|
||||
Images: c.getBestArtistImage(artist),
|
||||
Followers: artist.NbFan,
|
||||
Popularity: 0,
|
||||
})
|
||||
}
|
||||
var trackResp struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||
}
|
||||
|
||||
if trackResp.Error != nil {
|
||||
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||
|
||||
for _, track := range trackResp.Data {
|
||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||
if artistLimit > 0 {
|
||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||
|
||||
var artistResp struct {
|
||||
Data []deezerArtist `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||
if artistResp.Error != nil {
|
||||
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||
} else {
|
||||
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||
for _, artist := range artistResp.Data {
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||
Name: artist.Name,
|
||||
Images: c.getBestArtistImage(artist),
|
||||
Followers: artist.NbFan,
|
||||
Popularity: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if albumLimit > 0 {
|
||||
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
|
||||
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
|
||||
|
||||
var albumResp struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
RecordType string `json:"record_type"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
} `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := c.getJSON(ctx, albumURL, &albumResp); err == nil {
|
||||
if albumResp.Error != nil {
|
||||
GoLog("[Deezer] Album API error: type=%s, code=%d, message=%s\n", albumResp.Error.Type, albumResp.Error.Code, albumResp.Error.Message)
|
||||
} else {
|
||||
GoLog("[Deezer] Got %d albums from API\n", len(albumResp.Data))
|
||||
for _, album := range albumResp.Data {
|
||||
coverURL := album.CoverXL
|
||||
if coverURL == "" {
|
||||
coverURL = album.CoverBig
|
||||
}
|
||||
if coverURL == "" {
|
||||
coverURL = album.CoverMedium
|
||||
}
|
||||
if coverURL == "" {
|
||||
coverURL = album.Cover
|
||||
}
|
||||
|
||||
albumType := album.RecordType
|
||||
if albumType == "compile" {
|
||||
albumType = "compilation"
|
||||
}
|
||||
|
||||
result.Albums = append(result.Albums, SearchAlbumResult{
|
||||
ID: fmt.Sprintf("deezer:%d", album.ID),
|
||||
Name: album.Title,
|
||||
Artists: album.Artist.Name,
|
||||
Images: coverURL,
|
||||
ReleaseDate: album.ReleaseDate,
|
||||
TotalTracks: album.NbTracks,
|
||||
AlbumType: albumType,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Album search failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if playlistLimit > 0 {
|
||||
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
|
||||
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
|
||||
|
||||
var playlistResp struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Picture string `json:"picture"`
|
||||
PictureMedium string `json:"picture_medium"`
|
||||
PictureBig string `json:"picture_big"`
|
||||
PictureXL string `json:"picture_xl"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
User struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := c.getJSON(ctx, playlistURL, &playlistResp); err == nil {
|
||||
if playlistResp.Error != nil {
|
||||
GoLog("[Deezer] Playlist API error: type=%s, code=%d, message=%s\n", playlistResp.Error.Type, playlistResp.Error.Code, playlistResp.Error.Message)
|
||||
} else {
|
||||
GoLog("[Deezer] Got %d playlists from API\n", len(playlistResp.Data))
|
||||
for _, playlist := range playlistResp.Data {
|
||||
pictureURL := playlist.PictureXL
|
||||
if pictureURL == "" {
|
||||
pictureURL = playlist.PictureBig
|
||||
}
|
||||
if pictureURL == "" {
|
||||
pictureURL = playlist.PictureMedium
|
||||
}
|
||||
if pictureURL == "" {
|
||||
pictureURL = playlist.Picture
|
||||
}
|
||||
|
||||
result.Playlists = append(result.Playlists, SearchPlaylistResult{
|
||||
ID: fmt.Sprintf("deezer:%d", playlist.ID),
|
||||
Name: playlist.Title,
|
||||
Owner: playlist.User.Name,
|
||||
Images: pictureURL,
|
||||
TotalTracks: playlist.NbTracks,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Playlist search failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
@@ -271,7 +418,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetTrack fetches a single track by Deezer ID
|
||||
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||
|
||||
@@ -285,7 +431,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ISRC is fetched in parallel for better performance
|
||||
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||
@@ -311,7 +456,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
artistName = strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// Extract genres as comma-separated string
|
||||
var genres []string
|
||||
for _, g := range album.Genres.Data {
|
||||
if g.Name != "" {
|
||||
@@ -327,24 +471,55 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
Artists: artistName,
|
||||
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
||||
Images: albumImage,
|
||||
Genre: genreStr, // From Deezer album
|
||||
Label: album.Label, // From Deezer album
|
||||
Genre: genreStr,
|
||||
Label: album.Label,
|
||||
}
|
||||
|
||||
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||
allTracks := album.Tracks.Data
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||
// Normalize record_type (Deezer uses "compile" instead of "compilation")
|
||||
if album.NbTracks > len(allTracks) {
|
||||
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
|
||||
|
||||
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
|
||||
|
||||
for len(allTracks) < album.NbTracks {
|
||||
var tracksResp struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
||||
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
if len(tracksResp.Data) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
allTracks = append(allTracks, tracksResp.Data...)
|
||||
|
||||
if tracksResp.Next == "" {
|
||||
break
|
||||
}
|
||||
tracksURL = tracksResp.Next
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
|
||||
}
|
||||
|
||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||
albumType := album.RecordType
|
||||
if albumType == "compile" {
|
||||
albumType = "compilation"
|
||||
}
|
||||
|
||||
for i, track := range album.Tracks.Data {
|
||||
for i, track := range allTracks {
|
||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||
isrc := isrcMap[trackIDStr]
|
||||
|
||||
// Use track position from API, fallback to index+1 if not provided
|
||||
trackNum := track.TrackPosition
|
||||
if trackNum == 0 {
|
||||
trackNum = i + 1
|
||||
@@ -392,7 +567,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Fetch artist info
|
||||
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
||||
var artist deezerArtistFull
|
||||
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
||||
@@ -407,7 +581,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
Popularity: 0,
|
||||
}
|
||||
|
||||
// Fetch artist albums
|
||||
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
||||
var albumsResp struct {
|
||||
Data []struct {
|
||||
@@ -419,7 +592,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||
RecordType string `json:"record_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -491,10 +664,43 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
||||
info.Owner.Name = playlist.Title
|
||||
info.Owner.Images = playlistImage
|
||||
|
||||
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
||||
allTracks := playlist.Tracks.Data
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
||||
for _, track := range playlist.Tracks.Data {
|
||||
if playlist.NbTracks > len(allTracks) {
|
||||
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
|
||||
|
||||
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
|
||||
|
||||
for len(allTracks) < playlist.NbTracks {
|
||||
var tracksResp struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
||||
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
if len(tracksResp.Data) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
allTracks = append(allTracks, tracksResp.Data...)
|
||||
|
||||
if tracksResp.Next == "" {
|
||||
break
|
||||
}
|
||||
tracksURL = tracksResp.Next
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
|
||||
}
|
||||
|
||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||
for _, track := range allTracks {
|
||||
albumImage := track.Album.CoverXL
|
||||
if albumImage == "" {
|
||||
albumImage = track.Album.CoverBig
|
||||
@@ -565,7 +771,6 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
||||
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||
result := make(map[string]string, len(tracks))
|
||||
var resultMu sync.Mutex
|
||||
@@ -604,7 +809,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
return result
|
||||
}
|
||||
|
||||
// Use semaphore to limit concurrent requests
|
||||
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -626,7 +830,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
return
|
||||
}
|
||||
|
||||
// Store in result and cache
|
||||
resultMu.Lock()
|
||||
result[trackIDStr] = fullTrack.ISRC
|
||||
resultMu.Unlock()
|
||||
@@ -641,7 +844,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
return result
|
||||
}
|
||||
|
||||
// Use this when you need ISRC for download
|
||||
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||
c.cacheMu.RLock()
|
||||
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||
@@ -702,11 +904,10 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||
}
|
||||
|
||||
type AlbumExtendedMetadata struct {
|
||||
Genre string // Comma-separated list of genres
|
||||
Label string // Record label name
|
||||
Genre string
|
||||
Label string
|
||||
}
|
||||
|
||||
// Uses the album ID from a track to fetch extended metadata
|
||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||
if albumID == "" {
|
||||
return nil, fmt.Errorf("empty album ID")
|
||||
@@ -751,7 +952,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetTrackAlbumID fetches the album ID for a Deezer track
|
||||
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
|
||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||
|
||||
@@ -763,7 +963,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str
|
||||
return fmt.Sprintf("%d", track.Album.ID), nil
|
||||
}
|
||||
|
||||
// This is a convenience function that first gets the album ID, then fetches album metadata
|
||||
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
|
||||
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
||||
if err != nil {
|
||||
@@ -773,29 +972,22 @@ func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID
|
||||
return c.GetAlbumExtendedMetadata(ctx, albumID)
|
||||
}
|
||||
|
||||
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
|
||||
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
if isrc == "" {
|
||||
return nil, fmt.Errorf("empty ISRC")
|
||||
}
|
||||
|
||||
// First, search for track by ISRC
|
||||
track, err := c.SearchByISRC(ctx, isrc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
|
||||
}
|
||||
|
||||
// SpotifyID contains "deezer:123" format, extract the ID
|
||||
deezerID := track.SpotifyID
|
||||
if strings.HasPrefix(deezerID, "deezer:") {
|
||||
deezerID = strings.TrimPrefix(deezerID, "deezer:")
|
||||
}
|
||||
deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:")
|
||||
|
||||
if deezerID == "" {
|
||||
return nil, fmt.Errorf("track found but no Deezer ID")
|
||||
}
|
||||
|
||||
// Then fetch extended metadata using the Deezer track ID
|
||||
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
||||
}
|
||||
|
||||
@@ -825,7 +1017,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
return json.Unmarshal(body, dst)
|
||||
}
|
||||
|
||||
// parseDeezerURL is internal function, returns type and ID
|
||||
func parseDeezerURL(input string) (string, string, error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
|
||||
+1
-18
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
|
||||
type ISRCIndex struct {
|
||||
index map[string]string // ISRC (uppercase) -> file path
|
||||
outputDir string
|
||||
@@ -25,8 +24,6 @@ var (
|
||||
isrcIndexTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// GetISRCIndex returns or builds an ISRC index for the given directory
|
||||
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
|
||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
// Fast path: check cache first
|
||||
isrcIndexCacheMu.RLock()
|
||||
@@ -56,7 +53,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
return buildISRCIndex(outputDir)
|
||||
}
|
||||
|
||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
idx := &ISRCIndex{
|
||||
index: make(map[string]string),
|
||||
@@ -91,7 +87,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||
|
||||
isrcIndexCacheMu.Lock()
|
||||
@@ -113,7 +109,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||
return path, exists
|
||||
}
|
||||
|
||||
// remove deletes an ISRC entry from the index (internal use)
|
||||
func (idx *ISRCIndex) remove(isrc string) {
|
||||
if isrc == "" {
|
||||
return
|
||||
@@ -125,14 +120,11 @@ func (idx *ISRCIndex) remove(isrc string) {
|
||||
delete(idx.index, strings.ToUpper(isrc))
|
||||
}
|
||||
|
||||
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||
// Returns filepath if found, empty string if not found
|
||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||
path, _ := idx.lookup(isrc)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Add adds a new ISRC to the index (call after successful download)
|
||||
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||
if isrc == "" || filePath == "" {
|
||||
return
|
||||
@@ -144,15 +136,12 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||
idx.index[strings.ToUpper(isrc)] = filePath
|
||||
}
|
||||
|
||||
// InvalidateCache clears the ISRC index cache for a directory
|
||||
func InvalidateISRCCache(outputDir string) {
|
||||
isrcIndexCacheMu.Lock()
|
||||
delete(isrcIndexCache, outputDir)
|
||||
isrcIndexCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
||||
// Uses ISRC index for fast lookup
|
||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
if isrc == "" || outputDir == "" {
|
||||
return "", false
|
||||
@@ -173,13 +162,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
return filePath, true
|
||||
}
|
||||
|
||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||
return filepath, nil
|
||||
}
|
||||
|
||||
// CheckFileExists checks if a file with the given name exists
|
||||
func CheckFileExists(filePath string) bool {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
@@ -188,7 +175,6 @@ func CheckFileExists(filePath string) bool {
|
||||
return !info.IsDir() && info.Size() > 0
|
||||
}
|
||||
|
||||
// FileExistenceResult represents the result of checking if a file exists
|
||||
type FileExistenceResult struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Exists bool `json:"exists"`
|
||||
@@ -249,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
|
||||
// PreBuildISRCIndex pre-builds the ISRC index for a directory
|
||||
// Call this when app starts or when entering album/playlist screen
|
||||
func PreBuildISRCIndex(outputDir string) error {
|
||||
if outputDir == "" {
|
||||
return fmt.Errorf("output directory is required")
|
||||
@@ -260,7 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||
if outputDir == "" || isrc == "" || filePath == "" {
|
||||
return
|
||||
|
||||
+11
-70
@@ -148,17 +148,16 @@ type DownloadRequest struct {
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadResponse represents the result of a download
|
||||
type DownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||
Service string `json:"service,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
@@ -172,6 +171,7 @@ type DownloadResponse struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResult struct {
|
||||
@@ -185,6 +185,7 @@ type DownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func DownloadTrack(requestJSON string) (string, error) {
|
||||
@@ -222,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
TrackNumber: tidalResult.TrackNumber,
|
||||
DiscNumber: tidalResult.DiscNumber,
|
||||
ISRC: tidalResult.ISRC,
|
||||
LyricsLRC: tidalResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = tidalErr
|
||||
@@ -317,6 +319,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
@@ -380,6 +383,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: tidalResult.TrackNumber,
|
||||
DiscNumber: tidalResult.DiscNumber,
|
||||
ISRC: tidalResult.ISRC,
|
||||
LyricsLRC: tidalResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||
@@ -452,6 +456,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
@@ -480,6 +485,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
@@ -631,14 +637,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
|
||||
}
|
||||
|
||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
// If filePath is provided, ONLY check file - don't fallback to online
|
||||
// This allows Flutter to distinguish between "from file" vs "from online"
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
// File has no lyrics - return empty, let Flutter call again without filePath
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -649,7 +652,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Return special marker for instrumental tracks
|
||||
if lyricsData.Instrumental {
|
||||
return "[instrumental:true]", nil
|
||||
}
|
||||
@@ -716,12 +718,12 @@ func ClearTrackIDCache() {
|
||||
ClearTrackCache()
|
||||
}
|
||||
|
||||
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||
func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -735,8 +737,6 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error)
|
||||
}
|
||||
|
||||
// GetDeezerMetadata fetches metadata from Deezer URL or ID
|
||||
// resourceType: track, album, artist, playlist
|
||||
// resourceID: Deezer ID
|
||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -770,7 +770,6 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ParseDeezerURLExport parses a Deezer URL and returns type and ID
|
||||
func ParseDeezerURLExport(url string) (string, error) {
|
||||
resourceType, resourceID, err := parseDeezerURL(url)
|
||||
if err != nil {
|
||||
@@ -790,9 +789,6 @@ func ParseDeezerURLExport(url string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
|
||||
// trackID: Deezer track ID (will look up album ID from track)
|
||||
// Returns JSON with genre, label fields
|
||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("empty track ID")
|
||||
@@ -821,7 +817,6 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SearchDeezerByISRC searches for a track by ISRC on Deezer
|
||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -949,9 +944,6 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||
}
|
||||
|
||||
// CheckAvailabilityByPlatformID checks track availability using any platform as source
|
||||
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube"
|
||||
// entityType: "song" or "album"
|
||||
// entityID: the ID on that platform
|
||||
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
|
||||
@@ -967,19 +959,16 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetSpotifyIDFromDeezerTrack converts a Deezer track ID to Spotify track ID
|
||||
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
return client.GetSpotifyIDFromDeezer(deezerTrackID)
|
||||
}
|
||||
|
||||
// GetTidalURLFromDeezerTrack converts a Deezer track ID to Tidal URL
|
||||
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
return client.GetTidalURLFromDeezer(deezerTrackID)
|
||||
}
|
||||
|
||||
// GetAmazonURLFromDeezerTrack converts a Deezer track ID to Amazon Music URL
|
||||
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
return client.GetAmazonURLFromDeezer(deezerTrackID)
|
||||
@@ -1029,7 +1018,6 @@ func errorResponse(msg string) (string, error) {
|
||||
|
||||
// ==================== EXTENSION SYSTEM ====================
|
||||
|
||||
// InitExtensionSystem initializes the extension system with directories
|
||||
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||
manager := GetExtensionManager()
|
||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||
@@ -1044,7 +1032,6 @@ func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadExtensionsFromDir loads all extensions from a directory
|
||||
func LoadExtensionsFromDir(dirPath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
|
||||
@@ -1066,7 +1053,6 @@ func LoadExtensionsFromDir(dirPath string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// LoadExtensionFromPath loads a single extension from a .spotiflac-ext file
|
||||
func LoadExtensionFromPath(filePath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.LoadExtensionFromFile(filePath)
|
||||
@@ -1096,19 +1082,16 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// UnloadExtensionByID unloads an extension
|
||||
func UnloadExtensionByID(extensionID string) error {
|
||||
manager := GetExtensionManager()
|
||||
return manager.UnloadExtension(extensionID)
|
||||
}
|
||||
|
||||
// RemoveExtensionByID completely removes an extension (unload + delete files)
|
||||
func RemoveExtensionByID(extensionID string) error {
|
||||
manager := GetExtensionManager()
|
||||
return manager.RemoveExtension(extensionID)
|
||||
}
|
||||
|
||||
// UpgradeExtensionFromPath upgrades an existing extension from a new package file
|
||||
func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.UpgradeExtension(filePath)
|
||||
@@ -1137,25 +1120,21 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CheckExtensionUpgradeFromPath checks if a package file is an upgrade for an existing extension
|
||||
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
return manager.CheckExtensionUpgradeJSON(filePath)
|
||||
}
|
||||
|
||||
// GetInstalledExtensions returns all installed extensions as JSON
|
||||
func GetInstalledExtensions() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
return manager.GetInstalledExtensionsJSON()
|
||||
}
|
||||
|
||||
// SetExtensionEnabledByID enables or disables an extension
|
||||
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
|
||||
manager := GetExtensionManager()
|
||||
return manager.SetExtensionEnabled(extensionID, enabled)
|
||||
}
|
||||
|
||||
// SetProviderPriorityJSON sets the provider priority order from JSON array
|
||||
func SetProviderPriorityJSON(priorityJSON string) error {
|
||||
var priority []string
|
||||
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
||||
@@ -1166,7 +1145,6 @@ func SetProviderPriorityJSON(priorityJSON string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProviderPriorityJSON returns the provider priority order as JSON
|
||||
func GetProviderPriorityJSON() (string, error) {
|
||||
priority := GetProviderPriority()
|
||||
jsonBytes, err := json.Marshal(priority)
|
||||
@@ -1176,7 +1154,6 @@ func GetProviderPriorityJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetMetadataProviderPriorityJSON sets the metadata provider priority order from JSON array
|
||||
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
||||
var priority []string
|
||||
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
||||
@@ -1187,7 +1164,6 @@ func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadataProviderPriorityJSON returns the metadata provider priority order as JSON
|
||||
func GetMetadataProviderPriorityJSON() (string, error) {
|
||||
priority := GetMetadataProviderPriority()
|
||||
jsonBytes, err := json.Marshal(priority)
|
||||
@@ -1197,7 +1173,6 @@ func GetMetadataProviderPriorityJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetExtensionSettingsJSON returns settings for an extension as JSON
|
||||
func GetExtensionSettingsJSON(extensionID string) (string, error) {
|
||||
store := GetExtensionSettingsStore()
|
||||
settings := store.GetAll(extensionID)
|
||||
@@ -1210,7 +1185,6 @@ func GetExtensionSettingsJSON(extensionID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetExtensionSettingsJSON sets settings for an extension from JSON
|
||||
func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
|
||||
@@ -1226,7 +1200,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
||||
return manager.InitializeExtension(extensionID, settings)
|
||||
}
|
||||
|
||||
// SearchTracksWithExtensionsJSON searches all extension metadata providers
|
||||
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithExtensions(query, limit)
|
||||
@@ -1242,7 +1215,6 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// DownloadWithExtensionsJSON downloads using extension providers with fallback
|
||||
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
@@ -1262,14 +1234,11 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CleanupExtensions unloads all extensions gracefully
|
||||
func CleanupExtensions() {
|
||||
manager := GetExtensionManager()
|
||||
manager.UnloadAllExtensions()
|
||||
}
|
||||
|
||||
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
|
||||
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
|
||||
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
result, err := manager.InvokeAction(extensionID, actionName)
|
||||
@@ -1285,7 +1254,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||
req := GetPendingAuthRequest(extensionID)
|
||||
if req == nil {
|
||||
@@ -1306,12 +1274,10 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetExtensionAuthCodeByID sets auth code for an extension (called from Flutter after OAuth callback)
|
||||
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||
SetExtensionAuthCode(extensionID, authCode)
|
||||
}
|
||||
|
||||
// SetExtensionTokensByID sets tokens for an extension
|
||||
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
||||
var expiresAt time.Time
|
||||
if expiresIn > 0 {
|
||||
@@ -1320,12 +1286,10 @@ func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expir
|
||||
SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt)
|
||||
}
|
||||
|
||||
// ClearExtensionPendingAuthByID clears pending auth request for an extension
|
||||
func ClearExtensionPendingAuthByID(extensionID string) {
|
||||
ClearPendingAuthRequest(extensionID)
|
||||
}
|
||||
|
||||
// IsExtensionAuthenticatedByID checks if an extension is authenticated
|
||||
func IsExtensionAuthenticatedByID(extensionID string) bool {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -1342,7 +1306,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
|
||||
return state.IsAuthenticated
|
||||
}
|
||||
|
||||
// GetAllPendingAuthRequestsJSON returns all pending auth requests
|
||||
func GetAllPendingAuthRequestsJSON() (string, error) {
|
||||
pendingAuthRequestsMu.RLock()
|
||||
defer pendingAuthRequestsMu.RUnlock()
|
||||
@@ -1386,12 +1349,10 @@ func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetFFmpegCommandResultByID sets the result of an FFmpeg command
|
||||
func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) {
|
||||
SetFFmpegCommandResult(commandID, success, output, errorMsg)
|
||||
}
|
||||
|
||||
// GetAllPendingFFmpegCommandsJSON returns all pending FFmpeg commands
|
||||
func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
||||
ffmpegCommandsMu.RLock()
|
||||
defer ffmpegCommandsMu.RUnlock()
|
||||
@@ -1417,8 +1378,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
||||
|
||||
// ==================== EXTENSION CUSTOM SEARCH ====================
|
||||
|
||||
// EnrichTrackWithExtensionJSON enriches track metadata using the source extension
|
||||
// This is called lazily before download starts, allowing extension to fetch real ISRC etc.
|
||||
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
@@ -1449,7 +1408,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CustomSearchWithExtensionJSON performs custom search using an extension
|
||||
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
@@ -1502,7 +1460,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetSearchProvidersJSON returns all extensions that provide custom search
|
||||
func GetSearchProvidersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
providers := manager.GetSearchProviders()
|
||||
@@ -1666,8 +1623,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// FindURLHandlerJSON finds an extension that can handle the given URL
|
||||
// Returns extension ID or empty string if none found
|
||||
func FindURLHandlerJSON(url string) string {
|
||||
manager := GetExtensionManager()
|
||||
handler := manager.FindURLHandler(url)
|
||||
@@ -1677,7 +1632,6 @@ func FindURLHandlerJSON(url string) string {
|
||||
return handler.extension.ID
|
||||
}
|
||||
|
||||
// GetAlbumWithExtensionJSON gets album tracks using an extension
|
||||
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
@@ -1752,7 +1706,6 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetPlaylistWithExtensionJSON gets playlist tracks using an extension
|
||||
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
@@ -1844,7 +1797,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetArtistWithExtensionJSON gets artist info and albums using an extension
|
||||
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
@@ -1928,7 +1880,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetURLHandlersJSON returns all extensions that handle custom URLs
|
||||
func GetURLHandlersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
handlers := manager.GetURLHandlers()
|
||||
@@ -1972,7 +1923,6 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetPostProcessingProvidersJSON returns all extensions that provide post-processing
|
||||
func GetPostProcessingProvidersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
providers := manager.GetPostProcessingProviders()
|
||||
@@ -2005,13 +1955,11 @@ func GetPostProcessingProvidersJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// InitExtensionStoreJSON initializes the extension store with cache directory
|
||||
func InitExtensionStoreJSON(cacheDir string) error {
|
||||
InitExtensionStore(cacheDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStoreExtensionsJSON returns all extensions from the store with installation status
|
||||
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
@@ -2035,7 +1983,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SearchStoreExtensionsJSON searches extensions in the store
|
||||
func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
@@ -2055,7 +2002,6 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetStoreCategoriesJSON returns all available categories
|
||||
func GetStoreCategoriesJSON() (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
@@ -2071,8 +2017,6 @@ func GetStoreCategoriesJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// DownloadStoreExtensionJSON downloads an extension from the store
|
||||
// Returns the path to the downloaded file
|
||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
@@ -2088,7 +2032,6 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
// ClearStoreCacheJSON clears the store cache
|
||||
func ClearStoreCacheJSON() error {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
@@ -2139,12 +2082,10 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
|
||||
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
|
||||
}
|
||||
|
||||
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
|
||||
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ type LoadedExtension struct {
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
// ExtensionManager manages all loaded extensions
|
||||
type ExtensionManager struct {
|
||||
mu sync.RWMutex
|
||||
extensions map[string]*LoadedExtension
|
||||
@@ -283,7 +282,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnloadExtension unloads an extension by ID
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -311,7 +309,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns error if extension not found (gomobile compatible)
|
||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -323,7 +320,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// GetAllExtensions returns all loaded extensions
|
||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -356,7 +352,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadExtensionsFromDirectory scans a directory and loads all valid extensions
|
||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||
var loaded []string
|
||||
var errors []error
|
||||
@@ -456,7 +451,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// RemoveExtension completely removes an extension (unload + delete files)
|
||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
ext, err := m.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
@@ -637,8 +631,6 @@ type ExtensionUpgradeInfo struct {
|
||||
IsInstalled bool `json:"is_installed"`
|
||||
}
|
||||
|
||||
// checkExtensionUpgradeInternal checks if a package file is an upgrade for an existing extension
|
||||
// Internal function that returns struct
|
||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
// Validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
@@ -714,7 +706,6 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter
|
||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
extensions := m.GetAllExtensions()
|
||||
|
||||
@@ -809,8 +800,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== Extension Lifecycle ====================
|
||||
|
||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -923,7 +912,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnloadAllExtensions unloads all extensions gracefully
|
||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
m.mu.Lock()
|
||||
extensionIDs := make([]string, 0, len(m.extensions))
|
||||
@@ -940,7 +928,6 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
GoLog("[Extension] All extensions unloaded\n")
|
||||
}
|
||||
|
||||
// The function is called as extension.<actionName>() and can return a result
|
||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExtensionType represents the type of extension
|
||||
type ExtensionType string
|
||||
|
||||
const (
|
||||
@@ -15,7 +14,6 @@ const (
|
||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||
)
|
||||
|
||||
// SettingType represents the type of a setting field
|
||||
type SettingType string
|
||||
|
||||
const (
|
||||
@@ -26,14 +24,12 @@ const (
|
||||
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||
)
|
||||
|
||||
// ExtensionPermissions defines what resources an extension can access
|
||||
type ExtensionPermissions struct {
|
||||
Network []string `json:"network"` // List of allowed domains
|
||||
Storage bool `json:"storage"` // Whether extension can use storage API
|
||||
File bool `json:"file"` // Whether extension can use file API
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
}
|
||||
|
||||
// ExtensionSetting defines a configurable setting for an extension
|
||||
type ExtensionSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
@@ -42,19 +38,17 @@ type ExtensionSetting struct {
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"` // For select type
|
||||
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
|
||||
Options []string `json:"options,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
// QualityOption represents a quality option for download providers
|
||||
type QualityOption struct {
|
||||
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128")
|
||||
Label string `json:"label"` // Display name (e.g., "MP3 320kbps")
|
||||
Description string `json:"description"` // Optional description (e.g., "Best quality MP3")
|
||||
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// QualitySpecificSetting represents a setting that's specific to a quality option
|
||||
type QualitySpecificSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
@@ -63,49 +57,50 @@ type QualitySpecificSetting struct {
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"` // For select type
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type SearchFilter struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
// SearchBehaviorConfig defines custom search behavior for an extension
|
||||
type SearchBehaviorConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension provides custom search
|
||||
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
|
||||
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
|
||||
Icon string `json:"icon,omitempty"` // Icon for search tab
|
||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
|
||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
||||
Enabled bool `json:"enabled"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
|
||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
|
||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
|
||||
Filters []SearchFilter `json:"filters,omitempty"`
|
||||
}
|
||||
|
||||
// URLHandlerConfig defines custom URL handling for an extension
|
||||
type URLHandlerConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension handles URLs
|
||||
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
|
||||
Enabled bool `json:"enabled"`
|
||||
Patterns []string `json:"patterns,omitempty"`
|
||||
}
|
||||
|
||||
// TrackMatchingConfig defines custom track matching behavior
|
||||
type TrackMatchingConfig struct {
|
||||
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
|
||||
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom"
|
||||
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching
|
||||
CustomMatching bool `json:"customMatching"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
DurationTolerance int `json:"durationTolerance,omitempty"`
|
||||
}
|
||||
|
||||
// PostProcessingHook defines a post-processing hook
|
||||
type PostProcessingHook struct {
|
||||
ID string `json:"id"` // Unique identifier
|
||||
Name string `json:"name"` // Display name
|
||||
Description string `json:"description,omitempty"` // Description
|
||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default
|
||||
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"])
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
|
||||
SupportedFormats []string `json:"supportedFormats,omitempty"`
|
||||
}
|
||||
|
||||
// PostProcessingConfig defines post-processing capabilities
|
||||
type PostProcessingConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension provides post-processing
|
||||
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
|
||||
Enabled bool `json:"enabled"`
|
||||
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
// ExtensionManifest represents the manifest.json of an extension
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
@@ -113,22 +108,21 @@ type ExtensionManifest struct {
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// ManifestValidationError represents a validation error in the manifest
|
||||
type ManifestValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
@@ -138,7 +132,6 @@ func (e *ManifestValidationError) Error() string {
|
||||
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// ParseManifest parses and validates a manifest from JSON bytes
|
||||
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||
var manifest ExtensionManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
@@ -217,7 +210,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasType checks if the extension has a specific type
|
||||
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||
for _, et := range m.Types {
|
||||
if et == t {
|
||||
@@ -227,17 +219,14 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsMetadataProvider returns true if extension provides metadata
|
||||
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||
return m.HasType(ExtensionTypeMetadataProvider)
|
||||
}
|
||||
|
||||
// IsDownloadProvider returns true if extension provides downloads
|
||||
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||
return m.HasType(ExtensionTypeDownloadProvider)
|
||||
}
|
||||
|
||||
// IsDomainAllowed checks if a domain is in the allowed network permissions
|
||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, allowed := range m.Permissions.Network {
|
||||
@@ -256,27 +245,22 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// HasCustomSearch returns true if extension provides custom search
|
||||
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||
}
|
||||
|
||||
// HasCustomMatching returns true if extension provides custom track matching
|
||||
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||
}
|
||||
|
||||
// HasPostProcessing returns true if extension provides post-processing
|
||||
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||
}
|
||||
|
||||
// HasURLHandler returns true if extension handles custom URLs
|
||||
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||
}
|
||||
|
||||
// MatchesURL checks if a URL matches any of the extension's URL patterns
|
||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
if !m.HasURLHandler() {
|
||||
return false
|
||||
@@ -293,7 +277,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPostProcessingHooks returns all post-processing hooks
|
||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||
if m.PostProcessing == nil {
|
||||
return nil
|
||||
@@ -301,7 +284,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||
return m.PostProcessing.Hooks
|
||||
}
|
||||
|
||||
// ToJSON serializes the manifest to JSON
|
||||
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
@@ -732,7 +732,7 @@ func GetMetadataProviderPriority() []string {
|
||||
// isBuiltInProvider checks if a provider ID is a built-in provider
|
||||
func isBuiltInProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz", "amazon":
|
||||
case "tidal", "qobuz", "amazon", "deezer":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -748,6 +748,21 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
priority := GetProviderPriority()
|
||||
extManager := GetExtensionManager()
|
||||
|
||||
// If req.Service is a built-in provider, prioritize it first
|
||||
// This handles user's explicit selection from the service picker
|
||||
if req.Service != "" && isBuiltInProvider(req.Service) {
|
||||
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||
// Reorder priority to put req.Service first
|
||||
newPriority := []string{req.Service}
|
||||
for _, p := range priority {
|
||||
if p != req.Service {
|
||||
newPriority = append(newPriority, p)
|
||||
}
|
||||
}
|
||||
priority = newPriority
|
||||
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
|
||||
|
||||
|
||||
@@ -23,9 +23,8 @@ type ExtensionAuthState struct {
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
IsAuthenticated bool
|
||||
// PKCE support
|
||||
PKCEVerifier string
|
||||
PKCEChallenge string
|
||||
PKCEVerifier string
|
||||
PKCEChallenge string
|
||||
}
|
||||
|
||||
type PendingAuthRequest struct {
|
||||
@@ -39,7 +38,6 @@ var (
|
||||
pendingAuthRequestsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
|
||||
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||
pendingAuthRequestsMu.RLock()
|
||||
defer pendingAuthRequestsMu.RUnlock()
|
||||
@@ -201,7 +199,6 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
// RegisterAPIs registers all sandboxed APIs to the Goja VM
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
@@ -212,7 +209,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
httpObj.Set("put", r.httpPut)
|
||||
httpObj.Set("delete", r.httpDelete)
|
||||
httpObj.Set("patch", r.httpPatch)
|
||||
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
httpObj.Set("request", r.httpRequest)
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
@@ -222,7 +219,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
storageObj.Set("remove", r.storageRemove)
|
||||
vm.Set("storage", storageObj)
|
||||
|
||||
// Secure Credentials API (encrypted storage for sensitive data)
|
||||
credentialsObj := vm.NewObject()
|
||||
credentialsObj.Set("store", r.credentialsStore)
|
||||
credentialsObj.Set("get", r.credentialsGet)
|
||||
@@ -237,7 +233,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
authObj.Set("clearAuth", r.authClear)
|
||||
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||
authObj.Set("getTokens", r.authGetTokens)
|
||||
// PKCE support
|
||||
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||
authObj.Set("getPKCE", r.authGetPKCE)
|
||||
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||
@@ -279,14 +274,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
||||
utilsObj.Set("parseJSON", r.parseJSON)
|
||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||
// Crypto utilities for developers
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
// Log object (already set in extension_manager.go, but we can enhance it)
|
||||
logObj := vm.NewObject()
|
||||
logObj.Set("debug", r.logDebug)
|
||||
logObj.Set("info", r.logInfo)
|
||||
@@ -298,10 +291,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
|
||||
// ==================== Browser-like Polyfills ====================
|
||||
// These make porting browser/Node.js libraries easier
|
||||
|
||||
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
||||
vm.Set("fetch", r.fetchPolyfill)
|
||||
|
||||
vm.Set("atob", r.atobPolyfill)
|
||||
|
||||
@@ -70,13 +70,11 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(state.AuthCode)
|
||||
}
|
||||
|
||||
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
|
||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// Can accept either just auth code or an object with tokens
|
||||
arg := call.Arguments[0].Export()
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
// ==================== File API (Sandboxed) ====================
|
||||
|
||||
// List of allowed directories for file operations (set by Go backend for download operations)
|
||||
var (
|
||||
allowedDownloadDirs []string
|
||||
allowedDownloadDirsMu sync.RWMutex
|
||||
@@ -49,9 +48,6 @@ func isPathInAllowedDirs(absPath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// validatePath checks if the path is within the extension's sandbox
|
||||
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
|
||||
// Extensions should use relative paths for their own data storage
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
// Check if extension has file permission
|
||||
if !r.manifest.Permissions.File {
|
||||
|
||||
@@ -14,14 +14,12 @@ import (
|
||||
|
||||
// ==================== HTTP API (Sandboxed) ====================
|
||||
|
||||
// HTTPResponse represents the response from an HTTP request
|
||||
type HTTPResponse struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
// validateDomain checks if the domain is allowed by the extension's permissions
|
||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpGet performs a GET request (sandboxed)
|
||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -76,16 +73,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set default User-Agent if not provided by extension
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -101,26 +96,24 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpPost performs a POST request (sandboxed)
|
||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -137,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get body if provided - support both string and object
|
||||
var bodyStr string
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
@@ -145,7 +137,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -154,12 +145,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
// Fallback to string conversion
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
@@ -177,11 +166,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set defaults if not provided by extension
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
@@ -189,7 +177,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -205,19 +192,18 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
@@ -240,27 +226,22 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Default options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// Parse options if provided
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Get method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Get body - support both string and object
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -273,7 +254,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
@@ -282,7 +262,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
@@ -295,11 +274,10 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set defaults if not provided by extension
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
@@ -307,7 +285,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -323,20 +300,18 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Return response with helper properties
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
@@ -347,7 +322,6 @@ func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
@@ -356,8 +330,6 @@ func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
|
||||
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -377,9 +349,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
|
||||
if method == "DELETE" {
|
||||
// http.delete(url, headers)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
@@ -389,7 +359,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// http.put(url, body, headers) / http.patch(url, body, headers)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
@@ -418,7 +387,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
@@ -431,7 +399,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
@@ -442,7 +409,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -458,7 +424,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
|
||||
@@ -17,12 +17,10 @@ import (
|
||||
|
||||
// ==================== Storage API ====================
|
||||
|
||||
// getStoragePath returns the path to the extension's storage file
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
// loadStorage loads the storage data from disk
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// saveStorage saves the storage data to disk
|
||||
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := json.MarshalIndent(storage, "", " ")
|
||||
@@ -52,7 +49,6 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
return os.WriteFile(storagePath, data, 0644)
|
||||
}
|
||||
|
||||
// storageGet retrieves a value from storage
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
@@ -68,7 +64,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
value, exists := storage[key]
|
||||
if !exists {
|
||||
// Return default value if provided
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
@@ -78,7 +73,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
// storageSet stores a value in storage
|
||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -103,7 +97,6 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// storageRemove removes a value from storage
|
||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -127,19 +120,14 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// ==================== Credentials API (Encrypted Storage) ====================
|
||||
|
||||
// getCredentialsPath returns the path to the extension's encrypted credentials file
|
||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||
}
|
||||
|
||||
// getSaltPath returns the path to the extension's encryption salt file
|
||||
func (r *ExtensionRuntime) getSaltPath() string {
|
||||
return filepath.Join(r.dataDir, ".cred_salt")
|
||||
}
|
||||
|
||||
// getOrCreateSalt gets existing salt or creates a new random one
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
@@ -160,22 +148,17 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
// getEncryptionKey derives an encryption key from extension ID + random salt
|
||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
// Get or create per-installation random salt
|
||||
salt, err := r.getOrCreateSalt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Combine extension ID + random salt for key derivation
|
||||
// This makes each installation unique, preventing mass decryption attacks
|
||||
combined := append([]byte(r.extensionID), salt...)
|
||||
hash := sha256.Sum256(combined)
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
// loadCredentials loads and decrypts credentials from disk
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
credPath := r.getCredentialsPath()
|
||||
data, err := os.ReadFile(credPath)
|
||||
@@ -186,7 +169,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||
@@ -204,7 +186,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// saveCredentials encrypts and saves credentials to disk
|
||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
@@ -221,10 +202,9 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions
|
||||
return os.WriteFile(credPath, encrypted, 0600)
|
||||
}
|
||||
|
||||
// credentialsStore stores an encrypted credential
|
||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -260,7 +240,6 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// credentialsGet retrieves a decrypted credential
|
||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
@@ -276,7 +255,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
value, exists := creds[key]
|
||||
if !exists {
|
||||
// Return default value if provided
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
@@ -286,7 +264,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
// credentialsRemove removes a credential
|
||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -310,7 +287,6 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// credentialsHas checks if a credential exists
|
||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -327,9 +303,6 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(exists)
|
||||
}
|
||||
|
||||
// ==================== Crypto Utilities ====================
|
||||
|
||||
// encryptAES encrypts data using AES-GCM
|
||||
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
@@ -350,7 +323,6 @@ func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// decryptAES decrypts data using AES-GCM
|
||||
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
|
||||
// ==================== Utility Functions ====================
|
||||
|
||||
// base64Encode encodes a string to base64
|
||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -28,7 +27,6 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
// base64Decode decodes a base64 string
|
||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
// md5Hash computes MD5 hash of a string
|
||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -51,7 +48,6 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
// sha256Hash computes SHA256 hash of a string
|
||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
// hmacSHA256 computes HMAC-SHA256 of a message with a key
|
||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -74,7 +69,6 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
|
||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -87,9 +81,6 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
|
||||
// Arguments: message (string or array of bytes), key (string or array of bytes)
|
||||
// Returns: array of bytes (for TOTP dynamic truncation)
|
||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue([]byte{})
|
||||
@@ -142,7 +133,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(jsArray)
|
||||
}
|
||||
|
||||
// parseJSON parses a JSON string
|
||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
@@ -158,7 +148,6 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// stringifyJSON converts a value to JSON string
|
||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -174,9 +163,6 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(data))
|
||||
}
|
||||
|
||||
// ==================== Crypto Utilities for Extensions ====================
|
||||
|
||||
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
|
||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -188,7 +174,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
plaintext := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
// Derive 32-byte key from provided key string
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||
@@ -205,7 +190,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
|
||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -225,14 +209,13 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Derive 32-byte key from provided key string
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -242,9 +225,8 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// cryptoGenerateKey generates a random encryption key
|
||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
length := 32 // Default 256-bit key
|
||||
length := 32
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||
length = int(l)
|
||||
@@ -266,13 +248,10 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
||||
})
|
||||
}
|
||||
|
||||
// randomUserAgent returns a random Chrome User-Agent string
|
||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(getRandomUserAgent())
|
||||
}
|
||||
|
||||
// ==================== Logging Functions ====================
|
||||
|
||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
@@ -305,8 +284,6 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// ==================== Go Backend Wrappers ====================
|
||||
|
||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -315,7 +292,6 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(sanitizeFilename(input))
|
||||
}
|
||||
|
||||
// RegisterGoBackendAPIs adds more Go backend functions to the VM
|
||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.Get("gobackend")
|
||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||
@@ -325,7 +301,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
|
||||
obj := gobackendObj.(*goja.Object)
|
||||
|
||||
// Expose sanitizeFilename
|
||||
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
@@ -333,7 +308,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
// Expose getAudioQuality
|
||||
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
@@ -356,7 +330,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
})
|
||||
})
|
||||
|
||||
// Expose buildFilename
|
||||
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue("")
|
||||
@@ -373,7 +346,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||
})
|
||||
|
||||
// Expose getLocalTime - returns device local time info
|
||||
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
|
||||
now := time.Now()
|
||||
_, offsetSeconds := now.Zone()
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ExtensionSettingsStore manages settings for all extensions
|
||||
type ExtensionSettingsStore struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
@@ -22,7 +21,6 @@ var (
|
||||
globalSettingsStoreOnce sync.Once
|
||||
)
|
||||
|
||||
// GetExtensionSettingsStore returns the global settings store
|
||||
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||
globalSettingsStoreOnce.Do(func() {
|
||||
globalSettingsStore = &ExtensionSettingsStore{
|
||||
@@ -32,7 +30,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||
return globalSettingsStore
|
||||
}
|
||||
|
||||
// SetDataDir sets the data directory for settings storage
|
||||
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -45,12 +42,10 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||
return s.loadAllSettings()
|
||||
}
|
||||
|
||||
// getSettingsPath returns the path to an extension's settings file
|
||||
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
||||
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
||||
}
|
||||
|
||||
// loadAllSettings loads settings for all extensions from disk
|
||||
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||
entries, err := os.ReadDir(s.dataDir)
|
||||
if err != nil {
|
||||
@@ -75,7 +70,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSettings loads settings for a specific extension
|
||||
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
@@ -94,7 +88,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// saveSettings saves settings for a specific extension
|
||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
|
||||
@@ -111,8 +104,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
|
||||
return os.WriteFile(settingsPath, data, 0644)
|
||||
}
|
||||
|
||||
// Get retrieves a setting value for an extension
|
||||
// Returns error if extension or key not found (gomobile compatible)
|
||||
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -129,7 +120,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// GetAll retrieves all settings for an extension
|
||||
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -147,7 +137,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
|
||||
return result
|
||||
}
|
||||
|
||||
// Set stores a setting value for an extension
|
||||
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -161,7 +150,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
|
||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||
}
|
||||
|
||||
// SetAll stores all settings for an extension
|
||||
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -172,7 +160,6 @@ func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]
|
||||
return s.saveSettings(extensionID, settings)
|
||||
}
|
||||
|
||||
// Remove removes a setting for an extension
|
||||
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -188,7 +175,6 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||
return s.saveSettings(extensionID, extSettings)
|
||||
}
|
||||
|
||||
// RemoveAll removes all settings for an extension
|
||||
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -203,7 +189,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllExtensionSettings returns settings for all extensions as JSON
|
||||
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -20,28 +20,26 @@ const (
|
||||
CategoryIntegration = "integration"
|
||||
)
|
||||
|
||||
// StoreExtension represents an extension in the store
|
||||
type StoreExtension struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||
}
|
||||
|
||||
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getDisplayName() string {
|
||||
if e.DisplayName != "" {
|
||||
return e.DisplayName
|
||||
@@ -52,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string {
|
||||
return e.Name
|
||||
}
|
||||
|
||||
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getDownloadURL() string {
|
||||
if e.DownloadURL != "" {
|
||||
return e.DownloadURL
|
||||
@@ -60,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string {
|
||||
return e.DownloadURLAlt
|
||||
}
|
||||
|
||||
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getIconURL() string {
|
||||
if e.IconURL != "" {
|
||||
return e.IconURL
|
||||
@@ -68,7 +64,6 @@ func (e *StoreExtension) getIconURL() string {
|
||||
return e.IconURLAlt
|
||||
}
|
||||
|
||||
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getMinAppVersion() string {
|
||||
if e.MinAppVersion != "" {
|
||||
return e.MinAppVersion
|
||||
@@ -76,7 +71,6 @@ func (e *StoreExtension) getMinAppVersion() string {
|
||||
return e.MinAppVersionAlt
|
||||
}
|
||||
|
||||
// StoreRegistry represents the extension registry
|
||||
type StoreRegistry struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
@@ -103,7 +97,6 @@ type StoreExtensionResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
// ToResponse converts StoreExtension to normalized response
|
||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
return StoreExtensionResponse{
|
||||
ID: e.ID,
|
||||
@@ -122,7 +115,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// ExtensionStore manages the extension store
|
||||
type ExtensionStore struct {
|
||||
registryURL string
|
||||
cacheDir string
|
||||
@@ -143,7 +135,6 @@ const (
|
||||
cacheFileName = "store_cache.json"
|
||||
)
|
||||
|
||||
// InitExtensionStore initializes the extension store
|
||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
@@ -160,14 +151,12 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
// GetExtensionStore returns the singleton store instance
|
||||
func GetExtensionStore() *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
// loadDiskCache loads cached registry from disk
|
||||
func (s *ExtensionStore) loadDiskCache() {
|
||||
if s.cacheDir == "" {
|
||||
return
|
||||
@@ -193,7 +182,6 @@ func (s *ExtensionStore) loadDiskCache() {
|
||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
}
|
||||
|
||||
// saveDiskCache saves registry to disk cache
|
||||
func (s *ExtensionStore) saveDiskCache() {
|
||||
if s.cacheDir == "" || s.cache == nil {
|
||||
return
|
||||
@@ -216,7 +204,6 @@ func (s *ExtensionStore) saveDiskCache() {
|
||||
os.WriteFile(cachePath, data, 0644)
|
||||
}
|
||||
|
||||
// FetchRegistry fetches the extension registry from GitHub
|
||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
@@ -267,7 +254,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
// GetExtensionsWithStatus returns extensions with installation status
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
@@ -299,7 +285,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DownloadExtension downloads an extension package to the specified path
|
||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
@@ -347,7 +332,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCategories returns all available categories
|
||||
func (s *ExtensionStore) GetCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
@@ -358,7 +342,6 @@ func (s *ExtensionStore) GetCategories() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// SearchExtensions searches extensions by query
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||
extensions, err := s.GetExtensionsWithStatus()
|
||||
if err != nil {
|
||||
@@ -404,7 +387,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ClearCache clears the in-memory and disk cache
|
||||
func (s *ExtensionStore) ClearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
+12
-6
@@ -1,23 +1,29 @@
|
||||
module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.24.5
|
||||
toolchain go1.25.6
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/go-flac/flacpicture v0.3.0
|
||||
github.com/go-flac/flacvorbis v0.2.0
|
||||
github.com/go-flac/go-flac v1.0.0
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4
|
||||
golang.org/x/net v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
)
|
||||
|
||||
+22
-8
@@ -1,5 +1,7 @@
|
||||
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.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
@@ -12,17 +14,29 @@ github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY
|
||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+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-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU=
|
||||
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
+1
-26
@@ -15,9 +15,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||
// Uses modern Chrome format with build and patch numbers
|
||||
// Windows 11 still reports as "Windows NT 10.0" for compatibility
|
||||
func getRandomUserAgent() string {
|
||||
// Chrome version 120-145 (modern range)
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
@@ -38,9 +35,9 @@ const (
|
||||
SongLinkTimeout = 30 * time.Second
|
||||
DefaultMaxRetries = 3
|
||||
DefaultRetryDelay = 1 * time.Second
|
||||
Second = time.Second
|
||||
)
|
||||
|
||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||
var sharedTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -84,7 +81,6 @@ func GetDownloadClient() *http.Client {
|
||||
return downloadClient
|
||||
}
|
||||
|
||||
// CloseIdleConnections closes idle connections in the shared transport
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
}
|
||||
@@ -116,9 +112,6 @@ func DefaultRetryConfig() RetryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
||||
// Also detects and logs ISP blocking
|
||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||
var lastErr error
|
||||
delay := config.InitialDelay
|
||||
@@ -148,12 +141,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
continue
|
||||
}
|
||||
|
||||
// Success
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Handle rate limiting (429)
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
retryAfter := getRetryAfterDuration(resp)
|
||||
@@ -193,7 +184,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
}
|
||||
}
|
||||
|
||||
// Server errors (5xx) - retry
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||
@@ -205,7 +195,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
continue
|
||||
}
|
||||
|
||||
// Client errors (4xx except 429) - don't retry
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -224,12 +213,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
return 60 * time.Second // Default wait time
|
||||
}
|
||||
|
||||
// Try parsing as seconds
|
||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// Try parsing as HTTP date
|
||||
if t, err := http.ParseTime(retryAfter); err == nil {
|
||||
duration := time.Until(t)
|
||||
if duration > 0 {
|
||||
@@ -240,8 +227,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
return 60 * time.Second // Default
|
||||
}
|
||||
|
||||
// ReadResponseBody reads and returns the response body
|
||||
// Returns error if body is empty
|
||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("response is nil")
|
||||
@@ -271,14 +256,12 @@ func ValidateResponse(resp *http.Response) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildErrorMessage creates a detailed error message for API failures
|
||||
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
|
||||
msg := fmt.Sprintf("API %s failed", apiURL)
|
||||
if statusCode > 0 {
|
||||
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
|
||||
}
|
||||
if responsePreview != "" {
|
||||
// Truncate preview if too long
|
||||
if len(responsePreview) > 100 {
|
||||
responsePreview = responsePreview[:100] + "..."
|
||||
}
|
||||
@@ -297,18 +280,14 @@ func (e *ISPBlockingError) Error() string {
|
||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||
}
|
||||
|
||||
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
||||
// Returns the ISPBlockingError if detected, nil otherwise
|
||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract domain from URL
|
||||
domain := extractDomain(requestURL)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Check for DNS resolution failure (common ISP blocking method)
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||
@@ -320,11 +299,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for connection refused (ISP firewall blocking)
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if opErr.Op == "dial" {
|
||||
// Check for specific syscall errors
|
||||
var syscallErr syscall.Errno
|
||||
if errors.As(opErr.Err, &syscallErr) {
|
||||
switch syscallErr {
|
||||
@@ -363,7 +340,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
||||
var tlsErr *tls.RecordHeaderError
|
||||
if errors.As(err, &tlsErr) {
|
||||
return &ISPBlockingError{
|
||||
@@ -424,7 +400,6 @@ func extractDomain(rawURL string) string {
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
// Try to extract domain manually
|
||||
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build ios
|
||||
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
|
||||
// Fall back to standard HTTP client
|
||||
|
||||
// GetCloudflareBypassClient returns the standard HTTP client on iOS
|
||||
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
|
||||
func GetCloudflareBypassClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
// DoRequestWithCloudflareBypass on iOS just uses the standard client
|
||||
// uTLS Chrome fingerprint bypass is not available on iOS
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//go:build !ios
|
||||
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
|
||||
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
|
||||
type utlsTransport struct {
|
||||
dialer *net.Dialer
|
||||
mu sync.Mutex
|
||||
h2Transports map[string]*http2.Transport
|
||||
}
|
||||
|
||||
func newUTLSTransport() *utlsTransport {
|
||||
return &utlsTransport{
|
||||
dialer: &net.Dialer{
|
||||
Timeout: 30 * Second,
|
||||
KeepAlive: 30 * Second,
|
||||
},
|
||||
h2Transports: make(map[string]*http2.Transport),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// For non-HTTPS, use standard transport
|
||||
if req.URL.Scheme != "https" {
|
||||
return sharedTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
host := req.URL.Hostname()
|
||||
port := t.getPort(req.URL)
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
// Dial TCP connection
|
||||
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create uTLS connection with Chrome fingerprint (supports HTTP/2 ALPN)
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"}, // Prefer HTTP/2
|
||||
}, utls.HelloChrome_Auto)
|
||||
|
||||
// Perform TLS handshake
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if server supports HTTP/2
|
||||
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||
|
||||
if negotiatedProto == "h2" {
|
||||
// Use HTTP/2 transport
|
||||
h2Transport := &http2.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
},
|
||||
AllowHTTP: false,
|
||||
DisableCompression: false,
|
||||
}
|
||||
return h2Transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// Fallback to HTTP/1.1
|
||||
transport := &http.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
|
||||
return transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *utlsTransport) getPort(u *url.URL) string {
|
||||
if u.Port() != "" {
|
||||
return u.Port()
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
// Cloudflare bypass client using uTLS Chrome fingerprint
|
||||
var cloudflareBypassTransport = newUTLSTransport()
|
||||
|
||||
var cloudflareBypassClient = &http.Client{
|
||||
Transport: cloudflareBypassTransport,
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
|
||||
// Use this when requests are blocked by Cloudflare (common when using VPN)
|
||||
func GetCloudflareBypassClient() *http.Client {
|
||||
return cloudflareBypassClient
|
||||
}
|
||||
|
||||
// DoRequestWithCloudflareBypass attempts request with standard client first,
|
||||
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
|
||||
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
// Try with standard client first
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err == nil {
|
||||
// Check for Cloudflare challenge page (403 with specific markers)
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if readErr == nil {
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
cloudflareMarkers := []string{
|
||||
"cloudflare", "cf-ray", "checking your browser",
|
||||
"please wait", "ddos protection", "ray id",
|
||||
"enable javascript", "challenge-platform",
|
||||
}
|
||||
|
||||
isCloudflare := false
|
||||
for _, marker := range cloudflareMarkers {
|
||||
if strings.Contains(bodyStr, marker) {
|
||||
isCloudflare = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isCloudflare {
|
||||
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||
|
||||
// Clone request for retry
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
// Retry with uTLS Chrome fingerprint
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
}
|
||||
|
||||
// Not Cloudflare, return original response (recreate body)
|
||||
return &http.Response{
|
||||
Status: resp.Status,
|
||||
StatusCode: resp.StatusCode,
|
||||
Header: resp.Header,
|
||||
Body: io.NopCloser(strings.NewReader(string(body))),
|
||||
}, nil
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Check if error might be TLS-related (Cloudflare blocking)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||
strings.Contains(errStr, "handshake") ||
|
||||
strings.Contains(errStr, "certificate") ||
|
||||
strings.Contains(errStr, "connection reset")
|
||||
|
||||
if tlsRelated {
|
||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||
|
||||
// Clone request for retry
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
// Retry with uTLS Chrome fingerprint
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IDHSClient is a client for I Don't Have Spotify API
|
||||
// Used as fallback when SongLink fails or is rate limited
|
||||
type IDHSClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
globalIDHSClient *IDHSClient
|
||||
idhsClientOnce sync.Once
|
||||
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
||||
)
|
||||
|
||||
// IDHSSearchRequest represents the request body for IDHS API
|
||||
type IDHSSearchRequest struct {
|
||||
Link string `json:"link"`
|
||||
Adapters []string `json:"adapters,omitempty"`
|
||||
}
|
||||
|
||||
// IDHSSearchResponse represents the response from IDHS API
|
||||
type IDHSSearchResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // song, album, artist, podcast, show
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Audio string `json:"audio,omitempty"`
|
||||
Source string `json:"source"`
|
||||
UniversalLink string `json:"universalLink"`
|
||||
Links []IDHSLink `json:"links"`
|
||||
}
|
||||
|
||||
// IDHSLink represents a link to a streaming platform
|
||||
type IDHSLink struct {
|
||||
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
||||
URL string `json:"url"`
|
||||
IsVerified bool `json:"isVerified,omitempty"`
|
||||
NotAvailable bool `json:"notAvailable,omitempty"`
|
||||
}
|
||||
|
||||
// NewIDHSClient creates a new IDHS client
|
||||
func NewIDHSClient() *IDHSClient {
|
||||
idhsClientOnce.Do(func() {
|
||||
globalIDHSClient = &IDHSClient{
|
||||
client: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
}
|
||||
})
|
||||
return globalIDHSClient
|
||||
}
|
||||
|
||||
// Search converts a music link to links on other platforms
|
||||
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
||||
idhsRateLimiter.WaitForSlot()
|
||||
|
||||
reqBody := IDHSSearchRequest{
|
||||
Link: link,
|
||||
Adapters: adapters,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("invalid link or missing parameters")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("IDHS rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode == 500 {
|
||||
return nil, fmt.Errorf("IDHS processing failed")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var result IDHSSearchResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
|
||||
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
|
||||
// Request only the platforms we need
|
||||
adapters := []string{"tidal", "deezer"}
|
||||
|
||||
result, err := c.Search(spotifyURL, adapters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
for _, link := range result.Links {
|
||||
if link.NotAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(link.Type) {
|
||||
case "tidal":
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = link.URL
|
||||
case "deezer":
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = link.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(link.URL)
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v",
|
||||
spotifyTrackID, availability.Tidal, availability.Deezer)
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// GetAvailabilityFromDeezer checks track availability using IDHS
|
||||
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
|
||||
// Request only the platforms we need
|
||||
adapters := []string{"spotify", "tidal"}
|
||||
|
||||
result, err := c.Search(deezerURL, adapters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
Deezer: true,
|
||||
DeezerID: deezerTrackID,
|
||||
}
|
||||
|
||||
for _, link := range result.Links {
|
||||
if link.NotAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(link.Type) {
|
||||
case "spotify":
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(link.URL)
|
||||
case "tidal":
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = link.URL
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v",
|
||||
deezerTrackID, availability.SpotifyID, availability.Tidal)
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
@@ -150,11 +150,11 @@ func GoLog(format string, args ...interface{}) {
|
||||
|
||||
// Determine level from message content
|
||||
msgLower := strings.ToLower(message)
|
||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
||||
level = "ERROR"
|
||||
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||
level = "WARN"
|
||||
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||
} else if strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||
level = "INFO"
|
||||
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||
level = "DEBUG"
|
||||
|
||||
@@ -238,12 +238,9 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
|
||||
return diff <= durationToleranceSec
|
||||
}
|
||||
|
||||
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
// Normalize artist name - take first artist before comma/semicolon for better matching
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
|
||||
// Check cache first (use original artist name for cache key)
|
||||
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||
cachedCopy := *cached
|
||||
@@ -254,12 +251,10 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
// Helper to check if lyrics result is valid (has lines OR is instrumental)
|
||||
isValidResult := func(l *LyricsResponse) bool {
|
||||
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||
}
|
||||
|
||||
// Try exact match first with primary artist
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
@@ -267,7 +262,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// Try with full artist name if different from primary
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
@@ -277,7 +271,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
}
|
||||
}
|
||||
|
||||
// Try with simplified track name
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
@@ -288,7 +281,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
}
|
||||
}
|
||||
|
||||
// Search with duration matching (use primary artist for search)
|
||||
query := primaryArtist + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
@@ -297,7 +289,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// Search with simplified name and duration matching
|
||||
if simplifiedTrack != trackName {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
@@ -393,32 +384,6 @@ func msToLRCTimestamp(ms int64) string {
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
// Use convertToLRCWithMetadata for full LRC with headers
|
||||
// Kept for potential future use
|
||||
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||
// if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
// return ""
|
||||
// }
|
||||
//
|
||||
// var builder strings.Builder
|
||||
//
|
||||
// if lyrics.SyncType == "LINE_SYNCED" {
|
||||
// for _, line := range lyrics.Lines {
|
||||
// timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
// builder.WriteString(timestamp)
|
||||
// builder.WriteString(line.Words)
|
||||
// builder.WriteString("\n")
|
||||
// }
|
||||
// } else {
|
||||
// for _, line := range lyrics.Lines {
|
||||
// builder.WriteString(line.Words)
|
||||
// builder.WriteString("\n")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return builder.String()
|
||||
// }
|
||||
|
||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
@@ -480,11 +445,7 @@ func simplifyTrackName(name string) string {
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
// normalizeArtistName extracts the primary artist from multi-artist strings
|
||||
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
|
||||
// e.g., "Artist1; Artist2" -> "Artist1"
|
||||
func normalizeArtistName(name string) string {
|
||||
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
|
||||
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
|
||||
|
||||
result := name
|
||||
|
||||
+33
-413
@@ -238,7 +238,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// ReadMetadata reads metadata from a FLAC file
|
||||
func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -336,6 +335,39 @@ func fileExists(path string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func ExtractCoverArt(filePath string) ([]byte, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
for _, meta := range f.Meta {
|
||||
if meta.Type == flac.Picture {
|
||||
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
|
||||
return pic.ImageData, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, meta := range f.Meta {
|
||||
if meta.Type == flac.Picture {
|
||||
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(pic.ImageData) > 0 {
|
||||
return pic.ImageData, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no cover art found in file")
|
||||
}
|
||||
|
||||
func EmbedLyrics(filePath string, lyrics string) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -418,7 +450,6 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -512,371 +543,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// M4A (MP4/AAC) Metadata Embedding
|
||||
// ========================================
|
||||
|
||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||
input, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open M4A file: %w", err)
|
||||
}
|
||||
defer input.Close()
|
||||
|
||||
info, err := input.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat M4A file: %w", err)
|
||||
}
|
||||
fileSize := info.Size()
|
||||
|
||||
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find moov atom: %w", err)
|
||||
}
|
||||
if !moovFound {
|
||||
return fmt.Errorf("moov atom not found in M4A file")
|
||||
}
|
||||
|
||||
moovContentStart := moovHeader.offset + moovHeader.headerSize
|
||||
moovContentSize := moovHeader.size - moovHeader.headerSize
|
||||
|
||||
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to locate udta atom: %w", err)
|
||||
}
|
||||
|
||||
var metaHeader atomHeader
|
||||
metaFound := false
|
||||
if udtaFound {
|
||||
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
|
||||
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
|
||||
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to locate meta atom: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
metaAtom := buildMetaAtom(metadata, coverData)
|
||||
metaSize := int64(len(metaAtom))
|
||||
|
||||
var delta int64
|
||||
var newUdtaSize int64
|
||||
switch {
|
||||
case udtaFound && metaFound:
|
||||
delta = metaSize - metaHeader.size
|
||||
newUdtaSize = udtaHeader.size + delta
|
||||
case udtaFound && !metaFound:
|
||||
delta = metaSize
|
||||
newUdtaSize = udtaHeader.size + delta
|
||||
case !udtaFound:
|
||||
newUdtaSize = int64(8 + len(metaAtom))
|
||||
delta = newUdtaSize
|
||||
}
|
||||
|
||||
newMoovSize := moovHeader.size + delta
|
||||
if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
|
||||
return fmt.Errorf("moov atom exceeds 32-bit size after update")
|
||||
}
|
||||
if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
|
||||
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
||||
}
|
||||
if !udtaFound && newUdtaSize > int64(^uint32(0)) {
|
||||
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
||||
}
|
||||
|
||||
tempPath := filePath + ".tmp"
|
||||
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
cleanupTemp := true
|
||||
defer func() {
|
||||
_ = output.Close()
|
||||
if cleanupTemp {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
switch {
|
||||
case udtaFound && metaFound:
|
||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(metaAtom); err != nil {
|
||||
return fmt.Errorf("failed to write meta atom: %w", err)
|
||||
}
|
||||
metaEnd := metaHeader.offset + metaHeader.size
|
||||
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
|
||||
return err
|
||||
}
|
||||
case udtaFound && !metaFound:
|
||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
insertPos := udtaHeader.offset + udtaHeader.size
|
||||
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(metaAtom); err != nil {
|
||||
return fmt.Errorf("failed to write meta atom: %w", err)
|
||||
}
|
||||
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
|
||||
return err
|
||||
}
|
||||
case !udtaFound:
|
||||
newUdtaAtom := buildUdtaAtom(metaAtom)
|
||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
||||
return err
|
||||
}
|
||||
moovEnd := moovHeader.offset + moovHeader.size
|
||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(newUdtaAtom); err != nil {
|
||||
return fmt.Errorf("failed to write udta atom: %w", err)
|
||||
}
|
||||
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := output.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
_ = input.Close()
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return fmt.Errorf("failed to replace original file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, filePath); err != nil {
|
||||
return fmt.Errorf("failed to move temp file: %w", err)
|
||||
}
|
||||
cleanupTemp = false
|
||||
|
||||
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func findAtom(data []byte, name string, offset int) int {
|
||||
for i := offset; i < len(data)-8; {
|
||||
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
||||
if size < 8 {
|
||||
break
|
||||
}
|
||||
atomName := string(data[i+4 : i+8])
|
||||
if atomName == name {
|
||||
return i
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
var ilst []byte
|
||||
|
||||
if metadata.Title != "" {
|
||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||
}
|
||||
|
||||
if metadata.Artist != "" {
|
||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||
}
|
||||
|
||||
if metadata.Album != "" {
|
||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||
}
|
||||
|
||||
if metadata.AlbumArtist != "" {
|
||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||
}
|
||||
|
||||
if metadata.Date != "" {
|
||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||
}
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||
}
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||
}
|
||||
|
||||
if metadata.Lyrics != "" {
|
||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||
}
|
||||
|
||||
if len(coverData) > 0 {
|
||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||
}
|
||||
|
||||
ilstSize := 8 + len(ilst)
|
||||
ilstAtom := make([]byte, 4)
|
||||
ilstAtom[0] = byte(ilstSize >> 24)
|
||||
ilstAtom[1] = byte(ilstSize >> 16)
|
||||
ilstAtom[2] = byte(ilstSize >> 8)
|
||||
ilstAtom[3] = byte(ilstSize)
|
||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||
ilstAtom = append(ilstAtom, ilst...)
|
||||
|
||||
hdlr := []byte{
|
||||
0, 0, 0, 33, // size = 33
|
||||
'h', 'd', 'l', 'r',
|
||||
0, 0, 0, 0, // version + flags
|
||||
0, 0, 0, 0, // predefined
|
||||
'm', 'd', 'i', 'r', // handler type
|
||||
'a', 'p', 'p', 'l', // manufacturer
|
||||
0, 0, 0, 0, // component flags
|
||||
0, 0, 0, 0, // component flags mask
|
||||
0, // null terminator
|
||||
}
|
||||
|
||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||
metaContent = append(metaContent, ilstAtom...)
|
||||
|
||||
metaSize := 8 + len(metaContent)
|
||||
metaAtom := make([]byte, 4)
|
||||
metaAtom[0] = byte(metaSize >> 24)
|
||||
metaAtom[1] = byte(metaSize >> 16)
|
||||
metaAtom[2] = byte(metaSize >> 8)
|
||||
metaAtom[3] = byte(metaSize)
|
||||
metaAtom = append(metaAtom, []byte("meta")...)
|
||||
metaAtom = append(metaAtom, metaContent...)
|
||||
|
||||
return metaAtom
|
||||
}
|
||||
|
||||
func buildTextAtom(name, value string) []byte {
|
||||
valueBytes := []byte(value)
|
||||
|
||||
dataSize := 16 + len(valueBytes)
|
||||
dataAtom := make([]byte, 4)
|
||||
dataAtom[0] = byte(dataSize >> 24)
|
||||
dataAtom[1] = byte(dataSize >> 16)
|
||||
dataAtom[2] = byte(dataSize >> 8)
|
||||
dataAtom[3] = byte(dataSize)
|
||||
dataAtom = append(dataAtom, []byte("data")...)
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||
dataAtom = append(dataAtom, valueBytes...)
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
atom[1] = byte(atomSize >> 16)
|
||||
atom[2] = byte(atomSize >> 8)
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte(name)...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
// buildTrackNumberAtom builds trkn atom
|
||||
func buildTrackNumberAtom(track, total int) []byte {
|
||||
dataAtom := []byte{
|
||||
0, 0, 0, 24, // size
|
||||
'd', 'a', 't', 'a',
|
||||
0, 0, 0, 0, // type = implicit
|
||||
0, 0, 0, 0, // locale
|
||||
0, 0, // padding
|
||||
byte(track >> 8), byte(track), // track number
|
||||
byte(total >> 8), byte(total), // total tracks
|
||||
0, 0, // padding
|
||||
}
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
atom[1] = byte(atomSize >> 16)
|
||||
atom[2] = byte(atomSize >> 8)
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("trkn")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
func buildDiscNumberAtom(disc, total int) []byte {
|
||||
dataAtom := []byte{
|
||||
0, 0, 0, 22, // size
|
||||
'd', 'a', 't', 'a',
|
||||
0, 0, 0, 0, // type = implicit
|
||||
0, 0, 0, 0, // locale
|
||||
0, 0, // padding
|
||||
byte(disc >> 8), byte(disc), // disc number
|
||||
byte(total >> 8), byte(total), // total discs
|
||||
}
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
atom[1] = byte(atomSize >> 16)
|
||||
atom[2] = byte(atomSize >> 8)
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("disk")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
// buildCoverAtom builds covr atom with image data
|
||||
func buildCoverAtom(coverData []byte) []byte {
|
||||
imageType := byte(13)
|
||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||
imageType = 14
|
||||
}
|
||||
|
||||
dataSize := 16 + len(coverData)
|
||||
dataAtom := make([]byte, 4)
|
||||
dataAtom[0] = byte(dataSize >> 24)
|
||||
dataAtom[1] = byte(dataSize >> 16)
|
||||
dataAtom[2] = byte(dataSize >> 8)
|
||||
dataAtom[3] = byte(dataSize)
|
||||
dataAtom = append(dataAtom, []byte("data")...)
|
||||
dataAtom = append(dataAtom, 0, 0, 0, imageType)
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0)
|
||||
dataAtom = append(dataAtom, coverData...)
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
atom[1] = byte(atomSize >> 16)
|
||||
atom[2] = byte(atomSize >> 8)
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("covr")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -989,52 +655,6 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
|
||||
return atomHeader{}, false, nil
|
||||
}
|
||||
|
||||
func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
|
||||
if len(typ) != 4 {
|
||||
return fmt.Errorf("invalid atom type: %s", typ)
|
||||
}
|
||||
|
||||
if headerSize == 16 {
|
||||
header := make([]byte, 16)
|
||||
binary.BigEndian.PutUint32(header[0:4], 1)
|
||||
copy(header[4:8], []byte(typ))
|
||||
binary.BigEndian.PutUint64(header[8:16], uint64(size))
|
||||
_, err := w.Write(header)
|
||||
return err
|
||||
}
|
||||
|
||||
if size > int64(^uint32(0)) {
|
||||
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
|
||||
}
|
||||
|
||||
header := make([]byte, 8)
|
||||
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
||||
copy(header[4:8], []byte(typ))
|
||||
_, err := w.Write(header)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
|
||||
if length <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := src.Seek(offset, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek source: %w", err)
|
||||
}
|
||||
if _, err := io.CopyN(dst, src, length); err != nil {
|
||||
return fmt.Errorf("failed to copy data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUdtaAtom(metaAtom []byte) []byte {
|
||||
size := 8 + len(metaAtom)
|
||||
header := make([]byte, 8)
|
||||
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
||||
copy(header[4:8], []byte("udta"))
|
||||
return append(header, metaAtom...)
|
||||
}
|
||||
|
||||
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
||||
const chunkSize = 64 * 1024
|
||||
patternMP4A := []byte("mp4a")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// mobile_deps.go
|
||||
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
||||
// These packages are required by gomobile bind but not directly imported in code.
|
||||
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
// Required for gomobile bind to work
|
||||
_ "golang.org/x/mobile/bind"
|
||||
)
|
||||
+55
-28
@@ -14,9 +14,11 @@ type TrackIDCacheEntry struct {
|
||||
}
|
||||
|
||||
type TrackIDCache struct {
|
||||
cache map[string]*TrackIDCacheEntry
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
cache map[string]*TrackIDCacheEntry
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
lastCleanup time.Time
|
||||
cleanupInterval time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -27,8 +29,9 @@ var (
|
||||
func GetTrackIDCache() *TrackIDCache {
|
||||
trackIDCacheOnce.Do(func() {
|
||||
globalTrackIDCache = &TrackIDCache{
|
||||
cache: make(map[string]*TrackIDCacheEntry),
|
||||
ttl: 30 * time.Minute,
|
||||
cache: make(map[string]*TrackIDCacheEntry),
|
||||
ttl: 30 * time.Minute,
|
||||
cleanupInterval: 5 * time.Minute,
|
||||
}
|
||||
})
|
||||
return globalTrackIDCache
|
||||
@@ -36,13 +39,33 @@ func GetTrackIDCache() *TrackIDCache {
|
||||
|
||||
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists || time.Now().After(entry.ExpiresAt) {
|
||||
if !exists {
|
||||
c.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
return entry
|
||||
expired := time.Now().After(entry.ExpiresAt)
|
||||
c.mu.RUnlock()
|
||||
|
||||
if !expired {
|
||||
return entry
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
entry, exists = c.cache[isrc]
|
||||
if exists && time.Now().After(entry.ExpiresAt) {
|
||||
delete(c.cache, isrc)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
|
||||
for key, entry := range c.cache {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(c.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
@@ -55,7 +78,13 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.TidalTrackID = trackID
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||
c.pruneExpiredLocked(now)
|
||||
c.lastCleanup = now
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
@@ -68,7 +97,13 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.QobuzTrackID = trackID
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||
c.pruneExpiredLocked(now)
|
||||
c.lastCleanup = now
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
@@ -81,7 +116,13 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.AmazonTrackID = trackID
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||
c.pruneExpiredLocked(now)
|
||||
c.lastCleanup = now
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) Clear() {
|
||||
@@ -96,7 +137,6 @@ func (c *TrackIDCache) Size() int {
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
// ParallelDownloadResult holds results from parallel operations
|
||||
type ParallelDownloadResult struct {
|
||||
CoverData []byte
|
||||
LyricsData *LyricsResponse
|
||||
@@ -121,14 +161,11 @@ func FetchCoverAndLyricsParallel(
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
fmt.Println("[Parallel] Starting cover download...")
|
||||
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||
if err != nil {
|
||||
result.CoverErr = err
|
||||
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
|
||||
} else {
|
||||
result.CoverData = data
|
||||
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -137,20 +174,16 @@ func FetchCoverAndLyricsParallel(
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
fmt.Println("[Parallel] Starting lyrics fetch...")
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
result.LyricsErr = err
|
||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
result.LyricsData = lyrics
|
||||
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||
} else {
|
||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||
fmt.Println("[Parallel] No lyrics found")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -163,8 +196,8 @@ type PreWarmCacheRequest struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
SpotifyID string // Needed for Amazon (SongLink lookup)
|
||||
Service string // "tidal", "qobuz", "amazon"
|
||||
SpotifyID string
|
||||
Service string
|
||||
}
|
||||
|
||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
@@ -172,7 +205,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
||||
cache := GetTrackIDCache()
|
||||
|
||||
semaphore := make(chan struct{}, 3)
|
||||
@@ -201,7 +233,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||
}
|
||||
|
||||
func preWarmTidalCache(isrc, _, _ string) {
|
||||
@@ -209,7 +240,6 @@ func preWarmTidalCache(isrc, _, _ string) {
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +248,6 @@ func preWarmQobuzCache(isrc string) {
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +256,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.Amazon {
|
||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +268,6 @@ func PreWarmCache(tracksJSON string) error {
|
||||
|
||||
func ClearTrackCache() {
|
||||
GetTrackIDCache().Clear()
|
||||
fmt.Println("[Cache] Track ID cache cleared")
|
||||
}
|
||||
|
||||
func GetCacheSize() int {
|
||||
|
||||
@@ -78,7 +78,6 @@ func GetItemProgress(itemID string) string {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
// StartItemProgress initializes progress tracking for an item
|
||||
func StartItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -93,7 +92,6 @@ func StartItemProgress(itemID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemBytesTotal sets total bytes for an item
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -103,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemBytesReceived sets bytes received for an item
|
||||
func SetItemBytesReceived(itemID string, received int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -116,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
||||
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -130,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
||||
}
|
||||
}
|
||||
|
||||
// CompleteItemProgress marks an item as complete
|
||||
func CompleteItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -142,7 +137,6 @@ func CompleteItemProgress(itemID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemProgress sets progress for an item directly
|
||||
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -158,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||
func SetItemFinalizing(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -169,7 +162,6 @@ func SetItemFinalizing(itemID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveItemProgress removes progress tracking for an item
|
||||
func RemoveItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -177,7 +169,6 @@ func RemoveItemProgress(itemID string) {
|
||||
delete(multiProgress.Items, itemID)
|
||||
}
|
||||
|
||||
// ClearAllItemProgress clears all item progress
|
||||
func ClearAllItemProgress() {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -185,7 +176,6 @@ func ClearAllItemProgress() {
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
}
|
||||
|
||||
// setDownloadDir sets the default download directory
|
||||
func setDownloadDir(path string) error {
|
||||
downloadDirMu.Lock()
|
||||
defer downloadDirMu.Unlock()
|
||||
@@ -193,7 +183,6 @@ func setDownloadDir(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
itemID string
|
||||
@@ -206,7 +195,6 @@ type ItemProgressWriter struct {
|
||||
|
||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||
|
||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||
now := time.Now()
|
||||
return &ItemProgressWriter{
|
||||
@@ -220,7 +208,6 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||
return 0, ErrDownloadCancelled
|
||||
|
||||
+134
-112
@@ -52,12 +52,10 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
@@ -112,24 +110,19 @@ func qobuzSplitArtists(artists string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
|
||||
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
|
||||
func qobuzSameWordsUnordered(a, b string) bool {
|
||||
wordsA := strings.Fields(a)
|
||||
wordsB := strings.Fields(b)
|
||||
|
||||
// Must have same number of words
|
||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sort and compare
|
||||
sortedA := make([]string, len(wordsA))
|
||||
sortedB := make([]string, len(wordsB))
|
||||
copy(sortedA, wordsA)
|
||||
copy(sortedB, wordsB)
|
||||
|
||||
// Simple bubble sort (usually just 2-3 words)
|
||||
for i := 0; i < len(sortedA)-1; i++ {
|
||||
for j := i + 1; j < len(sortedA); j++ {
|
||||
if sortedA[i] > sortedA[j] {
|
||||
@@ -153,7 +146,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
@@ -182,8 +174,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
||||
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||
if expectedLatin != foundLatin {
|
||||
@@ -194,9 +184,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
|
||||
func qobuzExtractCoreTitle(title string) string {
|
||||
// Find first occurrence of ( or [
|
||||
parenIdx := strings.Index(title, "(")
|
||||
bracketIdx := strings.Index(title, "[")
|
||||
dashIdx := strings.Index(title, " - ")
|
||||
@@ -281,49 +269,28 @@ func qobuzCleanTitle(title string) string {
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
// qobuzIsLatinScript checks if a string is primarily Latin script
|
||||
// Returns true for ASCII and Latin Extended characters (European languages)
|
||||
// Returns false for CJK, Arabic, Cyrillic, etc.
|
||||
func qobuzIsLatinScript(s string) bool {
|
||||
for _, r := range s {
|
||||
// Skip common punctuation and numbers
|
||||
if r < 128 {
|
||||
continue
|
||||
}
|
||||
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
||||
// Latin Extended-B: U+0180 to U+024F
|
||||
// Latin Extended Additional: U+1E00 to U+1EFF
|
||||
// Latin Extended-C/D/E: various ranges
|
||||
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
||||
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
||||
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
||||
if (r >= 0x0100 && r <= 0x024F) ||
|
||||
(r >= 0x1E00 && r <= 0x1EFF) ||
|
||||
(r >= 0x00C0 && r <= 0x00FF) {
|
||||
continue
|
||||
}
|
||||
// CJK ranges - definitely different script
|
||||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
||||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
||||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
||||
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
||||
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
||||
if (r >= 0x4E00 && r <= 0x9FFF) ||
|
||||
(r >= 0x3040 && r <= 0x309F) ||
|
||||
(r >= 0x30A0 && r <= 0x30FF) ||
|
||||
(r >= 0xAC00 && r <= 0xD7AF) ||
|
||||
(r >= 0x0600 && r <= 0x06FF) ||
|
||||
(r >= 0x0400 && r <= 0x04FF) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||
// Kept for potential future use
|
||||
// func qobuzIsASCIIString(s string) bool {
|
||||
// for _, r := range s {
|
||||
// if r > 127 {
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
|
||||
// containsQueryQobuz checks if a query already exists in the list
|
||||
func containsQueryQobuz(queries []string, query string) bool {
|
||||
for _, q := range queries {
|
||||
if q == query {
|
||||
@@ -336,7 +303,7 @@ func containsQueryQobuz(queries []string, query string) bool {
|
||||
func NewQobuzDownloader() *QobuzDownloader {
|
||||
qobuzDownloaderOnce.Do(func() {
|
||||
globalQobuzDownloader = &QobuzDownloader{
|
||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||
client: NewHTTPClientWithTimeout(DefaultTimeout),
|
||||
appID: "798273057",
|
||||
}
|
||||
})
|
||||
@@ -344,7 +311,6 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
// Qobuz API: /track/get?track_id=XXX
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
||||
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
|
||||
|
||||
@@ -371,14 +337,11 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||
// Uses same APIs as PC version for compatibility
|
||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
// Same APIs as PC version (referensi/backend/qobuz.go)
|
||||
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
|
||||
encodedAPIs := []string{
|
||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
|
||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
|
||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
|
||||
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
|
||||
}
|
||||
|
||||
var apis []string
|
||||
@@ -393,6 +356,86 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
return apis
|
||||
}
|
||||
|
||||
func mapJumoQuality(quality string) int {
|
||||
switch quality {
|
||||
case "6":
|
||||
return 6
|
||||
case "7":
|
||||
return 7
|
||||
case "27":
|
||||
return 27
|
||||
default:
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
func decodeXOR(data []byte) string {
|
||||
text := string(data)
|
||||
runes := []rune(text)
|
||||
result := make([]rune, len(runes))
|
||||
for i, char := range runes {
|
||||
key := rune((i * 17) % 128)
|
||||
result[i] = char ^ 253 ^ key
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||
formatID := mapJumoQuality(quality)
|
||||
region := "US"
|
||||
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||
|
||||
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
||||
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
req, err := http.NewRequest("GET", jumoURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("Jumo API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
decoded := decodeXOR(body)
|
||||
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
|
||||
GoLog("[Qobuz] Jumo API returned URL successfully\n")
|
||||
return urlVal, nil
|
||||
}
|
||||
|
||||
if data, ok := result["data"].(map[string]any); ok {
|
||||
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
|
||||
GoLog("[Qobuz] Jumo API returned URL successfully (from data)\n")
|
||||
return urlVal, nil
|
||||
}
|
||||
}
|
||||
|
||||
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
|
||||
GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n")
|
||||
return linkVal, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("URL not found in Jumo response")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||
@@ -421,7 +464,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find exact ISRC match
|
||||
for i := range result.Tracks.Items {
|
||||
if result.Tracks.Items[i].ISRC == isrc {
|
||||
return &result.Tracks.Items[i], nil
|
||||
@@ -435,7 +477,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||
}
|
||||
|
||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||
|
||||
@@ -468,7 +509,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
|
||||
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||
|
||||
// Find ISRC matches
|
||||
var isrcMatches []*QobuzTrack
|
||||
for i := range result.Tracks.Items {
|
||||
if result.Tracks.Items[i].ISRC == isrc {
|
||||
@@ -522,35 +562,26 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
||||
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||
}
|
||||
|
||||
// Now includes romaji conversion for Japanese text (same as Tidal)
|
||||
// Also includes title verification to prevent wrong song downloads
|
||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
|
||||
// Try multiple search strategies (same as Tidal/PC version)
|
||||
queries := []string{}
|
||||
|
||||
// Strategy 1: Artist + Track name
|
||||
if artistName != "" && trackName != "" {
|
||||
queries = append(queries, artistName+" "+trackName)
|
||||
}
|
||||
|
||||
// Strategy 2: Track name only
|
||||
if trackName != "" {
|
||||
queries = append(queries, trackName)
|
||||
}
|
||||
|
||||
// Strategy 3: Romaji versions if Japanese detected
|
||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||
// Convert to romaji (hiragana/katakana only, kanji stays)
|
||||
romajiTrack := JapaneseToRomaji(trackName)
|
||||
romajiArtist := JapaneseToRomaji(artistName)
|
||||
|
||||
// Clean and remove ALL non-ASCII characters (including kanji)
|
||||
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||
|
||||
// Artist + Track romaji (cleaned to ASCII only)
|
||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||
if !containsQueryQobuz(queries, romajiQuery) {
|
||||
@@ -559,7 +590,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
}
|
||||
}
|
||||
|
||||
// Track romaji only (cleaned)
|
||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||
if !containsQueryQobuz(queries, cleanRomajiTrack) {
|
||||
queries = append(queries, cleanRomajiTrack)
|
||||
@@ -567,7 +597,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Artist only as last resort
|
||||
if artistName != "" {
|
||||
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
|
||||
@@ -626,7 +655,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||
}
|
||||
|
||||
// Filter by title match first (NEW - like Tidal)
|
||||
var titleMatches []*QobuzTrack
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
@@ -637,7 +665,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
|
||||
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
||||
|
||||
// If no title matches, log warning but continue with all tracks
|
||||
tracksToCheck := titleMatches
|
||||
if len(titleMatches) == 0 {
|
||||
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
||||
@@ -646,7 +673,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
}
|
||||
}
|
||||
|
||||
// If duration verification is requested
|
||||
if expectedDurationSec > 0 {
|
||||
var durationMatches []*QobuzTrack
|
||||
for _, track := range tracksToCheck {
|
||||
@@ -662,12 +688,12 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
if len(durationMatches) > 0 {
|
||||
for _, track := range durationMatches {
|
||||
if track.MaximumBitDepth >= 24 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||
track.Title, track.Performer.Name)
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
||||
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n",
|
||||
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
||||
return durationMatches[0], nil
|
||||
}
|
||||
@@ -675,17 +701,16 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
||||
}
|
||||
|
||||
// No duration verification, return best quality from title matches
|
||||
for _, track := range tracksToCheck {
|
||||
if track.MaximumBitDepth >= 24 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||
track.Title, track.Performer.Name)
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(tracksToCheck) > 0 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
||||
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n",
|
||||
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
||||
return tracksToCheck[0], nil
|
||||
}
|
||||
@@ -693,7 +718,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||
}
|
||||
|
||||
// qobuzAPIResult holds the result from a parallel API request
|
||||
type qobuzAPIResult struct {
|
||||
apiURL string
|
||||
downloadURL string
|
||||
@@ -711,7 +735,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
||||
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
|
||||
// Start all requests in parallel
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
@@ -744,13 +767,11 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
||||
return
|
||||
}
|
||||
|
||||
// Check if response is HTML (error page)
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for error in JSON response
|
||||
var errorResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
@@ -776,15 +797,13 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
// Collect results - return first success
|
||||
var errors []string
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil {
|
||||
GoLog("[Qobuz] [Parallel] ✓ Got response from %s in %v\n", result.apiURL, result.duration)
|
||||
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration)
|
||||
|
||||
// Drain remaining results to avoid goroutine leaks
|
||||
go func(remaining int) {
|
||||
for j := 0; j < remaining; j++ {
|
||||
<-resultChan
|
||||
@@ -812,18 +831,38 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
}
|
||||
|
||||
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
if err == nil {
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
return downloadURL, nil
|
||||
GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
|
||||
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
|
||||
if jumoErr == nil {
|
||||
return jumoURL, nil
|
||||
}
|
||||
|
||||
if quality == "27" {
|
||||
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
|
||||
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
|
||||
if jumoErr == nil {
|
||||
return jumoURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
if quality == "27" || quality == "7" {
|
||||
GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n")
|
||||
jumoURL, jumoErr = q.downloadFromJumo(trackID, "6")
|
||||
if jumoErr == nil {
|
||||
return jumoURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -873,7 +912,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
// Flush buffer before checking for errors
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
@@ -893,7 +931,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
// Verify file size if Content-Length was provided
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
@@ -941,11 +978,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||
@@ -954,11 +989,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 1: Search by ISRC with duration verification
|
||||
if track == nil && req.ISRC != "" {
|
||||
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||
// Verify artist AND title
|
||||
if track != nil {
|
||||
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
@@ -972,10 +1005,8 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Search by metadata with duration verification (includes title verification)
|
||||
if track == nil {
|
||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, track.Performer.Name)
|
||||
@@ -991,7 +1022,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Log match found and cache the track ID
|
||||
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
@@ -1012,22 +1042,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
|
||||
// Map quality from Tidal format to Qobuz format
|
||||
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
|
||||
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
|
||||
qobuzQuality := "27" // Default to highest quality
|
||||
qobuzQuality := "27"
|
||||
switch req.Quality {
|
||||
case "LOSSLESS":
|
||||
qobuzQuality = "6" // 16-bit FLAC
|
||||
qobuzQuality = "6"
|
||||
case "HI_RES":
|
||||
qobuzQuality = "7" // 24-bit 96kHz
|
||||
qobuzQuality = "7"
|
||||
case "HI_RES_LOSSLESS":
|
||||
qobuzQuality = "27" // 24-bit 192kHz
|
||||
qobuzQuality = "27"
|
||||
}
|
||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||
|
||||
actualBitDepth := track.MaximumBitDepth
|
||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||
actualSampleRate := int(track.MaximumSamplingRate * 1000)
|
||||
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||
@@ -1035,7 +1062,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
@@ -1051,7 +1077,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
)
|
||||
}()
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||
@@ -1059,7 +1084,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
@@ -1072,7 +1096,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
albumName = req.AlbumName
|
||||
}
|
||||
|
||||
// Use track number from request if available, otherwise from Qobuz API
|
||||
actualTrackNumber := req.TrackNumber
|
||||
if actualTrackNumber == 0 {
|
||||
actualTrackNumber = track.TrackNumber
|
||||
@@ -1082,15 +1105,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
Title: track.Title,
|
||||
Artist: track.Performer.Name,
|
||||
Album: albumName,
|
||||
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: track.Album.ReleaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre, // From Deezer album metadata
|
||||
Label: req.Label, // From Deezer album metadata
|
||||
Copyright: req.Copyright, // From Deezer album metadata
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
@@ -1130,7 +1153,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||
}
|
||||
|
||||
// Add to ISRC index for fast duplicate checking
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
|
||||
return QobuzDownloadResult{
|
||||
@@ -1142,7 +1164,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
Album: track.Album.Title,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+28
-27
@@ -43,10 +43,6 @@ func NewSongLinkClient() *SongLinkClient {
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
if spotifyTrackID == "" {
|
||||
return nil, fmt.Errorf("spotify track ID is empty")
|
||||
}
|
||||
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
@@ -115,10 +111,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if isrc != "" {
|
||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -191,11 +183,11 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if !availability.Deezer || availability.DeezerID == "" {
|
||||
return "", fmt.Errorf("track not found on Deezer")
|
||||
}
|
||||
|
||||
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
@@ -208,10 +200,8 @@ type AlbumAvailability struct {
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||
// Use global rate limiter
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
// Build API URL for album
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||
|
||||
@@ -268,11 +258,11 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if !availability.Deezer || availability.DeezerID == "" {
|
||||
return "", fmt.Errorf("album not found on Deezer")
|
||||
}
|
||||
|
||||
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
@@ -281,7 +271,23 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
if deezerTrackID == "" {
|
||||
return nil, fmt.Errorf("deezer track ID is empty")
|
||||
}
|
||||
|
||||
|
||||
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
||||
if err != nil {
|
||||
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
|
||||
idhsClient := NewIDHSClient()
|
||||
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
||||
}
|
||||
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
@@ -301,7 +307,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle specific error codes
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||
}
|
||||
@@ -369,12 +374,9 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
if entityID == "" {
|
||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||
}
|
||||
|
||||
// Use global rate limiter
|
||||
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
// Build API URL using platform, type, and id parameters (as per API docs)
|
||||
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||
url.QueryEscape(platform),
|
||||
url.QueryEscape(entityType),
|
||||
@@ -392,7 +394,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle specific error codes
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||
}
|
||||
@@ -464,11 +465,11 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if availability.SpotifyID == "" {
|
||||
return "", fmt.Errorf("track not found on Spotify")
|
||||
}
|
||||
|
||||
|
||||
return availability.SpotifyID, nil
|
||||
}
|
||||
|
||||
@@ -478,11 +479,11 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if !availability.Tidal || availability.TidalURL == "" {
|
||||
return "", fmt.Errorf("track not found on Tidal")
|
||||
}
|
||||
|
||||
|
||||
return availability.TidalURL, nil
|
||||
}
|
||||
|
||||
@@ -491,10 +492,10 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return "", fmt.Errorf("track not found on Amazon Music")
|
||||
}
|
||||
|
||||
|
||||
return availability.AmazonURL, nil
|
||||
}
|
||||
|
||||
+22
-4
@@ -66,7 +66,6 @@ var (
|
||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||
|
||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
credentialsMu.Lock()
|
||||
defer credentialsMu.Unlock()
|
||||
@@ -89,7 +88,6 @@ func HasSpotifyCredentials() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// getCredentials returns the current credentials or error if not configured
|
||||
func getCredentials() (string, string, error) {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
@@ -238,9 +236,29 @@ type SearchArtistResult struct {
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
type SearchAlbumResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Images string `json:"images"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
AlbumType string `json:"album_type"`
|
||||
}
|
||||
|
||||
type SearchPlaylistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Images string `json:"images"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
}
|
||||
|
||||
type SearchAllResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
Albums []SearchAlbumResult `json:"albums"`
|
||||
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||
}
|
||||
|
||||
type spotifyURI struct {
|
||||
|
||||
+88
-37
@@ -119,17 +119,18 @@ func NewTidalDownloader() *TidalDownloader {
|
||||
return globalTidalDownloader
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available Tidal APIs
|
||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||
encodedAPIs := []string{
|
||||
"dGlkYWwua2lub3BsdXMub25saW5l",
|
||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
|
||||
"dHJpdG9uLnNxdWlkLnd0Zg==",
|
||||
"dm9nZWwucXFkbC5zaXRl",
|
||||
"bWF1cy5xcWRsLnNpdGU=",
|
||||
"aHVuZC5xcWRsLnNpdGU=",
|
||||
"a2F0emUucXFkbC5zaXRl",
|
||||
"d29sZi5xcWRsLnNpdGU=",
|
||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
|
||||
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
|
||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
||||
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
||||
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
||||
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
||||
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
||||
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
||||
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
|
||||
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
|
||||
}
|
||||
|
||||
var apis []string
|
||||
@@ -249,7 +250,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
||||
return trackID, nil
|
||||
}
|
||||
|
||||
// GetTrackInfoByID gets track info by Tidal track ID
|
||||
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
if err != nil {
|
||||
@@ -442,13 +442,13 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff <= 3 {
|
||||
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
||||
GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title)
|
||||
return track, nil
|
||||
}
|
||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||
expectedDuration, track.Duration)
|
||||
} else {
|
||||
GoLog("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
|
||||
GoLog("[Tidal] ISRC match: '%s'\n", track.Title)
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
@@ -487,7 +487,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
|
||||
if len(durationVerifiedMatches) > 0 {
|
||||
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||
GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||
return durationVerifiedMatches[0], nil
|
||||
}
|
||||
@@ -498,11 +498,11 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
expectedDuration, isrcMatches[0].Duration)
|
||||
}
|
||||
|
||||
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
return isrcMatches[0], nil
|
||||
}
|
||||
|
||||
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
||||
GoLog("[Tidal] No ISRC match found for: %s\n", spotifyISRC)
|
||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||
}
|
||||
|
||||
@@ -669,7 +669,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil {
|
||||
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
||||
GoLog("[Tidal] [Parallel] Got response from %s (%d-bit/%dHz) in %v\n",
|
||||
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
||||
|
||||
go func(remaining int) {
|
||||
@@ -795,7 +795,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
return "", initURL, mediaURLs, nil
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file from URL with progress tracking
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -968,7 +967,15 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
return nil
|
||||
}
|
||||
|
||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
// For DASH format, determine correct M4A path
|
||||
// If outputPath already ends with .m4a, use it directly
|
||||
// Otherwise, convert .flac to .m4a
|
||||
var m4aPath string
|
||||
if strings.HasSuffix(outputPath, ".m4a") {
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
}
|
||||
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||
|
||||
out, err := os.Create(m4aPath)
|
||||
@@ -1094,6 +1101,7 @@ type TidalDownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string // LRC content for embedding in converted files
|
||||
}
|
||||
|
||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
@@ -1104,7 +1112,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
||||
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
||||
return true
|
||||
}
|
||||
@@ -1163,7 +1170,6 @@ func sameWordsUnordered(a, b string) bool {
|
||||
wordsA := strings.Fields(a)
|
||||
wordsB := strings.Fields(b)
|
||||
|
||||
// Must have same number of words
|
||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||
return false
|
||||
}
|
||||
@@ -1196,7 +1202,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
@@ -1205,7 +1210,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Clean both titles and compare
|
||||
cleanExpected := cleanTitle(normExpected)
|
||||
cleanFound := cleanTitle(normFound)
|
||||
|
||||
@@ -1219,7 +1223,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract core title (before any parentheses/brackets)
|
||||
coreExpected := extractCoreTitle(normExpected)
|
||||
coreFound := extractCoreTitle(normFound)
|
||||
|
||||
@@ -1227,7 +1230,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||
expectedLatin := isLatinScript(expectedTitle)
|
||||
foundLatin := isLatinScript(foundTitle)
|
||||
if expectedLatin != foundLatin {
|
||||
@@ -1500,6 +1502,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||
}
|
||||
|
||||
quality := req.Quality
|
||||
if quality == "" {
|
||||
quality = "LOSSLESS"
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -1508,15 +1515,26 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
var outputPath string
|
||||
var m4aPath string
|
||||
if quality == "HIGH" {
|
||||
filename = sanitizeFilename(filename) + ".m4a"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
}
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
if quality != "HIGH" {
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
@@ -1525,10 +1543,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
quality := req.Quality
|
||||
if quality == "" {
|
||||
quality = "LOSSLESS"
|
||||
}
|
||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||
|
||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||
@@ -1591,7 +1605,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
|
||||
}
|
||||
|
||||
// Use track number from request if available, otherwise from Tidal API
|
||||
actualTrackNumber := req.TrackNumber
|
||||
actualDiscNumber := req.DiscNumber
|
||||
if actualTrackNumber == 0 {
|
||||
@@ -1654,15 +1667,52 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||
if quality == "HIGH" {
|
||||
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||
}
|
||||
}
|
||||
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
|
||||
bitDepth := downloadInfo.BitDepth
|
||||
sampleRate := downloadInfo.SampleRate
|
||||
lyricsLRC := ""
|
||||
if quality == "HIGH" {
|
||||
bitDepth = 0
|
||||
sampleRate = 44100
|
||||
if parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TidalDownloadResult{
|
||||
FilePath: actualOutputPath,
|
||||
BitDepth: downloadInfo.BitDepth,
|
||||
SampleRate: downloadInfo.SampleRate,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: track.Title,
|
||||
Artist: track.Artist.Name,
|
||||
Album: track.Album.Title,
|
||||
@@ -1670,5 +1720,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: actualDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -222,7 +222,8 @@ import Gobackend // Import Go framework
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.2.1';
|
||||
static const String buildNumber = '64';
|
||||
static const String version = '3.3.5';
|
||||
static const String buildNumber = '70';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
+135
-15
@@ -952,6 +952,12 @@ abstract class AppLocalizations {
|
||||
/// **'The original HiFi project creator. The foundation of Tidal integration!'**
|
||||
String get aboutSachinsenalDesc;
|
||||
|
||||
/// Credit description for sjdonado
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'**
|
||||
String get aboutSjdonadoDesc;
|
||||
|
||||
/// Name of Amazon API service - DO NOT TRANSLATE
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1306,6 +1312,12 @@ abstract class AppLocalizations {
|
||||
/// **'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'**
|
||||
String get setupIosEmptyFolderWarning;
|
||||
|
||||
/// Error when user selects iCloud Drive on iOS
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'iCloud Drive is not supported. Please use the app Documents folder.'**
|
||||
String get setupIcloudNotSupported;
|
||||
|
||||
/// App tagline in setup
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3394,35 +3406,65 @@ abstract class AppLocalizations {
|
||||
/// **'24-bit / up to 192kHz'**
|
||||
String get qualityHiResFlacMaxSubtitle;
|
||||
|
||||
/// Quality option - MP3 lossy format
|
||||
/// Quality option - lossy format (MP3/Opus)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3'**
|
||||
String get qualityMp3;
|
||||
/// **'Lossy'**
|
||||
String get qualityLossy;
|
||||
|
||||
/// Technical spec for MP3
|
||||
/// Technical spec for lossy MP3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'320kbps (converted from FLAC)'**
|
||||
String get qualityMp3Subtitle;
|
||||
/// **'MP3 320kbps (converted from FLAC)'**
|
||||
String get qualityLossyMp3Subtitle;
|
||||
|
||||
/// Setting - enable MP3 quality option
|
||||
/// Technical spec for lossy Opus
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable MP3 Option'**
|
||||
String get enableMp3Option;
|
||||
/// **'Opus 128kbps (converted from FLAC)'**
|
||||
String get qualityLossyOpusSubtitle;
|
||||
|
||||
/// Subtitle when MP3 is enabled
|
||||
/// Setting - enable lossy quality option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3 quality option is available'**
|
||||
String get enableMp3OptionSubtitleOn;
|
||||
/// **'Enable Lossy Option'**
|
||||
String get enableLossyOption;
|
||||
|
||||
/// Subtitle when MP3 is disabled
|
||||
/// Subtitle when lossy is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloads FLAC then converts to 320kbps MP3'**
|
||||
String get enableMp3OptionSubtitleOff;
|
||||
/// **'Lossy quality option is available'**
|
||||
String get enableLossyOptionSubtitleOn;
|
||||
|
||||
/// Subtitle when lossy is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloads FLAC then converts to lossy format'**
|
||||
String get enableLossyOptionSubtitleOff;
|
||||
|
||||
/// Setting - choose lossy format
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy Format'**
|
||||
String get lossyFormat;
|
||||
|
||||
/// Description for lossy format picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose the lossy format for conversion'**
|
||||
String get lossyFormatDescription;
|
||||
|
||||
/// MP3 format description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'320kbps, best compatibility'**
|
||||
String get lossyFormatMp3Subtitle;
|
||||
|
||||
/// Opus format description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'128kbps, better quality at smaller size'**
|
||||
String get lossyFormatOpusSubtitle;
|
||||
|
||||
/// Note about quality availability
|
||||
///
|
||||
@@ -3610,6 +3652,42 @@ abstract class AppLocalizations {
|
||||
/// **'Are you sure you want to clear all downloads?'**
|
||||
String get queueClearAllMessage;
|
||||
|
||||
/// Button - export failed downloads to TXT
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export'**
|
||||
String get queueExportFailed;
|
||||
|
||||
/// Success message after exporting failed downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed downloads exported to TXT file'**
|
||||
String get queueExportFailedSuccess;
|
||||
|
||||
/// Action to clear failed downloads after export
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear Failed'**
|
||||
String get queueExportFailedClear;
|
||||
|
||||
/// Error message when export fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to export downloads'**
|
||||
String get queueExportFailedError;
|
||||
|
||||
/// Setting toggle for auto-export
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto-export failed downloads'**
|
||||
String get settingsAutoExportFailed;
|
||||
|
||||
/// Subtitle for auto-export setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save failed downloads to TXT file automatically'**
|
||||
String get settingsAutoExportFailedSubtitle;
|
||||
|
||||
/// Empty queue state title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3921,6 +3999,48 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to fetch some albums'**
|
||||
String get discographyFailedToFetch;
|
||||
|
||||
/// Section header for storage access settings
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Storage Access'**
|
||||
String get sectionStorageAccess;
|
||||
|
||||
/// Toggle for MANAGE_EXTERNAL_STORAGE permission
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All Files Access'**
|
||||
String get allFilesAccess;
|
||||
|
||||
/// Subtitle when all files access is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Can write to any folder'**
|
||||
String get allFilesAccessEnabledSubtitle;
|
||||
|
||||
/// Subtitle when all files access is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Limited to media folders only'**
|
||||
String get allFilesAccessDisabledSubtitle;
|
||||
|
||||
/// Description explaining when to enable all files access
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.'**
|
||||
String get allFilesAccessDescription;
|
||||
|
||||
/// Message when permission is permanently denied
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Permission was denied. Please enable \'All files access\' manually in system settings.'**
|
||||
String get allFilesAccessDeniedMessage;
|
||||
|
||||
/// Snackbar message when user disables all files access
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All Files Access disabled. The app will use limited storage access.'**
|
||||
String get allFilesAccessDisabledMessage;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -112,7 +112,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Einzelne Titel-Downloads werden hier angezeigt';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
String get historySearchHint => 'Suchverlauf...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Einstellungen';
|
||||
@@ -416,7 +416,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
String get aboutTranslators => 'Übersetzer';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Besonderer Dank';
|
||||
@@ -445,19 +445,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Schlage neue Funktionen für die App vor';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
String get aboutTelegramChannel => 'Telegram Kanal';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
String get aboutTelegramChannelSubtitle => 'Ankündigungen und Updates';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
String get aboutTelegramChatSubtitle => 'Mit anderen Nutzern chatten';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
String get aboutSocial => 'Sozial';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
@@ -483,6 +483,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -499,7 +503,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
|
||||
|
||||
@override
|
||||
String get albumTitle => 'Album';
|
||||
@@ -509,246 +513,252 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
other: '$count Songs',
|
||||
one: '1 Song',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get albumDownloadAll => 'Download All';
|
||||
String get albumDownloadAll => 'Alle Herunterladen';
|
||||
|
||||
@override
|
||||
String get albumDownloadRemaining => 'Download Remaining';
|
||||
String get albumDownloadRemaining => 'Downloads verbleibend';
|
||||
|
||||
@override
|
||||
String get playlistTitle => 'Playlist';
|
||||
|
||||
@override
|
||||
String get artistTitle => 'Artist';
|
||||
String get artistTitle => 'Künstler';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
String get artistAlbums => 'Alben';
|
||||
|
||||
@override
|
||||
String get artistSingles => 'Singles & EPs';
|
||||
|
||||
@override
|
||||
String get artistCompilations => 'Compilations';
|
||||
String get artistCompilations => 'Zusammenstellungen';
|
||||
|
||||
@override
|
||||
String artistReleases(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count releases',
|
||||
one: '1 release',
|
||||
other: '$count Veröffentlichungen',
|
||||
one: '1 Veröffentlichung',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get artistPopular => 'Popular';
|
||||
String get artistPopular => 'Beliebt';
|
||||
|
||||
@override
|
||||
String artistMonthlyListeners(String count) {
|
||||
return '$count monthly listeners';
|
||||
return '$count monatliche Hörer';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackMetadataTitle => 'Track Info';
|
||||
String get trackMetadataTitle => 'Titel Info';
|
||||
|
||||
@override
|
||||
String get trackMetadataArtist => 'Artist';
|
||||
String get trackMetadataArtist => 'Künstler';
|
||||
|
||||
@override
|
||||
String get trackMetadataAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get trackMetadataDuration => 'Duration';
|
||||
String get trackMetadataDuration => 'Länge';
|
||||
|
||||
@override
|
||||
String get trackMetadataQuality => 'Quality';
|
||||
String get trackMetadataQuality => 'Qualität';
|
||||
|
||||
@override
|
||||
String get trackMetadataPath => 'File Path';
|
||||
String get trackMetadataPath => 'Dateipfad';
|
||||
|
||||
@override
|
||||
String get trackMetadataDownloadedAt => 'Downloaded';
|
||||
String get trackMetadataDownloadedAt => 'Heruntergeladen';
|
||||
|
||||
@override
|
||||
String get trackMetadataService => 'Service';
|
||||
String get trackMetadataService => 'Anbieter';
|
||||
|
||||
@override
|
||||
String get trackMetadataPlay => 'Play';
|
||||
String get trackMetadataPlay => 'Abspielen';
|
||||
|
||||
@override
|
||||
String get trackMetadataShare => 'Share';
|
||||
String get trackMetadataShare => 'Teilen';
|
||||
|
||||
@override
|
||||
String get trackMetadataDelete => 'Delete';
|
||||
String get trackMetadataDelete => 'Löschen';
|
||||
|
||||
@override
|
||||
String get trackMetadataRedownload => 'Re-download';
|
||||
String get trackMetadataRedownload => 'Erneut herunterladen';
|
||||
|
||||
@override
|
||||
String get trackMetadataOpenFolder => 'Open Folder';
|
||||
String get trackMetadataOpenFolder => 'Ordner öffnen';
|
||||
|
||||
@override
|
||||
String get setupTitle => 'Welcome to SpotiFLAC';
|
||||
String get setupTitle => 'Willkommen bei SpotiFLAC';
|
||||
|
||||
@override
|
||||
String get setupSubtitle => 'Let\'s get you started';
|
||||
String get setupSubtitle => 'Los geht\'s';
|
||||
|
||||
@override
|
||||
String get setupStoragePermission => 'Storage Permission';
|
||||
String get setupStoragePermission => 'Speicherberechtigung';
|
||||
|
||||
@override
|
||||
String get setupStoragePermissionSubtitle =>
|
||||
'Required to save downloaded files';
|
||||
'Benötigt um heruntergeladene Dateien zu Speichern';
|
||||
|
||||
@override
|
||||
String get setupStoragePermissionGranted => 'Permission granted';
|
||||
String get setupStoragePermissionGranted => 'Berechtigung erteilt';
|
||||
|
||||
@override
|
||||
String get setupStoragePermissionDenied => 'Permission denied';
|
||||
String get setupStoragePermissionDenied => 'Berechtigung verweigert';
|
||||
|
||||
@override
|
||||
String get setupGrantPermission => 'Grant Permission';
|
||||
String get setupGrantPermission => 'Berechtigung erlauben';
|
||||
|
||||
@override
|
||||
String get setupDownloadLocation => 'Download Location';
|
||||
String get setupDownloadLocation => 'Speicherort';
|
||||
|
||||
@override
|
||||
String get setupChooseFolder => 'Choose Folder';
|
||||
String get setupChooseFolder => 'Ordner wählen';
|
||||
|
||||
@override
|
||||
String get setupContinue => 'Continue';
|
||||
String get setupContinue => 'Fortfahren';
|
||||
|
||||
@override
|
||||
String get setupSkip => 'Skip for now';
|
||||
String get setupSkip => 'Vorerst überspringen';
|
||||
|
||||
@override
|
||||
String get setupStorageAccessRequired => 'Storage Access Required';
|
||||
String get setupStorageAccessRequired => 'Speicherzugriff erforderlich';
|
||||
|
||||
@override
|
||||
String get setupStorageAccessMessage =>
|
||||
'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.';
|
||||
'SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.';
|
||||
|
||||
@override
|
||||
String get setupStorageAccessMessageAndroid11 =>
|
||||
'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.';
|
||||
'Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.';
|
||||
|
||||
@override
|
||||
String get setupOpenSettings => 'Open Settings';
|
||||
String get setupOpenSettings => 'Einstellungen öffnen';
|
||||
|
||||
@override
|
||||
String get setupPermissionDeniedMessage =>
|
||||
'Permission denied. Please grant all permissions to continue.';
|
||||
'Berechtigung verweigert. Bitte erteilen Sie alle Berechtigungen um fortzufahren.';
|
||||
|
||||
@override
|
||||
String setupPermissionRequired(String permissionType) {
|
||||
return '$permissionType Permission Required';
|
||||
return '$permissionType Zugriff verweigert';
|
||||
}
|
||||
|
||||
@override
|
||||
String setupPermissionRequiredMessage(String permissionType) {
|
||||
return '$permissionType permission is required for the best experience. You can change this later in Settings.';
|
||||
return '$permissionType Berechtigung ist erforderlich für\ndie beste Benutzererfahrung. Für kannst dies später in den Einstellungen ändern.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get setupSelectDownloadFolder => 'Select Download Folder';
|
||||
String get setupSelectDownloadFolder => 'Wähle Download-Ordner aus';
|
||||
|
||||
@override
|
||||
String get setupUseDefaultFolder => 'Use Default Folder?';
|
||||
String get setupUseDefaultFolder => 'Als Standardordner verwenden?';
|
||||
|
||||
@override
|
||||
String get setupNoFolderSelected =>
|
||||
'No folder selected. Would you like to use the default Music folder?';
|
||||
'Kein Ordner ausgewählt. Soll der Standard-Musikordner verwendet werden?';
|
||||
|
||||
@override
|
||||
String get setupUseDefault => 'Use Default';
|
||||
String get setupUseDefault => 'Standart benutzen';
|
||||
|
||||
@override
|
||||
String get setupDownloadLocationTitle => 'Download Location';
|
||||
String get setupDownloadLocationTitle => 'Speicherort';
|
||||
|
||||
@override
|
||||
String get setupDownloadLocationIosMessage =>
|
||||
'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.';
|
||||
'Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Sie können sie über die Datei-App aufrufen.';
|
||||
|
||||
@override
|
||||
String get setupAppDocumentsFolder => 'App Documents Folder';
|
||||
String get setupAppDocumentsFolder => 'App-Dokumentenordner';
|
||||
|
||||
@override
|
||||
String get setupAppDocumentsFolderSubtitle =>
|
||||
'Recommended - accessible via Files app';
|
||||
'Empfohlen - zugänglich über die Datei-App';
|
||||
|
||||
@override
|
||||
String get setupChooseFromFiles => 'Choose from Files';
|
||||
String get setupChooseFromFiles => 'Aus Dateien auswählen';
|
||||
|
||||
@override
|
||||
String get setupChooseFromFilesSubtitle => 'Select iCloud or other location';
|
||||
String get setupChooseFromFilesSubtitle =>
|
||||
'Wählen Sie iCloud oder einen anderen Ort';
|
||||
|
||||
@override
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||
'iOS-Einschränkung: Leere Ordner können nicht ausgewählt werden. Wählen Sie einen Ordner mit mindestens einer Datei.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupStepStorage => 'Storage';
|
||||
String get setupDownloadInFlac => 'Spotify Titel in FLAC herunterladen';
|
||||
|
||||
@override
|
||||
String get setupStepNotification => 'Notification';
|
||||
String get setupStepStorage => 'Speicherort';
|
||||
|
||||
@override
|
||||
String get setupStepFolder => 'Folder';
|
||||
String get setupStepNotification => 'Benachrichtigung';
|
||||
|
||||
@override
|
||||
String get setupStepFolder => 'Ordner';
|
||||
|
||||
@override
|
||||
String get setupStepSpotify => 'Spotify';
|
||||
|
||||
@override
|
||||
String get setupStepPermission => 'Permission';
|
||||
String get setupStepPermission => 'Berechtigung';
|
||||
|
||||
@override
|
||||
String get setupStorageGranted => 'Storage Permission Granted!';
|
||||
String get setupStorageGranted => 'Speicherberechtigung erlaubt!';
|
||||
|
||||
@override
|
||||
String get setupStorageRequired => 'Storage Permission Required';
|
||||
String get setupStorageRequired => 'Speicherzugriff erforderlich';
|
||||
|
||||
@override
|
||||
String get setupStorageDescription =>
|
||||
'SpotiFLAC needs storage permission to save your downloaded music files.';
|
||||
'SpotiFLAC benötigt Speicherrechte, um die heruntergeladenen Musikdateien zu speichern.';
|
||||
|
||||
@override
|
||||
String get setupNotificationGranted => 'Notification Permission Granted!';
|
||||
String get setupNotificationGranted =>
|
||||
'Benachrichtigungs-Berechtigung erteilt';
|
||||
|
||||
@override
|
||||
String get setupNotificationEnable => 'Enable Notifications';
|
||||
String get setupNotificationEnable => 'Benachrichtigungen aktivieren';
|
||||
|
||||
@override
|
||||
String get setupNotificationDescription =>
|
||||
'Get notified when downloads complete or require attention.';
|
||||
'Benachrichtigt werden, wenn Downloads abgeschlossen sind.';
|
||||
|
||||
@override
|
||||
String get setupFolderSelected => 'Download Folder Selected!';
|
||||
String get setupFolderSelected => 'Download Ordner ausgewählt!';
|
||||
|
||||
@override
|
||||
String get setupFolderChoose => 'Choose Download Folder';
|
||||
String get setupFolderChoose => 'Speicherort auwählen';
|
||||
|
||||
@override
|
||||
String get setupFolderDescription =>
|
||||
'Select a folder where your downloaded music will be saved.';
|
||||
'Wählen Sie einen Ordner, in dem Ihre heruntergeladene Musik gespeichert wird.';
|
||||
|
||||
@override
|
||||
String get setupChangeFolder => 'Change Folder';
|
||||
String get setupChangeFolder => 'Ordner ändern';
|
||||
|
||||
@override
|
||||
String get setupSelectFolder => 'Select Folder';
|
||||
String get setupSelectFolder => 'Ordner wählen';
|
||||
|
||||
@override
|
||||
String get setupSpotifyApiOptional => 'Spotify API (Optional)';
|
||||
String get setupSpotifyApiOptional => 'Spotify-API (optional)';
|
||||
|
||||
@override
|
||||
String get setupSpotifyApiDescription =>
|
||||
@@ -1869,20 +1879,36 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1979,6 +2005,26 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Are you sure you want to clear all downloads?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2177,4 +2223,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
@@ -470,6 +470,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -680,6 +684,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||
|
||||
@@ -1856,20 +1864,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1966,6 +1990,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Are you sure you want to clear all downloads?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2164,4 +2208,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
@@ -470,6 +470,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -680,6 +684,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||
|
||||
@@ -1856,20 +1864,36 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1966,6 +1990,26 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Are you sure you want to clear all downloads?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2164,6 +2208,30 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
|
||||
@@ -470,6 +470,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -680,6 +684,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||
|
||||
@@ -1856,20 +1864,36 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1966,6 +1990,26 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Are you sure you want to clear all downloads?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2164,4 +2208,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
@@ -9,20 +9,20 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
AppLocalizationsHi([String locale = 'hi']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'SpotiFLAC';
|
||||
String get appName => 'SpotiFlac';
|
||||
|
||||
@override
|
||||
String get appDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।';
|
||||
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
String get navHome => 'होम';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
String get navHistory => 'इतिहास';
|
||||
|
||||
@override
|
||||
String get navSettings => 'Settings';
|
||||
String get navSettings => 'विकल्प';
|
||||
|
||||
@override
|
||||
String get navStore => 'Store';
|
||||
@@ -184,7 +184,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get quality128 => '128 kbps';
|
||||
|
||||
@override
|
||||
String get appearanceTitle => 'Appearance';
|
||||
String get appearanceTitle => 'दिखावट';
|
||||
|
||||
@override
|
||||
String get appearanceTheme => 'Theme';
|
||||
@@ -199,10 +199,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get appearanceThemeDark => 'Dark';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColor => 'Dynamic Color';
|
||||
String get appearanceDynamicColor => 'डायनेमिक रंग';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
||||
String get appearanceDynamicColorSubtitle => 'वॉलपेपर से रंग इस्तेमाल करें';
|
||||
|
||||
@override
|
||||
String get appearanceAccentColor => 'Accent Color';
|
||||
@@ -470,6 +470,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -680,6 +684,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||
|
||||
@@ -1856,20 +1864,36 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1966,6 +1990,26 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Are you sure you want to clear all downloads?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2164,4 +2208,28 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
@@ -475,6 +475,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -685,6 +689,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.';
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
|
||||
|
||||
@@ -1868,20 +1876,36 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (konversi dari FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Aktifkan Opsi MP3';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'Opsi kualitas MP3 tersedia';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Unduh FLAC lalu konversi ke MP3 320kbps';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1979,6 +2003,26 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Apakah Anda yakin ingin menghapus semua unduhan?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Tidak ada unduhan dalam antrian';
|
||||
|
||||
@@ -2113,68 +2157,92 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Unduh Diskografi';
|
||||
String get discographyDownload => 'Download Discography';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Unduh Semua';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count lagu dari $albumCount rilis';
|
||||
return '$count tracks from $albumCount releases';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Album Saja';
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count lagu dari $albumCount album';
|
||||
return '$count tracks from $albumCount albums';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Single & EP Saja';
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count lagu dari $albumCount single';
|
||||
return '$count tracks from $albumCount singles';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Pilih Album...';
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Pilih album atau single tertentu';
|
||||
'Choose specific albums or singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Mengambil lagu...';
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Mengambil $current dari $total...';
|
||||
return 'Fetching $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count dipilih';
|
||||
return '$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Unduh yang Dipilih';
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Menambahkan $count lagu ke antrian';
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added ditambahkan, $skipped sudah diunduh';
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'Tidak ada album tersedia';
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
+493
-439
File diff suppressed because it is too large
Load Diff
@@ -470,6 +470,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -680,6 +684,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||
|
||||
@@ -1856,20 +1864,36 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1966,6 +1990,26 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Are you sure you want to clear all downloads?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2164,4 +2208,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
@@ -470,6 +470,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -680,6 +684,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||
|
||||
@@ -1856,20 +1864,36 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1966,6 +1990,26 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Are you sure you want to clear all downloads?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2164,4 +2208,28 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
+230
-155
@@ -470,6 +470,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -680,6 +684,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||
|
||||
@@ -1856,20 +1864,36 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1966,6 +1990,26 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Are you sure you want to clear all downloads?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2164,6 +2208,30 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
@@ -2833,32 +2901,32 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
'Limitação do iOS: Pastas vazias não podem ser selecionadas. Escolha uma pasta com pelo menos um arquivo.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||
String get setupDownloadInFlac => 'Baixe faixas do Spotify em FLAC';
|
||||
|
||||
@override
|
||||
String get setupStepStorage => 'Storage';
|
||||
String get setupStepStorage => 'Armazenamento';
|
||||
|
||||
@override
|
||||
String get setupStepNotification => 'Notification';
|
||||
String get setupStepNotification => 'Notificação';
|
||||
|
||||
@override
|
||||
String get setupStepFolder => 'Folder';
|
||||
String get setupStepFolder => 'Pasta';
|
||||
|
||||
@override
|
||||
String get setupStepSpotify => 'Spotify';
|
||||
|
||||
@override
|
||||
String get setupStepPermission => 'Permission';
|
||||
String get setupStepPermission => 'Permissão';
|
||||
|
||||
@override
|
||||
String get setupStorageGranted => 'Storage Permission Granted!';
|
||||
String get setupStorageGranted => 'Permissão de Armazenamento Concedida!';
|
||||
|
||||
@override
|
||||
String get setupStorageRequired => 'Storage Permission Required';
|
||||
String get setupStorageRequired => 'Permissão de Armazenamento Necessária';
|
||||
|
||||
@override
|
||||
String get setupStorageDescription =>
|
||||
'SpotiFLAC needs storage permission to save your downloaded music files.';
|
||||
'O SpotiFLAC precisa de permissão de armazenamento para salvar os seus arquivos de música baixados.';
|
||||
|
||||
@override
|
||||
String get setupNotificationGranted => 'Permissão de Notificações Concedida!';
|
||||
@@ -3022,171 +3090,172 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
'Você tem certeza que deseja limpar todos os downloads?';
|
||||
|
||||
@override
|
||||
String get dialogRemoveFromDevice => 'Remove from device?';
|
||||
String get dialogRemoveFromDevice => 'Remover do dispositivo?';
|
||||
|
||||
@override
|
||||
String get dialogRemoveExtension => 'Remove Extension';
|
||||
String get dialogRemoveExtension => 'Remover Extensão';
|
||||
|
||||
@override
|
||||
String get dialogRemoveExtensionMessage =>
|
||||
'Are you sure you want to remove this extension? This cannot be undone.';
|
||||
'Tem certeza de que deseja remover esta extensão? Isso não pode ser desfeito.';
|
||||
|
||||
@override
|
||||
String get dialogUninstallExtension => 'Uninstall Extension?';
|
||||
String get dialogUninstallExtension => 'Desinstalar Extensão?';
|
||||
|
||||
@override
|
||||
String dialogUninstallExtensionMessage(String extensionName) {
|
||||
return 'Are you sure you want to remove $extensionName?';
|
||||
return 'Tem certeza de que deseja remover $extensionName?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogClearHistoryTitle => 'Clear History';
|
||||
String get dialogClearHistoryTitle => 'Limpar Histórico';
|
||||
|
||||
@override
|
||||
String get dialogClearHistoryMessage =>
|
||||
'Are you sure you want to clear all download history? This cannot be undone.';
|
||||
'Tem certeza de que deseja limpar todo o histórico de downloads? Isso não pode ser desfeito.';
|
||||
|
||||
@override
|
||||
String get dialogDeleteSelectedTitle => 'Delete Selected';
|
||||
String get dialogDeleteSelectedTitle => 'Apagar Selecionados';
|
||||
|
||||
@override
|
||||
String dialogDeleteSelectedMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: 'faixas',
|
||||
one: 'faixa',
|
||||
);
|
||||
return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.';
|
||||
return 'Apagar $count $_temp0 do histórico?\n\nIsso também apagará os arquivos do armazenamento.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogImportPlaylistTitle => 'Import Playlist';
|
||||
String get dialogImportPlaylistTitle => 'Importar Playlist';
|
||||
|
||||
@override
|
||||
String dialogImportPlaylistMessage(int count) {
|
||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||
return 'Encontradas $count faixas no CSV. Adicionar à fila de download?';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedToQueue(String trackName) {
|
||||
return 'Added \"$trackName\" to queue';
|
||||
return '\"$trackName\" adicionada à fila';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedTracksToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
return '$count faixas adicionadas à fila';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyDownloaded(String trackName) {
|
||||
return '\"$trackName\" already downloaded';
|
||||
return '\"$trackName\" já foi baixada';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
String get snackbarHistoryCleared => 'Histórico limpo';
|
||||
|
||||
@override
|
||||
String get snackbarCredentialsSaved => 'Credentials saved';
|
||||
String get snackbarCredentialsSaved => 'Credenciais salvas';
|
||||
|
||||
@override
|
||||
String get snackbarCredentialsCleared => 'Credentials cleared';
|
||||
String get snackbarCredentialsCleared => 'Credenciais removidas';
|
||||
|
||||
@override
|
||||
String snackbarDeletedTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: 'faixas apagadas',
|
||||
one: 'faixa apagada',
|
||||
);
|
||||
return 'Deleted $count $_temp0';
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarCannotOpenFile(String error) {
|
||||
return 'Cannot open file: $error';
|
||||
return 'Não foi possível abrir o arquivo: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarFillAllFields => 'Please fill all fields';
|
||||
String get snackbarFillAllFields => 'Por favor, preencha todos os campos';
|
||||
|
||||
@override
|
||||
String get snackbarViewQueue => 'View Queue';
|
||||
String get snackbarViewQueue => 'Ver Fila';
|
||||
|
||||
@override
|
||||
String snackbarFailedToLoad(String error) {
|
||||
return 'Failed to load: $error';
|
||||
return 'Falha ao carregar: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarUrlCopied(String platform) {
|
||||
return '$platform URL copied to clipboard';
|
||||
return 'URL do $platform copiada para a área de transferência';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarFileNotFound => 'File not found';
|
||||
String get snackbarFileNotFound => 'Arquivo não encontrado';
|
||||
|
||||
@override
|
||||
String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file';
|
||||
String get snackbarSelectExtFile =>
|
||||
'Por favor, selecione um arquivo .spotiflac-ext';
|
||||
|
||||
@override
|
||||
String get snackbarProviderPrioritySaved => 'Provider priority saved';
|
||||
String get snackbarProviderPrioritySaved => 'Prioridade de provedor salva';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataProviderSaved =>
|
||||
'Metadata provider priority saved';
|
||||
'Prioridade de provedor de metadados salva';
|
||||
|
||||
@override
|
||||
String snackbarExtensionInstalled(String extensionName) {
|
||||
return '$extensionName installed.';
|
||||
return '$extensionName instalada.';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarExtensionUpdated(String extensionName) {
|
||||
return '$extensionName updated.';
|
||||
return '$extensionName atualizada.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarFailedToInstall => 'Failed to install extension';
|
||||
String get snackbarFailedToInstall => 'Falha ao instalar extensão';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToUpdate => 'Failed to update extension';
|
||||
String get snackbarFailedToUpdate => 'Falha ao atualizar extensão';
|
||||
|
||||
@override
|
||||
String get errorRateLimited => 'Rate Limited';
|
||||
String get errorRateLimited => 'Taxa Limitada';
|
||||
|
||||
@override
|
||||
String get errorRateLimitedMessage =>
|
||||
'Too many requests. Please wait a moment before searching again.';
|
||||
'Muitas solicitações. Por favor, aguarde um momento antes de pesquisar novamente.';
|
||||
|
||||
@override
|
||||
String errorFailedToLoad(String item) {
|
||||
return 'Failed to load $item';
|
||||
return 'Falha ao carregar $item';
|
||||
}
|
||||
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
String get errorNoTracksFound => 'Nenhuma faixa encontrada';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
return 'Não foi possível carregar $item: fonte de extensão ausente';
|
||||
}
|
||||
|
||||
@override
|
||||
String get statusQueued => 'Queued';
|
||||
String get statusQueued => 'Na Fila';
|
||||
|
||||
@override
|
||||
String get statusDownloading => 'Downloading';
|
||||
String get statusDownloading => 'Baixando';
|
||||
|
||||
@override
|
||||
String get statusFinalizing => 'Finalizing';
|
||||
String get statusFinalizing => 'Finalizando';
|
||||
|
||||
@override
|
||||
String get statusCompleted => 'Completed';
|
||||
String get statusCompleted => 'Concluído';
|
||||
|
||||
@override
|
||||
String get statusFailed => 'Failed';
|
||||
String get statusFailed => 'Falhou';
|
||||
|
||||
@override
|
||||
String get statusSkipped => 'Ignorado';
|
||||
@@ -3523,42 +3592,43 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
String get logNetworkErrorDescription => 'Problemas de conexão detectados';
|
||||
|
||||
@override
|
||||
String get logNetworkErrorSuggestion => 'Check your internet connection';
|
||||
String get logNetworkErrorSuggestion =>
|
||||
'Verifique a sua conexão com a internet';
|
||||
|
||||
@override
|
||||
String get logTrackNotFoundDescription =>
|
||||
'Some tracks could not be found on download services';
|
||||
'Algumas faixas não foram encontradas nos serviços de download';
|
||||
|
||||
@override
|
||||
String get logTrackNotFoundSuggestion =>
|
||||
'The track may not be available in lossless quality';
|
||||
'A faixa pode não estar disponível em qualidade lossless';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
return 'Total de erros: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffected(String domains) {
|
||||
return 'Affected: $domains';
|
||||
return 'Afetados: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String logEntriesFiltered(int count) {
|
||||
return 'Entries ($count filtered)';
|
||||
return 'Entradas ($count filtradas)';
|
||||
}
|
||||
|
||||
@override
|
||||
String logEntries(int count) {
|
||||
return 'Entries ($count)';
|
||||
return 'Entradas ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get credentialsTitle => 'Spotify Credentials';
|
||||
String get credentialsTitle => 'Credenciais do Spotify';
|
||||
|
||||
@override
|
||||
String get credentialsDescription =>
|
||||
'Enter your Client ID and Secret to use your own Spotify application quota.';
|
||||
'Insira o seu Client ID e Secret para usar a sua própria cota de aplicativo do Spotify.';
|
||||
|
||||
@override
|
||||
String get credentialsClientId => 'Client ID';
|
||||
@@ -3723,136 +3793,138 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
String get trackDownloaded => 'Baixado';
|
||||
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
String get trackCopyLyrics => 'Copiar letras';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
String get trackLyricsNotAvailable =>
|
||||
'Letras não disponíveis para esta faixa';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
String get trackLyricsTimeout =>
|
||||
'A solicitação expirou. Tente novamente mais tarde.';
|
||||
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
String get trackLyricsLoadFailed => 'Falha ao carregar letras';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
String get trackCopiedToClipboard => 'Copiado para a área de transferência';
|
||||
|
||||
@override
|
||||
String get trackDeleteConfirmTitle => 'Remove from device?';
|
||||
String get trackDeleteConfirmTitle => 'Remover do dispositivo?';
|
||||
|
||||
@override
|
||||
String get trackDeleteConfirmMessage =>
|
||||
'This will permanently delete the downloaded file and remove it from your history.';
|
||||
'Isso apagará permanentemente o arquivo baixado e o removerá do seu histórico.';
|
||||
|
||||
@override
|
||||
String trackCannotOpen(String message) {
|
||||
return 'Cannot open: $message';
|
||||
return 'Não foi possível abrir: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dateToday => 'Today';
|
||||
String get dateToday => 'Hoje';
|
||||
|
||||
@override
|
||||
String get dateYesterday => 'Yesterday';
|
||||
String get dateYesterday => 'Ontem';
|
||||
|
||||
@override
|
||||
String dateDaysAgo(int count) {
|
||||
return '$count days ago';
|
||||
return 'Há $count dias';
|
||||
}
|
||||
|
||||
@override
|
||||
String dateWeeksAgo(int count) {
|
||||
return '$count weeks ago';
|
||||
return 'Há $count semanas';
|
||||
}
|
||||
|
||||
@override
|
||||
String dateMonthsAgo(int count) {
|
||||
return '$count months ago';
|
||||
return 'Há $count meses';
|
||||
}
|
||||
|
||||
@override
|
||||
String get concurrentSequential => 'Sequential';
|
||||
String get concurrentSequential => 'Sequencial';
|
||||
|
||||
@override
|
||||
String get concurrentParallel2 => '2 Parallel';
|
||||
String get concurrentParallel2 => '2 Paralelos';
|
||||
|
||||
@override
|
||||
String get concurrentParallel3 => '3 Parallel';
|
||||
String get concurrentParallel3 => '3 Paralelos';
|
||||
|
||||
@override
|
||||
String get tapToSeeError => 'Tap to see error details';
|
||||
String get tapToSeeError => 'Toque para ver detalhes do erro';
|
||||
|
||||
@override
|
||||
String get storeFilterAll => 'All';
|
||||
String get storeFilterAll => 'Todos';
|
||||
|
||||
@override
|
||||
String get storeFilterMetadata => 'Metadata';
|
||||
String get storeFilterMetadata => 'Metadados';
|
||||
|
||||
@override
|
||||
String get storeFilterDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeFilterUtility => 'Utility';
|
||||
String get storeFilterUtility => 'Utilitário';
|
||||
|
||||
@override
|
||||
String get storeFilterLyrics => 'Lyrics';
|
||||
String get storeFilterLyrics => 'Letras';
|
||||
|
||||
@override
|
||||
String get storeFilterIntegration => 'Integration';
|
||||
String get storeFilterIntegration => 'Integração';
|
||||
|
||||
@override
|
||||
String get storeClearFilters => 'Clear filters';
|
||||
String get storeClearFilters => 'Limpar filtros';
|
||||
|
||||
@override
|
||||
String get storeNoResults => 'No extensions found';
|
||||
String get storeNoResults => 'Nenhuma extensão encontrada';
|
||||
|
||||
@override
|
||||
String get extensionProviderPriority => 'Provider Priority';
|
||||
String get extensionProviderPriority => 'Prioridade de Provedor';
|
||||
|
||||
@override
|
||||
String get extensionInstallButton => 'Install Extension';
|
||||
String get extensionInstallButton => 'Instalar Extensão';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Padrão (Deezer/Spotify)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
String get extensionDefaultProviderSubtitle => 'Usar pesquisa integrada';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Author';
|
||||
String get extensionAuthor => 'Autor';
|
||||
|
||||
@override
|
||||
String get extensionId => 'ID';
|
||||
|
||||
@override
|
||||
String get extensionError => 'Error';
|
||||
String get extensionError => 'Erro';
|
||||
|
||||
@override
|
||||
String get extensionCapabilities => 'Capabilities';
|
||||
String get extensionCapabilities => 'Capacidades';
|
||||
|
||||
@override
|
||||
String get extensionMetadataProvider => 'Metadata Provider';
|
||||
String get extensionMetadataProvider => 'Provedor de Metadados';
|
||||
|
||||
@override
|
||||
String get extensionDownloadProvider => 'Download Provider';
|
||||
String get extensionDownloadProvider => 'Provedor de Download';
|
||||
|
||||
@override
|
||||
String get extensionLyricsProvider => 'Lyrics Provider';
|
||||
String get extensionLyricsProvider => 'Provedor de Letras';
|
||||
|
||||
@override
|
||||
String get extensionUrlHandler => 'URL Handler';
|
||||
String get extensionUrlHandler => 'Manipulador de URL';
|
||||
|
||||
@override
|
||||
String get extensionQualityOptions => 'Quality Options';
|
||||
String get extensionQualityOptions => 'Opções de Qualidade';
|
||||
|
||||
@override
|
||||
String get extensionPostProcessingHooks => 'Post-Processing Hooks';
|
||||
String get extensionPostProcessingHooks => 'Ganchos de Pós-Processamento';
|
||||
|
||||
@override
|
||||
String get extensionPermissions => 'Permissions';
|
||||
String get extensionPermissions => 'Permissões';
|
||||
|
||||
@override
|
||||
String get extensionSettings => 'Settings';
|
||||
String get extensionSettings => 'Configurações';
|
||||
|
||||
@override
|
||||
String get extensionRemoveButton => 'Remover Extensão';
|
||||
@@ -4003,25 +4075,27 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
String get folderNone => 'Nenhum';
|
||||
|
||||
@override
|
||||
String get folderNoneSubtitle => 'Save all files directly to download folder';
|
||||
String get folderNoneSubtitle =>
|
||||
'Salvar todos os arquivos diretamente na pasta de download';
|
||||
|
||||
@override
|
||||
String get folderArtist => 'Artist';
|
||||
String get folderArtist => 'Artista';
|
||||
|
||||
@override
|
||||
String get folderArtistSubtitle => 'Artist Name/filename';
|
||||
String get folderArtistSubtitle => 'Nome do Artista/nome do arquivo';
|
||||
|
||||
@override
|
||||
String get folderAlbum => 'Album';
|
||||
String get folderAlbum => 'Álbum';
|
||||
|
||||
@override
|
||||
String get folderAlbumSubtitle => 'Album Name/filename';
|
||||
String get folderAlbumSubtitle => 'Nome do Álbum/nome do arquivo';
|
||||
|
||||
@override
|
||||
String get folderArtistAlbum => 'Artist/Album';
|
||||
String get folderArtistAlbum => 'Artista/Álbum';
|
||||
|
||||
@override
|
||||
String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename';
|
||||
String get folderArtistAlbumSubtitle =>
|
||||
'Nome do Artista/Nome do Álbum/nome do arquivo';
|
||||
|
||||
@override
|
||||
String get serviceTidal => 'Tidal';
|
||||
@@ -4039,134 +4113,135 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
String get serviceSpotify => 'Spotify';
|
||||
|
||||
@override
|
||||
String get appearanceAmoledDark => 'AMOLED Dark';
|
||||
String get appearanceAmoledDark => 'AMOLED Escuro';
|
||||
|
||||
@override
|
||||
String get appearanceAmoledDarkSubtitle => 'Pure black background';
|
||||
String get appearanceAmoledDarkSubtitle => 'Fundo preto puro';
|
||||
|
||||
@override
|
||||
String get appearanceChooseAccentColor => 'Choose Accent Color';
|
||||
String get appearanceChooseAccentColor => 'Escolher Cor de Destaque';
|
||||
|
||||
@override
|
||||
String get appearanceChooseTheme => 'Theme Mode';
|
||||
String get appearanceChooseTheme => 'Modo de Tema';
|
||||
|
||||
@override
|
||||
String get queueTitle => 'Download Queue';
|
||||
String get queueTitle => 'Fila de Download';
|
||||
|
||||
@override
|
||||
String get queueClearAll => 'Clear All';
|
||||
String get queueClearAll => 'Limpar Tudo';
|
||||
|
||||
@override
|
||||
String get queueClearAllMessage =>
|
||||
'Are you sure you want to clear all downloads?';
|
||||
'Tem certeza de que deseja limpar todos os downloads?';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
String get queueEmpty => 'Nenhum download na fila';
|
||||
|
||||
@override
|
||||
String get queueEmptySubtitle => 'Add tracks from the home screen';
|
||||
String get queueEmptySubtitle => 'Adicione faixas a partir da tela inicial';
|
||||
|
||||
@override
|
||||
String get queueClearCompleted => 'Clear completed';
|
||||
String get queueClearCompleted => 'Limpar concluídos';
|
||||
|
||||
@override
|
||||
String get queueDownloadFailed => 'Download Failed';
|
||||
String get queueDownloadFailed => 'Download Falhou';
|
||||
|
||||
@override
|
||||
String get queueTrackLabel => 'Track:';
|
||||
String get queueTrackLabel => 'Faixa:';
|
||||
|
||||
@override
|
||||
String get queueArtistLabel => 'Artist:';
|
||||
String get queueArtistLabel => 'Artista:';
|
||||
|
||||
@override
|
||||
String get queueErrorLabel => 'Error:';
|
||||
String get queueErrorLabel => 'Erro:';
|
||||
|
||||
@override
|
||||
String get queueUnknownError => 'Unknown error';
|
||||
String get queueUnknownError => 'Erro desconhecido';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbum => 'Artist / Album';
|
||||
String get albumFolderArtistAlbum => 'Artista / Álbum';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
|
||||
String get albumFolderArtistAlbumSubtitle =>
|
||||
'Álbuns/Nome do Artista/Nome do Álbum/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistYearAlbum => 'Artist / [Year] Album';
|
||||
String get albumFolderArtistYearAlbum => 'Artista / [Ano] Álbum';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistYearAlbumSubtitle =>
|
||||
'Albums/Artist Name/[2005] Album Name/';
|
||||
'Álbuns/Nome do Artista/[2005] Nome do Álbum/';
|
||||
|
||||
@override
|
||||
String get albumFolderAlbumOnly => 'Album Only';
|
||||
String get albumFolderAlbumOnly => 'Apenas Álbum';
|
||||
|
||||
@override
|
||||
String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/';
|
||||
String get albumFolderAlbumOnlySubtitle => 'Álbuns/Nome do Álbum/';
|
||||
|
||||
@override
|
||||
String get albumFolderYearAlbum => '[Year] Album';
|
||||
String get albumFolderYearAlbum => '[Ano] Álbum';
|
||||
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
String get albumFolderYearAlbumSubtitle => 'Álbuns/[2005] Nome do Álbum/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
String get downloadedAlbumDeleteSelected => 'Apagar Selecionados';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDeleteMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: 'faixas',
|
||||
one: 'faixa',
|
||||
);
|
||||
return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.';
|
||||
return 'Apagar $count $_temp0 deste álbum?\n\nIsso também apagará os arquivos do armazenamento.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadedAlbumTracksHeader => 'Tracks';
|
||||
String get downloadedAlbumTracksHeader => 'Faixas';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDownloadedCount(int count) {
|
||||
return '$count downloaded';
|
||||
return '$count baixadas';
|
||||
}
|
||||
|
||||
@override
|
||||
String downloadedAlbumSelectedCount(int count) {
|
||||
return '$count selected';
|
||||
return '$count selecionadas';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadedAlbumAllSelected => 'All tracks selected';
|
||||
String get downloadedAlbumAllSelected => 'Todas as faixas selecionadas';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumTapToSelect => 'Tap tracks to select';
|
||||
String get downloadedAlbumTapToSelect => 'Toque nas faixas para selecionar';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDeleteCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: 'faixas',
|
||||
one: 'faixa',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
return 'Apagar $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
String get downloadedAlbumSelectToDelete => 'Selecione faixas para apagar';
|
||||
|
||||
@override
|
||||
String get utilityFunctions => 'Utility Functions';
|
||||
String get utilityFunctions => 'Funções Utilitárias';
|
||||
|
||||
@override
|
||||
String get recentTypeArtist => 'Artist';
|
||||
String get recentTypeArtist => 'Artista';
|
||||
|
||||
@override
|
||||
String get recentTypeAlbum => 'Album';
|
||||
String get recentTypeAlbum => 'Álbum';
|
||||
|
||||
@override
|
||||
String get recentTypeSong => 'Song';
|
||||
String get recentTypeSong => 'Música';
|
||||
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
@@ -4178,6 +4253,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
|
||||
@override
|
||||
String errorGeneric(String message) {
|
||||
return 'Error: $message';
|
||||
return 'Erro: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -87,9 +87,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count альбомов',
|
||||
one: '1 альбом',
|
||||
many: '$count альбомов',
|
||||
few: '$count альбома',
|
||||
one: '$count альбом',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -115,7 +115,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Здесь будут отображаться загрузки синглов';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
String get historySearchHint => 'Поиск в истории...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Настройки';
|
||||
@@ -418,7 +418,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Талантливый художник, который создал наш красивый логотип приложения!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
String get aboutTranslators => 'Переводчики';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Особая благодарность';
|
||||
@@ -446,19 +446,19 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Предложить новые функции для приложения';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
String get aboutTelegramChannel => 'Telegram канал';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
String get aboutTelegramChannelSubtitle => 'Объявления и обновления';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
String get aboutTelegramChat => 'Сообщество в Telegram';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||
String get aboutTelegramChatSubtitle => 'Чат с другими пользователями';
|
||||
|
||||
@override
|
||||
String get aboutSocial => 'Social';
|
||||
String get aboutSocial => 'Соцсети';
|
||||
|
||||
@override
|
||||
String get aboutSupport => 'Поддержка';
|
||||
@@ -483,6 +483,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get aboutSachinsenalDesc =>
|
||||
'Оригинальный создатель проекта HiFi. Основатель Tidal интеграции!';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
|
||||
@@ -510,9 +514,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -544,9 +548,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count релизов',
|
||||
one: '1 релиз',
|
||||
many: '$count релизов',
|
||||
few: '$count релиза',
|
||||
one: '$count релиз',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -698,6 +702,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get setupIosEmptyFolderWarning =>
|
||||
'Ограничение iOS: пустые папки не могут быть выбраны. Выберите папку, содержащую хотя бы один файл.';
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC';
|
||||
|
||||
@@ -922,9 +930,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -939,7 +947,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String csvImportTracks(int count) {
|
||||
return '$count tracks from CSV';
|
||||
return '$count треков из CSV';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -972,9 +980,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалено $count $_temp0';
|
||||
}
|
||||
@@ -1121,9 +1129,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -1482,33 +1490,33 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get sectionFileSettings => 'Настройки файла';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
String get sectionLyrics => 'Тексты песен';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
String get lyricsMode => 'Режим текстов песен';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
'Выберите как сохранить тексты песен при скачивании';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
String get lyricsModeEmbed => 'Вставить в файл';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
String get lyricsModeExternal => 'Внешний файл .lrc';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
'Отдельный файл .lrc для плееров, таких, как Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
String get lyricsModeBoth => 'Оба варианта';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
String get lyricsModeBothSubtitle => 'Вставить и сохранить файл .lrc';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Цвет';
|
||||
@@ -1565,9 +1573,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -1627,13 +1635,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackReleaseDate => 'Дата выхода';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Genre';
|
||||
String get trackGenre => 'Жанр';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
String get trackLabel => 'Заголовок';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
String get trackCopyright => 'Авторские права';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Скачано';
|
||||
@@ -1653,13 +1661,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
String get trackEmbedLyrics => 'Вставить текст песни';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
String get trackLyricsEmbedded => 'Текст успешно добавлен';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
String get trackInstrumental => 'Инструментальный трек';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
|
||||
@@ -1894,20 +1902,36 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
|
||||
|
||||
@override
|
||||
String get qualityMp3 => 'MP3';
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3Option => 'Enable MP3 Option';
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
|
||||
@override
|
||||
String get enableMp3OptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to 320kbps MP3';
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -2005,6 +2029,26 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get queueClearAllMessage =>
|
||||
'Вы уверены, что хотите очистить все загрузки?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Нет загрузок в очереди';
|
||||
|
||||
@@ -2057,11 +2101,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Альбомы/[2005] Название Альбома /';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
String get albumFolderArtistAlbumSingles => 'Исполнитель / Альбом + Синглы';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
'Исполнитель/Альбом и Исполнитель/Сингл/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
||||
@@ -2072,9 +2116,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -2104,9 +2148,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -2116,7 +2160,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
return 'Disc $discNumber';
|
||||
return 'Диск $discNumber';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2145,68 +2189,93 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
String get discographyDownload => 'Скачать дискографию';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Download All';
|
||||
String get discographyDownloadAll => 'Скачать всё';
|
||||
|
||||
@override
|
||||
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount releases';
|
||||
return '$count треков из $albumCount релизов';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyAlbumsOnly => 'Albums Only';
|
||||
String get discographyAlbumsOnly => 'Только альбомы';
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount albums';
|
||||
return '$count треков из $albumCount альбомов';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||
String get discographySinglesOnly => 'Только синглы и EP';
|
||||
|
||||
@override
|
||||
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||
return '$count tracks from $albumCount singles';
|
||||
return '$count треков из $albumCount синглов';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographySelectAlbums => 'Select Albums...';
|
||||
String get discographySelectAlbums => 'Выбрать альбомы...';
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
'Выберите конкретные альбомы или синглы';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||
String get discographyFetchingTracks => 'Получение треков...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
return 'Получение $current из $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySelectedCount(int count) {
|
||||
return '$count selected';
|
||||
return '$count выбрано';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownloadSelected => 'Download Selected';
|
||||
String get discographyDownloadSelected => 'Скачать выбранное';
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
return 'Добавлено $count треков в очередь';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added added, $skipped already downloaded';
|
||||
return '$added добавлено, $skipped уже скачано';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
String get discographyNoAlbums => 'Нет доступных альбомов';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
String get discographyFailedToFetch =>
|
||||
'Не удалось получить некоторые альбомы';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
}
|
||||
|
||||
+622
-539
File diff suppressed because it is too large
Load Diff
+4366
-3996
File diff suppressed because it is too large
Load Diff
+321
-68
@@ -127,6 +127,10 @@
|
||||
"@historyNoSinglesSubtitle": {
|
||||
"description": "Empty state subtitle for singles filter"
|
||||
},
|
||||
"historySearchHint": "Suchverlauf...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Einstellungen",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
@@ -512,6 +516,10 @@
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Übersetzer",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Besonderer Dank",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
@@ -544,6 +552,26 @@
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
"aboutTelegramChannel": "Telegram Kanal",
|
||||
"@aboutTelegramChannel": {
|
||||
"description": "Link to Telegram channel"
|
||||
},
|
||||
"aboutTelegramChannelSubtitle": "Ankündigungen und Updates",
|
||||
"@aboutTelegramChannelSubtitle": {
|
||||
"description": "Subtitle for Telegram channel"
|
||||
},
|
||||
"aboutTelegramChat": "Telegram Community",
|
||||
"@aboutTelegramChat": {
|
||||
"description": "Link to Telegram chat group"
|
||||
},
|
||||
"aboutTelegramChatSubtitle": "Mit anderen Nutzern chatten",
|
||||
"@aboutTelegramChatSubtitle": {
|
||||
"description": "Subtitle for Telegram chat"
|
||||
},
|
||||
"aboutSocial": "Sozial",
|
||||
"@aboutSocial": {
|
||||
"description": "Section for social links"
|
||||
},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
@@ -588,7 +616,7 @@
|
||||
"@aboutDabMusicDesc": {
|
||||
"description": "Credit for DAB Music API"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -596,7 +624,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"albumTracks": "{count, plural,=1{1 Song} other{{count} Songs}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -605,11 +633,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumDownloadAll": "Download All",
|
||||
"albumDownloadAll": "Alle Herunterladen",
|
||||
"@albumDownloadAll": {
|
||||
"description": "Button to download all tracks"
|
||||
},
|
||||
"albumDownloadRemaining": "Download Remaining",
|
||||
"albumDownloadRemaining": "Downloads verbleibend",
|
||||
"@albumDownloadRemaining": {
|
||||
"description": "Button to download remaining tracks"
|
||||
},
|
||||
@@ -617,11 +645,11 @@
|
||||
"@playlistTitle": {
|
||||
"description": "Playlist screen title"
|
||||
},
|
||||
"artistTitle": "Artist",
|
||||
"artistTitle": "Künstler",
|
||||
"@artistTitle": {
|
||||
"description": "Artist screen title"
|
||||
},
|
||||
"artistAlbums": "Albums",
|
||||
"artistAlbums": "Alben",
|
||||
"@artistAlbums": {
|
||||
"description": "Section header for artist albums"
|
||||
},
|
||||
@@ -629,11 +657,11 @@
|
||||
"@artistSingles": {
|
||||
"description": "Section header for singles/EPs"
|
||||
},
|
||||
"artistCompilations": "Compilations",
|
||||
"artistCompilations": "Zusammenstellungen",
|
||||
"@artistCompilations": {
|
||||
"description": "Section header for compilations"
|
||||
},
|
||||
"artistReleases": "{count, plural, =1{1 release} other{{count} releases}}",
|
||||
"artistReleases": "{count, plural,=1{1 Veröffentlichung} other{{count} Veröffentlichungen}}",
|
||||
"@artistReleases": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -642,11 +670,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"artistPopular": "Popular",
|
||||
"artistPopular": "Beliebt",
|
||||
"@artistPopular": {
|
||||
"description": "Section header for popular/top tracks"
|
||||
},
|
||||
"artistMonthlyListeners": "{count} monthly listeners",
|
||||
"artistMonthlyListeners": "{count} monatliche Hörer",
|
||||
"@artistMonthlyListeners": {
|
||||
"description": "Monthly listener count display",
|
||||
"placeholders": {
|
||||
@@ -656,11 +684,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackMetadataTitle": "Track Info",
|
||||
"trackMetadataTitle": "Titel Info",
|
||||
"@trackMetadataTitle": {
|
||||
"description": "Track metadata screen title"
|
||||
},
|
||||
"trackMetadataArtist": "Artist",
|
||||
"trackMetadataArtist": "Künstler",
|
||||
"@trackMetadataArtist": {
|
||||
"description": "Metadata field - artist name"
|
||||
},
|
||||
@@ -668,111 +696,111 @@
|
||||
"@trackMetadataAlbum": {
|
||||
"description": "Metadata field - album name"
|
||||
},
|
||||
"trackMetadataDuration": "Duration",
|
||||
"trackMetadataDuration": "Länge",
|
||||
"@trackMetadataDuration": {
|
||||
"description": "Metadata field - track length"
|
||||
},
|
||||
"trackMetadataQuality": "Quality",
|
||||
"trackMetadataQuality": "Qualität",
|
||||
"@trackMetadataQuality": {
|
||||
"description": "Metadata field - audio quality"
|
||||
},
|
||||
"trackMetadataPath": "File Path",
|
||||
"trackMetadataPath": "Dateipfad",
|
||||
"@trackMetadataPath": {
|
||||
"description": "Metadata field - file location"
|
||||
},
|
||||
"trackMetadataDownloadedAt": "Downloaded",
|
||||
"trackMetadataDownloadedAt": "Heruntergeladen",
|
||||
"@trackMetadataDownloadedAt": {
|
||||
"description": "Metadata field - download date"
|
||||
},
|
||||
"trackMetadataService": "Service",
|
||||
"trackMetadataService": "Anbieter",
|
||||
"@trackMetadataService": {
|
||||
"description": "Metadata field - download service used"
|
||||
},
|
||||
"trackMetadataPlay": "Play",
|
||||
"trackMetadataPlay": "Abspielen",
|
||||
"@trackMetadataPlay": {
|
||||
"description": "Action button - play track"
|
||||
},
|
||||
"trackMetadataShare": "Share",
|
||||
"trackMetadataShare": "Teilen",
|
||||
"@trackMetadataShare": {
|
||||
"description": "Action button - share track"
|
||||
},
|
||||
"trackMetadataDelete": "Delete",
|
||||
"trackMetadataDelete": "Löschen",
|
||||
"@trackMetadataDelete": {
|
||||
"description": "Action button - delete track"
|
||||
},
|
||||
"trackMetadataRedownload": "Re-download",
|
||||
"trackMetadataRedownload": "Erneut herunterladen",
|
||||
"@trackMetadataRedownload": {
|
||||
"description": "Action button - download again"
|
||||
},
|
||||
"trackMetadataOpenFolder": "Open Folder",
|
||||
"trackMetadataOpenFolder": "Ordner öffnen",
|
||||
"@trackMetadataOpenFolder": {
|
||||
"description": "Action button - open containing folder"
|
||||
},
|
||||
"setupTitle": "Welcome to SpotiFLAC",
|
||||
"setupTitle": "Willkommen bei SpotiFLAC",
|
||||
"@setupTitle": {
|
||||
"description": "Setup wizard title"
|
||||
},
|
||||
"setupSubtitle": "Let's get you started",
|
||||
"setupSubtitle": "Los geht's",
|
||||
"@setupSubtitle": {
|
||||
"description": "Setup wizard subtitle"
|
||||
},
|
||||
"setupStoragePermission": "Storage Permission",
|
||||
"setupStoragePermission": "Speicherberechtigung",
|
||||
"@setupStoragePermission": {
|
||||
"description": "Storage permission step title"
|
||||
},
|
||||
"setupStoragePermissionSubtitle": "Required to save downloaded files",
|
||||
"setupStoragePermissionSubtitle": "Benötigt um heruntergeladene Dateien zu Speichern",
|
||||
"@setupStoragePermissionSubtitle": {
|
||||
"description": "Explanation for storage permission"
|
||||
},
|
||||
"setupStoragePermissionGranted": "Permission granted",
|
||||
"setupStoragePermissionGranted": "Berechtigung erteilt",
|
||||
"@setupStoragePermissionGranted": {
|
||||
"description": "Status when permission granted"
|
||||
},
|
||||
"setupStoragePermissionDenied": "Permission denied",
|
||||
"setupStoragePermissionDenied": "Berechtigung verweigert",
|
||||
"@setupStoragePermissionDenied": {
|
||||
"description": "Status when permission denied"
|
||||
},
|
||||
"setupGrantPermission": "Grant Permission",
|
||||
"setupGrantPermission": "Berechtigung erlauben",
|
||||
"@setupGrantPermission": {
|
||||
"description": "Button to request permission"
|
||||
},
|
||||
"setupDownloadLocation": "Download Location",
|
||||
"setupDownloadLocation": "Speicherort",
|
||||
"@setupDownloadLocation": {
|
||||
"description": "Download folder step title"
|
||||
},
|
||||
"setupChooseFolder": "Choose Folder",
|
||||
"setupChooseFolder": "Ordner wählen",
|
||||
"@setupChooseFolder": {
|
||||
"description": "Button to pick folder"
|
||||
},
|
||||
"setupContinue": "Continue",
|
||||
"setupContinue": "Fortfahren",
|
||||
"@setupContinue": {
|
||||
"description": "Continue to next step button"
|
||||
},
|
||||
"setupSkip": "Skip for now",
|
||||
"setupSkip": "Vorerst überspringen",
|
||||
"@setupSkip": {
|
||||
"description": "Skip current step button"
|
||||
},
|
||||
"setupStorageAccessRequired": "Storage Access Required",
|
||||
"setupStorageAccessRequired": "Speicherzugriff erforderlich",
|
||||
"@setupStorageAccessRequired": {
|
||||
"description": "Title when storage access needed"
|
||||
},
|
||||
"setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.",
|
||||
"setupStorageAccessMessage": "SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.",
|
||||
"@setupStorageAccessMessage": {
|
||||
"description": "Explanation for storage access"
|
||||
},
|
||||
"setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.",
|
||||
"setupStorageAccessMessageAndroid11": "Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.",
|
||||
"@setupStorageAccessMessageAndroid11": {
|
||||
"description": "Android 11+ specific explanation"
|
||||
},
|
||||
"setupOpenSettings": "Open Settings",
|
||||
"setupOpenSettings": "Einstellungen öffnen",
|
||||
"@setupOpenSettings": {
|
||||
"description": "Button to open system settings"
|
||||
},
|
||||
"setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.",
|
||||
"setupPermissionDeniedMessage": "Berechtigung verweigert. Bitte erteilen Sie alle Berechtigungen um fortzufahren.",
|
||||
"@setupPermissionDeniedMessage": {
|
||||
"description": "Error when permission denied"
|
||||
},
|
||||
"setupPermissionRequired": "{permissionType} Permission Required",
|
||||
"setupPermissionRequired": "{permissionType} Zugriff verweigert",
|
||||
"@setupPermissionRequired": {
|
||||
"description": "Generic permission required title",
|
||||
"placeholders": {
|
||||
@@ -782,7 +810,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.",
|
||||
"setupPermissionRequiredMessage": "{permissionType} Berechtigung ist erforderlich für\ndie beste Benutzererfahrung. Für kannst dies später in den Einstellungen ändern.",
|
||||
"@setupPermissionRequiredMessage": {
|
||||
"description": "Generic permission required message",
|
||||
"placeholders": {
|
||||
@@ -791,63 +819,63 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"setupSelectDownloadFolder": "Select Download Folder",
|
||||
"setupSelectDownloadFolder": "Wähle Download-Ordner aus",
|
||||
"@setupSelectDownloadFolder": {
|
||||
"description": "Folder selection step title"
|
||||
},
|
||||
"setupUseDefaultFolder": "Use Default Folder?",
|
||||
"setupUseDefaultFolder": "Als Standardordner verwenden?",
|
||||
"@setupUseDefaultFolder": {
|
||||
"description": "Dialog title for default folder"
|
||||
},
|
||||
"setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?",
|
||||
"setupNoFolderSelected": "Kein Ordner ausgewählt. Soll der Standard-Musikordner verwendet werden?",
|
||||
"@setupNoFolderSelected": {
|
||||
"description": "Prompt when no folder selected"
|
||||
},
|
||||
"setupUseDefault": "Use Default",
|
||||
"setupUseDefault": "Standart benutzen",
|
||||
"@setupUseDefault": {
|
||||
"description": "Button to use default folder"
|
||||
},
|
||||
"setupDownloadLocationTitle": "Download Location",
|
||||
"setupDownloadLocationTitle": "Speicherort",
|
||||
"@setupDownloadLocationTitle": {
|
||||
"description": "Download location dialog title"
|
||||
},
|
||||
"setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.",
|
||||
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Sie können sie über die Datei-App aufrufen.",
|
||||
"@setupDownloadLocationIosMessage": {
|
||||
"description": "iOS-specific folder info"
|
||||
},
|
||||
"setupAppDocumentsFolder": "App Documents Folder",
|
||||
"setupAppDocumentsFolder": "App-Dokumentenordner",
|
||||
"@setupAppDocumentsFolder": {
|
||||
"description": "iOS documents folder option"
|
||||
},
|
||||
"setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app",
|
||||
"setupAppDocumentsFolderSubtitle": "Empfohlen - zugänglich über die Datei-App",
|
||||
"@setupAppDocumentsFolderSubtitle": {
|
||||
"description": "Subtitle for documents folder"
|
||||
},
|
||||
"setupChooseFromFiles": "Choose from Files",
|
||||
"setupChooseFromFiles": "Aus Dateien auswählen",
|
||||
"@setupChooseFromFiles": {
|
||||
"description": "iOS file picker option"
|
||||
},
|
||||
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
|
||||
"setupChooseFromFilesSubtitle": "Wählen Sie iCloud oder einen anderen Ort",
|
||||
"@setupChooseFromFilesSubtitle": {
|
||||
"description": "Subtitle for file picker"
|
||||
},
|
||||
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
|
||||
"setupIosEmptyFolderWarning": "iOS-Einschränkung: Leere Ordner können nicht ausgewählt werden. Wählen Sie einen Ordner mit mindestens einer Datei.",
|
||||
"@setupIosEmptyFolderWarning": {
|
||||
"description": "iOS folder selection warning"
|
||||
},
|
||||
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
|
||||
"setupDownloadInFlac": "Spotify Titel in FLAC herunterladen",
|
||||
"@setupDownloadInFlac": {
|
||||
"description": "App tagline in setup"
|
||||
},
|
||||
"setupStepStorage": "Storage",
|
||||
"setupStepStorage": "Speicherort",
|
||||
"@setupStepStorage": {
|
||||
"description": "Setup step indicator - storage"
|
||||
},
|
||||
"setupStepNotification": "Notification",
|
||||
"setupStepNotification": "Benachrichtigung",
|
||||
"@setupStepNotification": {
|
||||
"description": "Setup step indicator - notification"
|
||||
},
|
||||
"setupStepFolder": "Folder",
|
||||
"setupStepFolder": "Ordner",
|
||||
"@setupStepFolder": {
|
||||
"description": "Setup step indicator - folder"
|
||||
},
|
||||
@@ -855,55 +883,55 @@
|
||||
"@setupStepSpotify": {
|
||||
"description": "Setup step indicator - Spotify API"
|
||||
},
|
||||
"setupStepPermission": "Permission",
|
||||
"setupStepPermission": "Berechtigung",
|
||||
"@setupStepPermission": {
|
||||
"description": "Setup step indicator - permission"
|
||||
},
|
||||
"setupStorageGranted": "Storage Permission Granted!",
|
||||
"setupStorageGranted": "Speicherberechtigung erlaubt!",
|
||||
"@setupStorageGranted": {
|
||||
"description": "Success message for storage permission"
|
||||
},
|
||||
"setupStorageRequired": "Storage Permission Required",
|
||||
"setupStorageRequired": "Speicherzugriff erforderlich",
|
||||
"@setupStorageRequired": {
|
||||
"description": "Title when storage permission needed"
|
||||
},
|
||||
"setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.",
|
||||
"setupStorageDescription": "SpotiFLAC benötigt Speicherrechte, um die heruntergeladenen Musikdateien zu speichern.",
|
||||
"@setupStorageDescription": {
|
||||
"description": "Explanation for storage permission"
|
||||
},
|
||||
"setupNotificationGranted": "Notification Permission Granted!",
|
||||
"setupNotificationGranted": "Benachrichtigungs-Berechtigung erteilt",
|
||||
"@setupNotificationGranted": {
|
||||
"description": "Success message for notification permission"
|
||||
},
|
||||
"setupNotificationEnable": "Enable Notifications",
|
||||
"setupNotificationEnable": "Benachrichtigungen aktivieren",
|
||||
"@setupNotificationEnable": {
|
||||
"description": "Button to enable notifications"
|
||||
},
|
||||
"setupNotificationDescription": "Get notified when downloads complete or require attention.",
|
||||
"setupNotificationDescription": "Benachrichtigt werden, wenn Downloads abgeschlossen sind.",
|
||||
"@setupNotificationDescription": {
|
||||
"description": "Explanation for notifications"
|
||||
},
|
||||
"setupFolderSelected": "Download Folder Selected!",
|
||||
"setupFolderSelected": "Download Ordner ausgewählt!",
|
||||
"@setupFolderSelected": {
|
||||
"description": "Success message for folder selection"
|
||||
},
|
||||
"setupFolderChoose": "Choose Download Folder",
|
||||
"setupFolderChoose": "Speicherort auwählen",
|
||||
"@setupFolderChoose": {
|
||||
"description": "Button to choose folder"
|
||||
},
|
||||
"setupFolderDescription": "Select a folder where your downloaded music will be saved.",
|
||||
"setupFolderDescription": "Wählen Sie einen Ordner, in dem Ihre heruntergeladene Musik gespeichert wird.",
|
||||
"@setupFolderDescription": {
|
||||
"description": "Explanation for folder selection"
|
||||
},
|
||||
"setupChangeFolder": "Change Folder",
|
||||
"setupChangeFolder": "Ordner ändern",
|
||||
"@setupChangeFolder": {
|
||||
"description": "Button to change selected folder"
|
||||
},
|
||||
"setupSelectFolder": "Select Folder",
|
||||
"setupSelectFolder": "Ordner wählen",
|
||||
"@setupSelectFolder": {
|
||||
"description": "Button to select folder"
|
||||
},
|
||||
"setupSpotifyApiOptional": "Spotify API (Optional)",
|
||||
"setupSpotifyApiOptional": "Spotify-API (optional)",
|
||||
"@setupSpotifyApiOptional": {
|
||||
"description": "Spotify API step title"
|
||||
},
|
||||
@@ -1122,6 +1150,15 @@
|
||||
"description": "Dialog title - import CSV playlist"
|
||||
},
|
||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||
"csvImportTracks": "{count} tracks from CSV",
|
||||
"@csvImportTracks": {
|
||||
"description": "Label shown in quality picker for CSV import",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1851,6 +1888,42 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Both",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
"sectionColor": "Color",
|
||||
"@sectionColor": {
|
||||
"description": "Settings section header"
|
||||
@@ -1997,6 +2070,18 @@
|
||||
"@trackReleaseDate": {
|
||||
"description": "Metadata label - release date"
|
||||
},
|
||||
"trackGenre": "Genre",
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Label",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Copyright",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
"trackDownloaded": "Downloaded",
|
||||
"@trackDownloaded": {
|
||||
"description": "Metadata label - download date"
|
||||
@@ -2017,6 +2102,18 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"@trackCopiedToClipboard": {
|
||||
"description": "Snackbar - content copied"
|
||||
@@ -2328,6 +2425,26 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {
|
||||
"description": "Quality option - MP3 lossy format"
|
||||
},
|
||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||
"@qualityMp3Subtitle": {
|
||||
"description": "Technical spec for MP3"
|
||||
},
|
||||
"enableMp3Option": "Enable MP3 Option",
|
||||
"@enableMp3Option": {
|
||||
"description": "Setting - enable MP3 quality option"
|
||||
},
|
||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||
"@enableMp3OptionSubtitleOn": {
|
||||
"description": "Subtitle when MP3 is enabled"
|
||||
},
|
||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||
"@enableMp3OptionSubtitleOff": {
|
||||
"description": "Subtitle when MP3 is disabled"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
@@ -2516,6 +2633,14 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
@@ -2572,6 +2697,16 @@
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
"@downloadedAlbumDiscHeader": {
|
||||
"description": "Header for disc separator in multi-disc albums",
|
||||
"placeholders": {
|
||||
"discNumber": {
|
||||
"type": "int",
|
||||
"example": "1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
@@ -2611,5 +2746,123 @@
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Albums Only",
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Singles & EPs Only",
|
||||
"@discographySinglesOnly": {
|
||||
"description": "Option - download only singles"
|
||||
},
|
||||
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Select Albums...",
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
"discographyFetchingTracks": "Fetching tracks...",
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} selected",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {
|
||||
"type": "int"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
}
|
||||
}
|
||||
+54
-13
@@ -334,6 +334,8 @@
|
||||
"@aboutBinimumDesc": {"description": "Credit description for binimum"},
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
||||
"@aboutSachinsenalDesc": {"description": "Credit description for sachinsenal0x64"},
|
||||
"aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!",
|
||||
"@aboutSjdonadoDesc": {"description": "Credit description for sjdonado"},
|
||||
"aboutDoubleDouble": "DoubleDouble",
|
||||
"@aboutDoubleDouble": {"description": "Name of Amazon API service - DO NOT TRANSLATE"},
|
||||
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
|
||||
@@ -479,8 +481,10 @@
|
||||
"@setupChooseFromFiles": {"description": "iOS file picker option"},
|
||||
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
|
||||
"@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"},
|
||||
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
|
||||
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
|
||||
"@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"},
|
||||
"setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.",
|
||||
"@setupIcloudNotSupported": {"description": "Error when user selects iCloud Drive on iOS"},
|
||||
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
|
||||
"@setupDownloadInFlac": {"description": "App tagline in setup"},
|
||||
"setupStepStorage": "Storage",
|
||||
@@ -1373,16 +1377,26 @@
|
||||
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
|
||||
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
|
||||
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {"description": "Quality option - MP3 lossy format"},
|
||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||
"@qualityMp3Subtitle": {"description": "Technical spec for MP3"},
|
||||
"enableMp3Option": "Enable MP3 Option",
|
||||
"@enableMp3Option": {"description": "Setting - enable MP3 quality option"},
|
||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||
"@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"},
|
||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||
"@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"},
|
||||
"qualityLossy": "Lossy",
|
||||
"@qualityLossy": {"description": "Quality option - lossy format (MP3/Opus)"},
|
||||
"qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)",
|
||||
"@qualityLossyMp3Subtitle": {"description": "Technical spec for lossy MP3"},
|
||||
"qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)",
|
||||
"@qualityLossyOpusSubtitle": {"description": "Technical spec for lossy Opus"},
|
||||
"enableLossyOption": "Enable Lossy Option",
|
||||
"@enableLossyOption": {"description": "Setting - enable lossy quality option"},
|
||||
"enableLossyOptionSubtitleOn": "Lossy quality option is available",
|
||||
"@enableLossyOptionSubtitleOn": {"description": "Subtitle when lossy is enabled"},
|
||||
"enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format",
|
||||
"@enableLossyOptionSubtitleOff": {"description": "Subtitle when lossy is disabled"},
|
||||
"lossyFormat": "Lossy Format",
|
||||
"@lossyFormat": {"description": "Setting - choose lossy format"},
|
||||
"lossyFormatDescription": "Choose the lossy format for conversion",
|
||||
"@lossyFormatDescription": {"description": "Description for lossy format picker"},
|
||||
"lossyFormatMp3Subtitle": "320kbps, best compatibility",
|
||||
"@lossyFormatMp3Subtitle": {"description": "MP3 format description"},
|
||||
"lossyFormatOpusSubtitle": "128kbps, better quality at smaller size",
|
||||
"@lossyFormatOpusSubtitle": {"description": "Opus format description"},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {"description": "Note about quality availability"},
|
||||
|
||||
@@ -1446,10 +1460,22 @@
|
||||
|
||||
"queueTitle": "Download Queue",
|
||||
"@queueTitle": {"description": "Queue screen title"},
|
||||
"queueClearAll": "Clear All",
|
||||
"queueClearAll": "Clear All",
|
||||
"@queueClearAll": {"description": "Button - clear all queue items"},
|
||||
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
|
||||
"@queueClearAllMessage": {"description": "Clear queue confirmation"},
|
||||
"queueExportFailed": "Export",
|
||||
"@queueExportFailed": {"description": "Button - export failed downloads to TXT"},
|
||||
"queueExportFailedSuccess": "Failed downloads exported to TXT file",
|
||||
"@queueExportFailedSuccess": {"description": "Success message after exporting failed downloads"},
|
||||
"queueExportFailedClear": "Clear Failed",
|
||||
"@queueExportFailedClear": {"description": "Action to clear failed downloads after export"},
|
||||
"queueExportFailedError": "Failed to export downloads",
|
||||
"@queueExportFailedError": {"description": "Error message when export fails"},
|
||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
||||
"@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"},
|
||||
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
|
||||
"@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"},
|
||||
"queueEmpty": "No downloads in queue",
|
||||
"@queueEmpty": {"description": "Empty queue state title"},
|
||||
"queueEmptySubtitle": "Add tracks from the home screen",
|
||||
@@ -1634,5 +1660,20 @@
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {"description": "Error - no albums found for artist"},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"}
|
||||
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"},
|
||||
|
||||
"sectionStorageAccess": "Storage Access",
|
||||
"@sectionStorageAccess": {"description": "Section header for storage access settings"},
|
||||
"allFilesAccess": "All Files Access",
|
||||
"@allFilesAccess": {"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"},
|
||||
"allFilesAccessEnabledSubtitle": "Can write to any folder",
|
||||
"@allFilesAccessEnabledSubtitle": {"description": "Subtitle when all files access is enabled"},
|
||||
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
|
||||
"@allFilesAccessDisabledSubtitle": {"description": "Subtitle when all files access is disabled"},
|
||||
"allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.",
|
||||
"@allFilesAccessDescription": {"description": "Description explaining when to enable all files access"},
|
||||
"allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.",
|
||||
"@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"},
|
||||
"allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.",
|
||||
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"@historyNoSinglesSubtitle": {
|
||||
"description": "Empty state subtitle for singles filter"
|
||||
},
|
||||
"historySearchHint": "Search history...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Settings",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
@@ -512,6 +516,10 @@
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Translators",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
@@ -544,6 +552,26 @@
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
"aboutTelegramChannel": "Telegram Channel",
|
||||
"@aboutTelegramChannel": {
|
||||
"description": "Link to Telegram channel"
|
||||
},
|
||||
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||
"@aboutTelegramChannelSubtitle": {
|
||||
"description": "Subtitle for Telegram channel"
|
||||
},
|
||||
"aboutTelegramChat": "Telegram Community",
|
||||
"@aboutTelegramChat": {
|
||||
"description": "Link to Telegram chat group"
|
||||
},
|
||||
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||
"@aboutTelegramChatSubtitle": {
|
||||
"description": "Subtitle for Telegram chat"
|
||||
},
|
||||
"aboutSocial": "Social",
|
||||
"@aboutSocial": {
|
||||
"description": "Section for social links"
|
||||
},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
@@ -1122,6 +1150,15 @@
|
||||
"description": "Dialog title - import CSV playlist"
|
||||
},
|
||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||
"csvImportTracks": "{count} tracks from CSV",
|
||||
"@csvImportTracks": {
|
||||
"description": "Label shown in quality picker for CSV import",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1851,6 +1888,42 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Both",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
"sectionColor": "Color",
|
||||
"@sectionColor": {
|
||||
"description": "Settings section header"
|
||||
@@ -1997,6 +2070,18 @@
|
||||
"@trackReleaseDate": {
|
||||
"description": "Metadata label - release date"
|
||||
},
|
||||
"trackGenre": "Genre",
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Label",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Copyright",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
"trackDownloaded": "Downloaded",
|
||||
"@trackDownloaded": {
|
||||
"description": "Metadata label - download date"
|
||||
@@ -2017,6 +2102,18 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"@trackCopiedToClipboard": {
|
||||
"description": "Snackbar - content copied"
|
||||
@@ -2328,6 +2425,26 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {
|
||||
"description": "Quality option - MP3 lossy format"
|
||||
},
|
||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||
"@qualityMp3Subtitle": {
|
||||
"description": "Technical spec for MP3"
|
||||
},
|
||||
"enableMp3Option": "Enable MP3 Option",
|
||||
"@enableMp3Option": {
|
||||
"description": "Setting - enable MP3 quality option"
|
||||
},
|
||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||
"@enableMp3OptionSubtitleOn": {
|
||||
"description": "Subtitle when MP3 is enabled"
|
||||
},
|
||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||
"@enableMp3OptionSubtitleOff": {
|
||||
"description": "Subtitle when MP3 is disabled"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
@@ -2516,6 +2633,14 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
@@ -2572,6 +2697,16 @@
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
"@downloadedAlbumDiscHeader": {
|
||||
"description": "Header for disc separator in multi-disc albums",
|
||||
"placeholders": {
|
||||
"discNumber": {
|
||||
"type": "int",
|
||||
"example": "1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
@@ -2611,5 +2746,123 @@
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Albums Only",
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Singles & EPs Only",
|
||||
"@discographySinglesOnly": {
|
||||
"description": "Option - download only singles"
|
||||
},
|
||||
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Select Albums...",
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
"discographyFetchingTracks": "Fetching tracks...",
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} selected",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {
|
||||
"type": "int"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
}
|
||||
}
|
||||
+261
-8
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"@@locale": "hi",
|
||||
"@@last_modified": "2026-01-16",
|
||||
"appName": "SpotiFLAC",
|
||||
"appName": "SpotiFlac",
|
||||
"@appName": {
|
||||
"description": "App name - DO NOT TRANSLATE"
|
||||
},
|
||||
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||
"appDescription": "स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।",
|
||||
"@appDescription": {
|
||||
"description": "App description shown in about page"
|
||||
},
|
||||
"navHome": "Home",
|
||||
"navHome": "होम",
|
||||
"@navHome": {
|
||||
"description": "Bottom navigation - Home tab"
|
||||
},
|
||||
"navHistory": "History",
|
||||
"navHistory": "इतिहास",
|
||||
"@navHistory": {
|
||||
"description": "Bottom navigation - History tab"
|
||||
},
|
||||
"navSettings": "Settings",
|
||||
"navSettings": "विकल्प",
|
||||
"@navSettings": {
|
||||
"description": "Bottom navigation - Settings tab"
|
||||
},
|
||||
@@ -127,6 +127,10 @@
|
||||
"@historyNoSinglesSubtitle": {
|
||||
"description": "Empty state subtitle for singles filter"
|
||||
},
|
||||
"historySearchHint": "Search history...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Settings",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
@@ -219,7 +223,7 @@
|
||||
"@quality128": {
|
||||
"description": "Audio quality option - 128kbps MP3"
|
||||
},
|
||||
"appearanceTitle": "Appearance",
|
||||
"appearanceTitle": "दिखावट",
|
||||
"@appearanceTitle": {
|
||||
"description": "Appearance settings page title"
|
||||
},
|
||||
@@ -239,11 +243,11 @@
|
||||
"@appearanceThemeDark": {
|
||||
"description": "Dark theme"
|
||||
},
|
||||
"appearanceDynamicColor": "Dynamic Color",
|
||||
"appearanceDynamicColor": "डायनेमिक रंग",
|
||||
"@appearanceDynamicColor": {
|
||||
"description": "Material You dynamic colors"
|
||||
},
|
||||
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
|
||||
"appearanceDynamicColorSubtitle": "वॉलपेपर से रंग इस्तेमाल करें",
|
||||
"@appearanceDynamicColorSubtitle": {
|
||||
"description": "Subtitle for dynamic color"
|
||||
},
|
||||
@@ -512,6 +516,10 @@
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Translators",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
@@ -544,6 +552,26 @@
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
"aboutTelegramChannel": "Telegram Channel",
|
||||
"@aboutTelegramChannel": {
|
||||
"description": "Link to Telegram channel"
|
||||
},
|
||||
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||
"@aboutTelegramChannelSubtitle": {
|
||||
"description": "Subtitle for Telegram channel"
|
||||
},
|
||||
"aboutTelegramChat": "Telegram Community",
|
||||
"@aboutTelegramChat": {
|
||||
"description": "Link to Telegram chat group"
|
||||
},
|
||||
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||
"@aboutTelegramChatSubtitle": {
|
||||
"description": "Subtitle for Telegram chat"
|
||||
},
|
||||
"aboutSocial": "Social",
|
||||
"@aboutSocial": {
|
||||
"description": "Section for social links"
|
||||
},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
@@ -1122,6 +1150,15 @@
|
||||
"description": "Dialog title - import CSV playlist"
|
||||
},
|
||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||
"csvImportTracks": "{count} tracks from CSV",
|
||||
"@csvImportTracks": {
|
||||
"description": "Label shown in quality picker for CSV import",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1851,6 +1888,42 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Both",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
"sectionColor": "Color",
|
||||
"@sectionColor": {
|
||||
"description": "Settings section header"
|
||||
@@ -1997,6 +2070,18 @@
|
||||
"@trackReleaseDate": {
|
||||
"description": "Metadata label - release date"
|
||||
},
|
||||
"trackGenre": "Genre",
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Label",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Copyright",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
"trackDownloaded": "Downloaded",
|
||||
"@trackDownloaded": {
|
||||
"description": "Metadata label - download date"
|
||||
@@ -2017,6 +2102,18 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"@trackCopiedToClipboard": {
|
||||
"description": "Snackbar - content copied"
|
||||
@@ -2328,6 +2425,26 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {
|
||||
"description": "Quality option - MP3 lossy format"
|
||||
},
|
||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||
"@qualityMp3Subtitle": {
|
||||
"description": "Technical spec for MP3"
|
||||
},
|
||||
"enableMp3Option": "Enable MP3 Option",
|
||||
"@enableMp3Option": {
|
||||
"description": "Setting - enable MP3 quality option"
|
||||
},
|
||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||
"@enableMp3OptionSubtitleOn": {
|
||||
"description": "Subtitle when MP3 is enabled"
|
||||
},
|
||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||
"@enableMp3OptionSubtitleOff": {
|
||||
"description": "Subtitle when MP3 is disabled"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
@@ -2516,6 +2633,14 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
@@ -2572,6 +2697,16 @@
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
"@downloadedAlbumDiscHeader": {
|
||||
"description": "Header for disc separator in multi-disc albums",
|
||||
"placeholders": {
|
||||
"discNumber": {
|
||||
"type": "int",
|
||||
"example": "1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
@@ -2611,5 +2746,123 @@
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Albums Only",
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Singles & EPs Only",
|
||||
"@discographySinglesOnly": {
|
||||
"description": "Option - download only singles"
|
||||
},
|
||||
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Select Albums...",
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
"discographyFetchingTracks": "Fetching tracks...",
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} selected",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {
|
||||
"type": "int"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
}
|
||||
}
|
||||
+2727
-564
File diff suppressed because it is too large
Load Diff
+634
-381
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,10 @@
|
||||
"@historyNoSinglesSubtitle": {
|
||||
"description": "Empty state subtitle for singles filter"
|
||||
},
|
||||
"historySearchHint": "Search history...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Settings",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
@@ -512,6 +516,10 @@
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Translators",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
@@ -544,6 +552,26 @@
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
"aboutTelegramChannel": "Telegram Channel",
|
||||
"@aboutTelegramChannel": {
|
||||
"description": "Link to Telegram channel"
|
||||
},
|
||||
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||
"@aboutTelegramChannelSubtitle": {
|
||||
"description": "Subtitle for Telegram channel"
|
||||
},
|
||||
"aboutTelegramChat": "Telegram Community",
|
||||
"@aboutTelegramChat": {
|
||||
"description": "Link to Telegram chat group"
|
||||
},
|
||||
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||
"@aboutTelegramChatSubtitle": {
|
||||
"description": "Subtitle for Telegram chat"
|
||||
},
|
||||
"aboutSocial": "Social",
|
||||
"@aboutSocial": {
|
||||
"description": "Section for social links"
|
||||
},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
@@ -1122,6 +1150,15 @@
|
||||
"description": "Dialog title - import CSV playlist"
|
||||
},
|
||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||
"csvImportTracks": "{count} tracks from CSV",
|
||||
"@csvImportTracks": {
|
||||
"description": "Label shown in quality picker for CSV import",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1851,6 +1888,42 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Both",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
"sectionColor": "Color",
|
||||
"@sectionColor": {
|
||||
"description": "Settings section header"
|
||||
@@ -1997,6 +2070,18 @@
|
||||
"@trackReleaseDate": {
|
||||
"description": "Metadata label - release date"
|
||||
},
|
||||
"trackGenre": "Genre",
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Label",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Copyright",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
"trackDownloaded": "Downloaded",
|
||||
"@trackDownloaded": {
|
||||
"description": "Metadata label - download date"
|
||||
@@ -2017,6 +2102,18 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"@trackCopiedToClipboard": {
|
||||
"description": "Snackbar - content copied"
|
||||
@@ -2328,6 +2425,26 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {
|
||||
"description": "Quality option - MP3 lossy format"
|
||||
},
|
||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||
"@qualityMp3Subtitle": {
|
||||
"description": "Technical spec for MP3"
|
||||
},
|
||||
"enableMp3Option": "Enable MP3 Option",
|
||||
"@enableMp3Option": {
|
||||
"description": "Setting - enable MP3 quality option"
|
||||
},
|
||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||
"@enableMp3OptionSubtitleOn": {
|
||||
"description": "Subtitle when MP3 is enabled"
|
||||
},
|
||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||
"@enableMp3OptionSubtitleOff": {
|
||||
"description": "Subtitle when MP3 is disabled"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
@@ -2516,6 +2633,14 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
@@ -2572,6 +2697,16 @@
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
"@downloadedAlbumDiscHeader": {
|
||||
"description": "Header for disc separator in multi-disc albums",
|
||||
"placeholders": {
|
||||
"discNumber": {
|
||||
"type": "int",
|
||||
"example": "1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
@@ -2611,5 +2746,123 @@
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Albums Only",
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Singles & EPs Only",
|
||||
"@discographySinglesOnly": {
|
||||
"description": "Option - download only singles"
|
||||
},
|
||||
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Select Albums...",
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
"discographyFetchingTracks": "Fetching tracks...",
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} selected",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {
|
||||
"type": "int"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,10 @@
|
||||
"@historyNoSinglesSubtitle": {
|
||||
"description": "Empty state subtitle for singles filter"
|
||||
},
|
||||
"historySearchHint": "Search history...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Settings",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
@@ -512,6 +516,10 @@
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Translators",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
@@ -544,6 +552,26 @@
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
"aboutTelegramChannel": "Telegram Channel",
|
||||
"@aboutTelegramChannel": {
|
||||
"description": "Link to Telegram channel"
|
||||
},
|
||||
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||
"@aboutTelegramChannelSubtitle": {
|
||||
"description": "Subtitle for Telegram channel"
|
||||
},
|
||||
"aboutTelegramChat": "Telegram Community",
|
||||
"@aboutTelegramChat": {
|
||||
"description": "Link to Telegram chat group"
|
||||
},
|
||||
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||
"@aboutTelegramChatSubtitle": {
|
||||
"description": "Subtitle for Telegram chat"
|
||||
},
|
||||
"aboutSocial": "Social",
|
||||
"@aboutSocial": {
|
||||
"description": "Section for social links"
|
||||
},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
@@ -1122,6 +1150,15 @@
|
||||
"description": "Dialog title - import CSV playlist"
|
||||
},
|
||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||
"csvImportTracks": "{count} tracks from CSV",
|
||||
"@csvImportTracks": {
|
||||
"description": "Label shown in quality picker for CSV import",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1851,6 +1888,42 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Both",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
"sectionColor": "Color",
|
||||
"@sectionColor": {
|
||||
"description": "Settings section header"
|
||||
@@ -1997,6 +2070,18 @@
|
||||
"@trackReleaseDate": {
|
||||
"description": "Metadata label - release date"
|
||||
},
|
||||
"trackGenre": "Genre",
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Label",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Copyright",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
"trackDownloaded": "Downloaded",
|
||||
"@trackDownloaded": {
|
||||
"description": "Metadata label - download date"
|
||||
@@ -2017,6 +2102,18 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"@trackCopiedToClipboard": {
|
||||
"description": "Snackbar - content copied"
|
||||
@@ -2328,6 +2425,26 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {
|
||||
"description": "Quality option - MP3 lossy format"
|
||||
},
|
||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||
"@qualityMp3Subtitle": {
|
||||
"description": "Technical spec for MP3"
|
||||
},
|
||||
"enableMp3Option": "Enable MP3 Option",
|
||||
"@enableMp3Option": {
|
||||
"description": "Setting - enable MP3 quality option"
|
||||
},
|
||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||
"@enableMp3OptionSubtitleOn": {
|
||||
"description": "Subtitle when MP3 is enabled"
|
||||
},
|
||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||
"@enableMp3OptionSubtitleOff": {
|
||||
"description": "Subtitle when MP3 is disabled"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
@@ -2516,6 +2633,14 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
@@ -2572,6 +2697,16 @@
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
"@downloadedAlbumDiscHeader": {
|
||||
"description": "Header for disc separator in multi-disc albums",
|
||||
"placeholders": {
|
||||
"discNumber": {
|
||||
"type": "int",
|
||||
"example": "1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
@@ -2611,5 +2746,123 @@
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Albums Only",
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Singles & EPs Only",
|
||||
"@discographySinglesOnly": {
|
||||
"description": "Option - download only singles"
|
||||
},
|
||||
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Select Albums...",
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
"discographyFetchingTracks": "Fetching tracks...",
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} selected",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {
|
||||
"type": "int"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
}
|
||||
}
|
||||
+141
-141
@@ -835,19 +835,19 @@
|
||||
"@setupIosEmptyFolderWarning": {
|
||||
"description": "iOS folder selection warning"
|
||||
},
|
||||
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
|
||||
"setupDownloadInFlac": "Baixe faixas do Spotify em FLAC",
|
||||
"@setupDownloadInFlac": {
|
||||
"description": "App tagline in setup"
|
||||
},
|
||||
"setupStepStorage": "Storage",
|
||||
"setupStepStorage": "Armazenamento",
|
||||
"@setupStepStorage": {
|
||||
"description": "Setup step indicator - storage"
|
||||
},
|
||||
"setupStepNotification": "Notification",
|
||||
"setupStepNotification": "Notificação",
|
||||
"@setupStepNotification": {
|
||||
"description": "Setup step indicator - notification"
|
||||
},
|
||||
"setupStepFolder": "Folder",
|
||||
"setupStepFolder": "Pasta",
|
||||
"@setupStepFolder": {
|
||||
"description": "Setup step indicator - folder"
|
||||
},
|
||||
@@ -855,19 +855,19 @@
|
||||
"@setupStepSpotify": {
|
||||
"description": "Setup step indicator - Spotify API"
|
||||
},
|
||||
"setupStepPermission": "Permission",
|
||||
"setupStepPermission": "Permissão",
|
||||
"@setupStepPermission": {
|
||||
"description": "Setup step indicator - permission"
|
||||
},
|
||||
"setupStorageGranted": "Storage Permission Granted!",
|
||||
"setupStorageGranted": "Permissão de Armazenamento Concedida!",
|
||||
"@setupStorageGranted": {
|
||||
"description": "Success message for storage permission"
|
||||
},
|
||||
"setupStorageRequired": "Storage Permission Required",
|
||||
"setupStorageRequired": "Permissão de Armazenamento Necessária",
|
||||
"@setupStorageRequired": {
|
||||
"description": "Title when storage permission needed"
|
||||
},
|
||||
"setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.",
|
||||
"setupStorageDescription": "O SpotiFLAC precisa de permissão de armazenamento para salvar os seus arquivos de música baixados.",
|
||||
"@setupStorageDescription": {
|
||||
"description": "Explanation for storage permission"
|
||||
},
|
||||
@@ -1071,23 +1071,23 @@
|
||||
"@dialogClearAllDownloads": {
|
||||
"description": "Dialog message - clear downloads confirmation"
|
||||
},
|
||||
"dialogRemoveFromDevice": "Remove from device?",
|
||||
"dialogRemoveFromDevice": "Remover do dispositivo?",
|
||||
"@dialogRemoveFromDevice": {
|
||||
"description": "Dialog title - delete file confirmation"
|
||||
},
|
||||
"dialogRemoveExtension": "Remove Extension",
|
||||
"dialogRemoveExtension": "Remover Extensão",
|
||||
"@dialogRemoveExtension": {
|
||||
"description": "Dialog title - uninstall extension"
|
||||
},
|
||||
"dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.",
|
||||
"dialogRemoveExtensionMessage": "Tem certeza de que deseja remover esta extensão? Isso não pode ser desfeito.",
|
||||
"@dialogRemoveExtensionMessage": {
|
||||
"description": "Dialog message - uninstall confirmation"
|
||||
},
|
||||
"dialogUninstallExtension": "Uninstall Extension?",
|
||||
"dialogUninstallExtension": "Desinstalar Extensão?",
|
||||
"@dialogUninstallExtension": {
|
||||
"description": "Dialog title - uninstall extension"
|
||||
},
|
||||
"dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?",
|
||||
"dialogUninstallExtensionMessage": "Tem certeza de que deseja remover {extensionName}?",
|
||||
"@dialogUninstallExtensionMessage": {
|
||||
"description": "Dialog message - uninstall specific extension",
|
||||
"placeholders": {
|
||||
@@ -1096,19 +1096,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialogClearHistoryTitle": "Clear History",
|
||||
"dialogClearHistoryTitle": "Limpar Histórico",
|
||||
"@dialogClearHistoryTitle": {
|
||||
"description": "Dialog title - clear download history"
|
||||
},
|
||||
"dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.",
|
||||
"dialogClearHistoryMessage": "Tem certeza de que deseja limpar todo o histórico de downloads? Isso não pode ser desfeito.",
|
||||
"@dialogClearHistoryMessage": {
|
||||
"description": "Dialog message - clear history confirmation"
|
||||
},
|
||||
"dialogDeleteSelectedTitle": "Delete Selected",
|
||||
"dialogDeleteSelectedTitle": "Apagar Selecionados",
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.",
|
||||
"dialogDeleteSelectedMessage": "Apagar {count} {count, plural, =1{faixa} other{faixas}} do histórico?\n\nIsso também apagará os arquivos do armazenamento.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1117,11 +1117,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialogImportPlaylistTitle": "Import Playlist",
|
||||
"dialogImportPlaylistTitle": "Importar Playlist",
|
||||
"@dialogImportPlaylistTitle": {
|
||||
"description": "Dialog title - import CSV playlist"
|
||||
},
|
||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||
"dialogImportPlaylistMessage": "Encontradas {count} faixas no CSV. Adicionar à fila de download?",
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1130,7 +1130,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarAddedToQueue": "Added \"{trackName}\" to queue",
|
||||
"snackbarAddedToQueue": "\"{trackName}\" adicionada à fila",
|
||||
"@snackbarAddedToQueue": {
|
||||
"description": "Snackbar - track added to download queue",
|
||||
"placeholders": {
|
||||
@@ -1139,7 +1139,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarAddedTracksToQueue": "Added {count} tracks to queue",
|
||||
"snackbarAddedTracksToQueue": "{count} faixas adicionadas à fila",
|
||||
"@snackbarAddedTracksToQueue": {
|
||||
"description": "Snackbar - multiple tracks added to queue",
|
||||
"placeholders": {
|
||||
@@ -1148,7 +1148,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded",
|
||||
"snackbarAlreadyDownloaded": "\"{trackName}\" já foi baixada",
|
||||
"@snackbarAlreadyDownloaded": {
|
||||
"description": "Snackbar - track already exists",
|
||||
"placeholders": {
|
||||
@@ -1157,19 +1157,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarHistoryCleared": "History cleared",
|
||||
"snackbarHistoryCleared": "Histórico limpo",
|
||||
"@snackbarHistoryCleared": {
|
||||
"description": "Snackbar - history deleted"
|
||||
},
|
||||
"snackbarCredentialsSaved": "Credentials saved",
|
||||
"snackbarCredentialsSaved": "Credenciais salvas",
|
||||
"@snackbarCredentialsSaved": {
|
||||
"description": "Snackbar - Spotify credentials saved"
|
||||
},
|
||||
"snackbarCredentialsCleared": "Credentials cleared",
|
||||
"snackbarCredentialsCleared": "Credenciais removidas",
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}",
|
||||
"snackbarDeletedTracks": "{count} {count, plural, =1{faixa apagada} other{faixas apagadas}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1178,7 +1178,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarCannotOpenFile": "Cannot open file: {error}",
|
||||
"snackbarCannotOpenFile": "Não foi possível abrir o arquivo: {error}",
|
||||
"@snackbarCannotOpenFile": {
|
||||
"description": "Snackbar - file open error",
|
||||
"placeholders": {
|
||||
@@ -1187,15 +1187,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarFillAllFields": "Please fill all fields",
|
||||
"snackbarFillAllFields": "Por favor, preencha todos os campos",
|
||||
"@snackbarFillAllFields": {
|
||||
"description": "Snackbar - validation error"
|
||||
},
|
||||
"snackbarViewQueue": "View Queue",
|
||||
"snackbarViewQueue": "Ver Fila",
|
||||
"@snackbarViewQueue": {
|
||||
"description": "Snackbar action - view download queue"
|
||||
},
|
||||
"snackbarFailedToLoad": "Failed to load: {error}",
|
||||
"snackbarFailedToLoad": "Falha ao carregar: {error}",
|
||||
"@snackbarFailedToLoad": {
|
||||
"description": "Snackbar - loading error",
|
||||
"placeholders": {
|
||||
@@ -1204,7 +1204,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarUrlCopied": "{platform} URL copied to clipboard",
|
||||
"snackbarUrlCopied": "URL do {platform} copiada para a área de transferência",
|
||||
"@snackbarUrlCopied": {
|
||||
"description": "Snackbar - URL copied",
|
||||
"placeholders": {
|
||||
@@ -1214,23 +1214,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarFileNotFound": "File not found",
|
||||
"snackbarFileNotFound": "Arquivo não encontrado",
|
||||
"@snackbarFileNotFound": {
|
||||
"description": "Snackbar - file doesn't exist"
|
||||
},
|
||||
"snackbarSelectExtFile": "Please select a .spotiflac-ext file",
|
||||
"snackbarSelectExtFile": "Por favor, selecione um arquivo .spotiflac-ext",
|
||||
"@snackbarSelectExtFile": {
|
||||
"description": "Snackbar - wrong file type selected"
|
||||
},
|
||||
"snackbarProviderPrioritySaved": "Provider priority saved",
|
||||
"snackbarProviderPrioritySaved": "Prioridade de provedor salva",
|
||||
"@snackbarProviderPrioritySaved": {
|
||||
"description": "Snackbar - provider order saved"
|
||||
},
|
||||
"snackbarMetadataProviderSaved": "Metadata provider priority saved",
|
||||
"snackbarMetadataProviderSaved": "Prioridade de provedor de metadados salva",
|
||||
"@snackbarMetadataProviderSaved": {
|
||||
"description": "Snackbar - metadata provider order saved"
|
||||
},
|
||||
"snackbarExtensionInstalled": "{extensionName} installed.",
|
||||
"snackbarExtensionInstalled": "{extensionName} instalada.",
|
||||
"@snackbarExtensionInstalled": {
|
||||
"description": "Snackbar - extension installed successfully",
|
||||
"placeholders": {
|
||||
@@ -1239,7 +1239,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarExtensionUpdated": "{extensionName} updated.",
|
||||
"snackbarExtensionUpdated": "{extensionName} atualizada.",
|
||||
"@snackbarExtensionUpdated": {
|
||||
"description": "Snackbar - extension updated successfully",
|
||||
"placeholders": {
|
||||
@@ -1248,23 +1248,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarFailedToInstall": "Failed to install extension",
|
||||
"snackbarFailedToInstall": "Falha ao instalar extensão",
|
||||
"@snackbarFailedToInstall": {
|
||||
"description": "Snackbar - extension install error"
|
||||
},
|
||||
"snackbarFailedToUpdate": "Failed to update extension",
|
||||
"snackbarFailedToUpdate": "Falha ao atualizar extensão",
|
||||
"@snackbarFailedToUpdate": {
|
||||
"description": "Snackbar - extension update error"
|
||||
},
|
||||
"errorRateLimited": "Rate Limited",
|
||||
"errorRateLimited": "Taxa Limitada",
|
||||
"@errorRateLimited": {
|
||||
"description": "Error title - too many requests"
|
||||
},
|
||||
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.",
|
||||
"errorRateLimitedMessage": "Muitas solicitações. Por favor, aguarde um momento antes de pesquisar novamente.",
|
||||
"@errorRateLimitedMessage": {
|
||||
"description": "Error message - rate limit explanation"
|
||||
},
|
||||
"errorFailedToLoad": "Failed to load {item}",
|
||||
"errorFailedToLoad": "Falha ao carregar {item}",
|
||||
"@errorFailedToLoad": {
|
||||
"description": "Error message - loading failed",
|
||||
"placeholders": {
|
||||
@@ -1274,11 +1274,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorNoTracksFound": "No tracks found",
|
||||
"errorNoTracksFound": "Nenhuma faixa encontrada",
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||
"errorMissingExtensionSource": "Não foi possível carregar {item}: fonte de extensão ausente",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
"placeholders": {
|
||||
@@ -1287,23 +1287,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"statusQueued": "Queued",
|
||||
"statusQueued": "Na Fila",
|
||||
"@statusQueued": {
|
||||
"description": "Download status - waiting in queue"
|
||||
},
|
||||
"statusDownloading": "Downloading",
|
||||
"statusDownloading": "Baixando",
|
||||
"@statusDownloading": {
|
||||
"description": "Download status - in progress"
|
||||
},
|
||||
"statusFinalizing": "Finalizing",
|
||||
"statusFinalizing": "Finalizando",
|
||||
"@statusFinalizing": {
|
||||
"description": "Download status - writing metadata"
|
||||
},
|
||||
"statusCompleted": "Completed",
|
||||
"statusCompleted": "Concluído",
|
||||
"@statusCompleted": {
|
||||
"description": "Download status - finished"
|
||||
},
|
||||
"statusFailed": "Failed",
|
||||
"statusFailed": "Falhou",
|
||||
"@statusFailed": {
|
||||
"description": "Download status - error occurred"
|
||||
},
|
||||
@@ -1735,19 +1735,19 @@
|
||||
"@logNetworkErrorDescription": {
|
||||
"description": "Network error explanation"
|
||||
},
|
||||
"logNetworkErrorSuggestion": "Check your internet connection",
|
||||
"logNetworkErrorSuggestion": "Verifique a sua conexão com a internet",
|
||||
"@logNetworkErrorSuggestion": {
|
||||
"description": "Network error fix suggestion"
|
||||
},
|
||||
"logTrackNotFoundDescription": "Some tracks could not be found on download services",
|
||||
"logTrackNotFoundDescription": "Algumas faixas não foram encontradas nos serviços de download",
|
||||
"@logTrackNotFoundDescription": {
|
||||
"description": "Track not found explanation"
|
||||
},
|
||||
"logTrackNotFoundSuggestion": "The track may not be available in lossless quality",
|
||||
"logTrackNotFoundSuggestion": "A faixa pode não estar disponível em qualidade lossless",
|
||||
"@logTrackNotFoundSuggestion": {
|
||||
"description": "Track not found explanation"
|
||||
},
|
||||
"logTotalErrors": "Total errors: {count}",
|
||||
"logTotalErrors": "Total de erros: {count}",
|
||||
"@logTotalErrors": {
|
||||
"description": "Error count display",
|
||||
"placeholders": {
|
||||
@@ -1756,7 +1756,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"logAffected": "Affected: {domains}",
|
||||
"logAffected": "Afetados: {domains}",
|
||||
"@logAffected": {
|
||||
"description": "Affected domains display",
|
||||
"placeholders": {
|
||||
@@ -1765,7 +1765,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"logEntriesFiltered": "Entries ({count} filtered)",
|
||||
"logEntriesFiltered": "Entradas ({count} filtradas)",
|
||||
"@logEntriesFiltered": {
|
||||
"description": "Log count with filter active",
|
||||
"placeholders": {
|
||||
@@ -1774,7 +1774,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"logEntries": "Entries ({count})",
|
||||
"logEntries": "Entradas ({count})",
|
||||
"@logEntries": {
|
||||
"description": "Total log count",
|
||||
"placeholders": {
|
||||
@@ -1783,11 +1783,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"credentialsTitle": "Spotify Credentials",
|
||||
"credentialsTitle": "Credenciais do Spotify",
|
||||
"@credentialsTitle": {
|
||||
"description": "Credentials dialog title"
|
||||
},
|
||||
"credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.",
|
||||
"credentialsDescription": "Insira o seu Client ID e Secret para usar a sua própria cota de aplicativo do Spotify.",
|
||||
"@credentialsDescription": {
|
||||
"description": "Credentials dialog explanation"
|
||||
},
|
||||
@@ -2001,35 +2001,35 @@
|
||||
"@trackDownloaded": {
|
||||
"description": "Metadata label - download date"
|
||||
},
|
||||
"trackCopyLyrics": "Copy lyrics",
|
||||
"trackCopyLyrics": "Copiar letras",
|
||||
"@trackCopyLyrics": {
|
||||
"description": "Action - copy lyrics to clipboard"
|
||||
},
|
||||
"trackLyricsNotAvailable": "Lyrics not available for this track",
|
||||
"trackLyricsNotAvailable": "Letras não disponíveis para esta faixa",
|
||||
"@trackLyricsNotAvailable": {
|
||||
"description": "Message when lyrics not found"
|
||||
},
|
||||
"trackLyricsTimeout": "Request timed out. Try again later.",
|
||||
"trackLyricsTimeout": "A solicitação expirou. Tente novamente mais tarde.",
|
||||
"@trackLyricsTimeout": {
|
||||
"description": "Message when lyrics request times out"
|
||||
},
|
||||
"trackLyricsLoadFailed": "Failed to load lyrics",
|
||||
"trackLyricsLoadFailed": "Falha ao carregar letras",
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"trackCopiedToClipboard": "Copiado para a área de transferência",
|
||||
"@trackCopiedToClipboard": {
|
||||
"description": "Snackbar - content copied"
|
||||
},
|
||||
"trackDeleteConfirmTitle": "Remove from device?",
|
||||
"trackDeleteConfirmTitle": "Remover do dispositivo?",
|
||||
"@trackDeleteConfirmTitle": {
|
||||
"description": "Delete confirmation title"
|
||||
},
|
||||
"trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.",
|
||||
"trackDeleteConfirmMessage": "Isso apagará permanentemente o arquivo baixado e o removerá do seu histórico.",
|
||||
"@trackDeleteConfirmMessage": {
|
||||
"description": "Delete confirmation message"
|
||||
},
|
||||
"trackCannotOpen": "Cannot open: {message}",
|
||||
"trackCannotOpen": "Não foi possível abrir: {message}",
|
||||
"@trackCannotOpen": {
|
||||
"description": "Error opening file",
|
||||
"placeholders": {
|
||||
@@ -2038,15 +2038,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dateToday": "Today",
|
||||
"dateToday": "Hoje",
|
||||
"@dateToday": {
|
||||
"description": "Relative date - today"
|
||||
},
|
||||
"dateYesterday": "Yesterday",
|
||||
"dateYesterday": "Ontem",
|
||||
"@dateYesterday": {
|
||||
"description": "Relative date - yesterday"
|
||||
},
|
||||
"dateDaysAgo": "{count} days ago",
|
||||
"dateDaysAgo": "Há {count} dias",
|
||||
"@dateDaysAgo": {
|
||||
"description": "Relative date - days ago",
|
||||
"placeholders": {
|
||||
@@ -2055,7 +2055,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dateWeeksAgo": "{count} weeks ago",
|
||||
"dateWeeksAgo": "Há {count} semanas",
|
||||
"@dateWeeksAgo": {
|
||||
"description": "Relative date - weeks ago",
|
||||
"placeholders": {
|
||||
@@ -2064,7 +2064,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dateMonthsAgo": "{count} months ago",
|
||||
"dateMonthsAgo": "Há {count} meses",
|
||||
"@dateMonthsAgo": {
|
||||
"description": "Relative date - months ago",
|
||||
"placeholders": {
|
||||
@@ -2073,27 +2073,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"concurrentSequential": "Sequential",
|
||||
"concurrentSequential": "Sequencial",
|
||||
"@concurrentSequential": {
|
||||
"description": "Download mode - one at a time"
|
||||
},
|
||||
"concurrentParallel2": "2 Parallel",
|
||||
"concurrentParallel2": "2 Paralelos",
|
||||
"@concurrentParallel2": {
|
||||
"description": "Download mode - 2 simultaneous"
|
||||
},
|
||||
"concurrentParallel3": "3 Parallel",
|
||||
"concurrentParallel3": "3 Paralelos",
|
||||
"@concurrentParallel3": {
|
||||
"description": "Download mode - 3 simultaneous"
|
||||
},
|
||||
"tapToSeeError": "Tap to see error details",
|
||||
"tapToSeeError": "Toque para ver detalhes do erro",
|
||||
"@tapToSeeError": {
|
||||
"description": "Tooltip for failed download"
|
||||
},
|
||||
"storeFilterAll": "All",
|
||||
"storeFilterAll": "Todos",
|
||||
"@storeFilterAll": {
|
||||
"description": "Store filter - all extensions"
|
||||
},
|
||||
"storeFilterMetadata": "Metadata",
|
||||
"storeFilterMetadata": "Metadados",
|
||||
"@storeFilterMetadata": {
|
||||
"description": "Store filter - metadata providers"
|
||||
},
|
||||
@@ -2101,43 +2101,43 @@
|
||||
"@storeFilterDownload": {
|
||||
"description": "Store filter - download providers"
|
||||
},
|
||||
"storeFilterUtility": "Utility",
|
||||
"storeFilterUtility": "Utilitário",
|
||||
"@storeFilterUtility": {
|
||||
"description": "Store filter - utility extensions"
|
||||
},
|
||||
"storeFilterLyrics": "Lyrics",
|
||||
"storeFilterLyrics": "Letras",
|
||||
"@storeFilterLyrics": {
|
||||
"description": "Store filter - lyrics providers"
|
||||
},
|
||||
"storeFilterIntegration": "Integration",
|
||||
"storeFilterIntegration": "Integração",
|
||||
"@storeFilterIntegration": {
|
||||
"description": "Store filter - integrations"
|
||||
},
|
||||
"storeClearFilters": "Clear filters",
|
||||
"storeClearFilters": "Limpar filtros",
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"storeNoResults": "No extensions found",
|
||||
"storeNoResults": "Nenhuma extensão encontrada",
|
||||
"@storeNoResults": {
|
||||
"description": "Empty state when no extensions match filters"
|
||||
},
|
||||
"extensionProviderPriority": "Provider Priority",
|
||||
"extensionProviderPriority": "Prioridade de Provedor",
|
||||
"@extensionProviderPriority": {
|
||||
"description": "Extension capability - provider priority"
|
||||
},
|
||||
"extensionInstallButton": "Install Extension",
|
||||
"extensionInstallButton": "Instalar Extensão",
|
||||
"@extensionInstallButton": {
|
||||
"description": "Button to install extension"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Padrão (Deezer/Spotify)",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
||||
"extensionDefaultProviderSubtitle": "Usar pesquisa integrada",
|
||||
"@extensionDefaultProviderSubtitle": {
|
||||
"description": "Subtitle for default provider"
|
||||
},
|
||||
"extensionAuthor": "Author",
|
||||
"extensionAuthor": "Autor",
|
||||
"@extensionAuthor": {
|
||||
"description": "Extension detail - author"
|
||||
},
|
||||
@@ -2145,43 +2145,43 @@
|
||||
"@extensionId": {
|
||||
"description": "Extension detail - unique ID"
|
||||
},
|
||||
"extensionError": "Error",
|
||||
"extensionError": "Erro",
|
||||
"@extensionError": {
|
||||
"description": "Extension detail - error message"
|
||||
},
|
||||
"extensionCapabilities": "Capabilities",
|
||||
"extensionCapabilities": "Capacidades",
|
||||
"@extensionCapabilities": {
|
||||
"description": "Section header - extension features"
|
||||
},
|
||||
"extensionMetadataProvider": "Metadata Provider",
|
||||
"extensionMetadataProvider": "Provedor de Metadados",
|
||||
"@extensionMetadataProvider": {
|
||||
"description": "Capability - provides metadata"
|
||||
},
|
||||
"extensionDownloadProvider": "Download Provider",
|
||||
"extensionDownloadProvider": "Provedor de Download",
|
||||
"@extensionDownloadProvider": {
|
||||
"description": "Capability - provides downloads"
|
||||
},
|
||||
"extensionLyricsProvider": "Lyrics Provider",
|
||||
"extensionLyricsProvider": "Provedor de Letras",
|
||||
"@extensionLyricsProvider": {
|
||||
"description": "Capability - provides lyrics"
|
||||
},
|
||||
"extensionUrlHandler": "URL Handler",
|
||||
"extensionUrlHandler": "Manipulador de URL",
|
||||
"@extensionUrlHandler": {
|
||||
"description": "Capability - handles URLs"
|
||||
},
|
||||
"extensionQualityOptions": "Quality Options",
|
||||
"extensionQualityOptions": "Opções de Qualidade",
|
||||
"@extensionQualityOptions": {
|
||||
"description": "Capability - quality selection"
|
||||
},
|
||||
"extensionPostProcessingHooks": "Post-Processing Hooks",
|
||||
"extensionPostProcessingHooks": "Ganchos de Pós-Processamento",
|
||||
"@extensionPostProcessingHooks": {
|
||||
"description": "Capability - post-processing"
|
||||
},
|
||||
"extensionPermissions": "Permissions",
|
||||
"extensionPermissions": "Permissões",
|
||||
"@extensionPermissions": {
|
||||
"description": "Section header - required permissions"
|
||||
},
|
||||
"extensionSettings": "Settings",
|
||||
"extensionSettings": "Configurações",
|
||||
"@extensionSettings": {
|
||||
"description": "Section header - extension settings"
|
||||
},
|
||||
@@ -2376,31 +2376,31 @@
|
||||
"@folderNone": {
|
||||
"description": "Folder option - no organization"
|
||||
},
|
||||
"folderNoneSubtitle": "Save all files directly to download folder",
|
||||
"folderNoneSubtitle": "Salvar todos os arquivos diretamente na pasta de download",
|
||||
"@folderNoneSubtitle": {
|
||||
"description": "Subtitle for no folder organization"
|
||||
},
|
||||
"folderArtist": "Artist",
|
||||
"folderArtist": "Artista",
|
||||
"@folderArtist": {
|
||||
"description": "Folder option - by artist"
|
||||
},
|
||||
"folderArtistSubtitle": "Artist Name/filename",
|
||||
"folderArtistSubtitle": "Nome do Artista/nome do arquivo",
|
||||
"@folderArtistSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"folderAlbum": "Album",
|
||||
"folderAlbum": "Álbum",
|
||||
"@folderAlbum": {
|
||||
"description": "Folder option - by album"
|
||||
},
|
||||
"folderAlbumSubtitle": "Album Name/filename",
|
||||
"folderAlbumSubtitle": "Nome do Álbum/nome do arquivo",
|
||||
"@folderAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"folderArtistAlbum": "Artist/Album",
|
||||
"folderArtistAlbum": "Artista/Álbum",
|
||||
"@folderArtistAlbum": {
|
||||
"description": "Folder option - nested"
|
||||
},
|
||||
"folderArtistAlbumSubtitle": "Artist Name/Album Name/filename",
|
||||
"folderArtistAlbumSubtitle": "Nome do Artista/Nome do Álbum/nome do arquivo",
|
||||
"@folderArtistAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
@@ -2424,103 +2424,103 @@
|
||||
"@serviceSpotify": {
|
||||
"description": "Service name - DO NOT TRANSLATE"
|
||||
},
|
||||
"appearanceAmoledDark": "AMOLED Dark",
|
||||
"appearanceAmoledDark": "AMOLED Escuro",
|
||||
"@appearanceAmoledDark": {
|
||||
"description": "Theme option - pure black"
|
||||
},
|
||||
"appearanceAmoledDarkSubtitle": "Pure black background",
|
||||
"appearanceAmoledDarkSubtitle": "Fundo preto puro",
|
||||
"@appearanceAmoledDarkSubtitle": {
|
||||
"description": "Subtitle for AMOLED dark"
|
||||
},
|
||||
"appearanceChooseAccentColor": "Choose Accent Color",
|
||||
"appearanceChooseAccentColor": "Escolher Cor de Destaque",
|
||||
"@appearanceChooseAccentColor": {
|
||||
"description": "Color picker dialog title"
|
||||
},
|
||||
"appearanceChooseTheme": "Theme Mode",
|
||||
"appearanceChooseTheme": "Modo de Tema",
|
||||
"@appearanceChooseTheme": {
|
||||
"description": "Theme picker dialog title"
|
||||
},
|
||||
"queueTitle": "Download Queue",
|
||||
"queueTitle": "Fila de Download",
|
||||
"@queueTitle": {
|
||||
"description": "Queue screen title"
|
||||
},
|
||||
"queueClearAll": "Clear All",
|
||||
"queueClearAll": "Limpar Tudo",
|
||||
"@queueClearAll": {
|
||||
"description": "Button - clear all queue items"
|
||||
},
|
||||
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
|
||||
"queueClearAllMessage": "Tem certeza de que deseja limpar todos os downloads?",
|
||||
"@queueClearAllMessage": {
|
||||
"description": "Clear queue confirmation"
|
||||
},
|
||||
"queueEmpty": "No downloads in queue",
|
||||
"queueEmpty": "Nenhum download na fila",
|
||||
"@queueEmpty": {
|
||||
"description": "Empty queue state title"
|
||||
},
|
||||
"queueEmptySubtitle": "Add tracks from the home screen",
|
||||
"queueEmptySubtitle": "Adicione faixas a partir da tela inicial",
|
||||
"@queueEmptySubtitle": {
|
||||
"description": "Empty queue state subtitle"
|
||||
},
|
||||
"queueClearCompleted": "Clear completed",
|
||||
"queueClearCompleted": "Limpar concluídos",
|
||||
"@queueClearCompleted": {
|
||||
"description": "Button - clear finished downloads"
|
||||
},
|
||||
"queueDownloadFailed": "Download Failed",
|
||||
"queueDownloadFailed": "Download Falhou",
|
||||
"@queueDownloadFailed": {
|
||||
"description": "Error dialog title"
|
||||
},
|
||||
"queueTrackLabel": "Track:",
|
||||
"queueTrackLabel": "Faixa:",
|
||||
"@queueTrackLabel": {
|
||||
"description": "Label in error dialog"
|
||||
},
|
||||
"queueArtistLabel": "Artist:",
|
||||
"queueArtistLabel": "Artista:",
|
||||
"@queueArtistLabel": {
|
||||
"description": "Label in error dialog"
|
||||
},
|
||||
"queueErrorLabel": "Error:",
|
||||
"queueErrorLabel": "Erro:",
|
||||
"@queueErrorLabel": {
|
||||
"description": "Label in error dialog"
|
||||
},
|
||||
"queueUnknownError": "Unknown error",
|
||||
"queueUnknownError": "Erro desconhecido",
|
||||
"@queueUnknownError": {
|
||||
"description": "Fallback error message"
|
||||
},
|
||||
"albumFolderArtistAlbum": "Artist / Album",
|
||||
"albumFolderArtistAlbum": "Artista / Álbum",
|
||||
"@albumFolderArtistAlbum": {
|
||||
"description": "Album folder option"
|
||||
},
|
||||
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/",
|
||||
"albumFolderArtistAlbumSubtitle": "Álbuns/Nome do Artista/Nome do Álbum/",
|
||||
"@albumFolderArtistAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistYearAlbum": "Artist / [Year] Album",
|
||||
"albumFolderArtistYearAlbum": "Artista / [Ano] Álbum",
|
||||
"@albumFolderArtistYearAlbum": {
|
||||
"description": "Album folder option with year"
|
||||
},
|
||||
"albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/",
|
||||
"albumFolderArtistYearAlbumSubtitle": "Álbuns/Nome do Artista/[2005] Nome do Álbum/",
|
||||
"@albumFolderArtistYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderAlbumOnly": "Album Only",
|
||||
"albumFolderAlbumOnly": "Apenas Álbum",
|
||||
"@albumFolderAlbumOnly": {
|
||||
"description": "Album folder option"
|
||||
},
|
||||
"albumFolderAlbumOnlySubtitle": "Albums/Album Name/",
|
||||
"albumFolderAlbumOnlySubtitle": "Álbuns/Nome do Álbum/",
|
||||
"@albumFolderAlbumOnlySubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderYearAlbum": "[Year] Album",
|
||||
"albumFolderYearAlbum": "[Ano] Álbum",
|
||||
"@albumFolderYearAlbum": {
|
||||
"description": "Album folder option with year"
|
||||
},
|
||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
||||
"albumFolderYearAlbumSubtitle": "Álbuns/[2005] Nome do Álbum/",
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"downloadedAlbumDeleteSelected": "Apagar Selecionados",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
},
|
||||
"downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.",
|
||||
"downloadedAlbumDeleteMessage": "Apagar {count} {count, plural, =1{faixa} other{faixas}} deste álbum?\n\nIsso também apagará os arquivos do armazenamento.",
|
||||
"@downloadedAlbumDeleteMessage": {
|
||||
"description": "Delete confirmation with count",
|
||||
"placeholders": {
|
||||
@@ -2529,11 +2529,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumTracksHeader": "Tracks",
|
||||
"downloadedAlbumTracksHeader": "Faixas",
|
||||
"@downloadedAlbumTracksHeader": {
|
||||
"description": "Section header for tracks"
|
||||
},
|
||||
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
||||
"downloadedAlbumDownloadedCount": "{count} baixadas",
|
||||
"@downloadedAlbumDownloadedCount": {
|
||||
"description": "Downloaded tracks count badge",
|
||||
"placeholders": {
|
||||
@@ -2542,7 +2542,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumSelectedCount": "{count} selected",
|
||||
"downloadedAlbumSelectedCount": "{count} selecionadas",
|
||||
"@downloadedAlbumSelectedCount": {
|
||||
"description": "Selection count indicator",
|
||||
"placeholders": {
|
||||
@@ -2551,15 +2551,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumAllSelected": "All tracks selected",
|
||||
"downloadedAlbumAllSelected": "Todas as faixas selecionadas",
|
||||
"@downloadedAlbumAllSelected": {
|
||||
"description": "Status - all items selected"
|
||||
},
|
||||
"downloadedAlbumTapToSelect": "Tap tracks to select",
|
||||
"downloadedAlbumTapToSelect": "Toque nas faixas para selecionar",
|
||||
"@downloadedAlbumTapToSelect": {
|
||||
"description": "Selection hint"
|
||||
},
|
||||
"downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}",
|
||||
"downloadedAlbumDeleteCount": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
|
||||
"@downloadedAlbumDeleteCount": {
|
||||
"description": "Delete button text with count",
|
||||
"placeholders": {
|
||||
@@ -2568,23 +2568,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
||||
"downloadedAlbumSelectToDelete": "Selecione faixas para apagar",
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"utilityFunctions": "Funções Utilitárias",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
},
|
||||
"recentTypeArtist": "Artist",
|
||||
"recentTypeArtist": "Artista",
|
||||
"@recentTypeArtist": {
|
||||
"description": "Recent access item type - artist"
|
||||
},
|
||||
"recentTypeAlbum": "Album",
|
||||
"recentTypeAlbum": "Álbum",
|
||||
"@recentTypeAlbum": {
|
||||
"description": "Recent access item type - album"
|
||||
},
|
||||
"recentTypeSong": "Song",
|
||||
"recentTypeSong": "Música",
|
||||
"@recentTypeSong": {
|
||||
"description": "Recent access item type - song/track"
|
||||
},
|
||||
@@ -2602,7 +2602,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorGeneric": "Error: {message}",
|
||||
"errorGeneric": "Erro: {message}",
|
||||
"@errorGeneric": {
|
||||
"description": "Generic error message format",
|
||||
"placeholders": {
|
||||
|
||||
+263
-10
@@ -85,7 +85,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}",
|
||||
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} =1 {1 альбом} other {{count} альбомов}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -127,6 +127,10 @@
|
||||
"@historyNoSinglesSubtitle": {
|
||||
"description": "Empty state subtitle for singles filter"
|
||||
},
|
||||
"historySearchHint": "Поиск в истории...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Настройки",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
@@ -512,6 +516,10 @@
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Переводчики",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Особая благодарность",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
@@ -544,6 +552,26 @@
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
"aboutTelegramChannel": "Telegram канал",
|
||||
"@aboutTelegramChannel": {
|
||||
"description": "Link to Telegram channel"
|
||||
},
|
||||
"aboutTelegramChannelSubtitle": "Объявления и обновления",
|
||||
"@aboutTelegramChannelSubtitle": {
|
||||
"description": "Subtitle for Telegram channel"
|
||||
},
|
||||
"aboutTelegramChat": "Сообщество в Telegram",
|
||||
"@aboutTelegramChat": {
|
||||
"description": "Link to Telegram chat group"
|
||||
},
|
||||
"aboutTelegramChatSubtitle": "Чат с другими пользователями",
|
||||
"@aboutTelegramChatSubtitle": {
|
||||
"description": "Subtitle for Telegram chat"
|
||||
},
|
||||
"aboutSocial": "Соцсети",
|
||||
"@aboutSocial": {
|
||||
"description": "Section for social links"
|
||||
},
|
||||
"aboutSupport": "Поддержка",
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
@@ -596,7 +624,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -633,7 +661,7 @@
|
||||
"@artistCompilations": {
|
||||
"description": "Section header for compilations"
|
||||
},
|
||||
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}",
|
||||
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} =1 {1 релиз} other {{count} релизов}}",
|
||||
"@artistReleases": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -1108,7 +1136,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1122,6 +1150,15 @@
|
||||
"description": "Dialog title - import CSV playlist"
|
||||
},
|
||||
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
|
||||
"csvImportTracks": "{count} треков из CSV",
|
||||
"@csvImportTracks": {
|
||||
"description": "Label shown in quality picker for CSV import",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1169,7 +1206,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1376,7 +1413,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"placeholders": {
|
||||
@@ -1851,6 +1888,42 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Тексты песен",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Режим текстов песен",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Выберите как сохранить тексты песен при скачивании",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Вставить в файл",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Встроить текст в метаданные FLAC",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "Внешний файл .lrc",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Отдельный файл .lrc для плееров, таких, как Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Оба варианта",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Вставить и сохранить файл .lrc",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
"sectionColor": "Цвет",
|
||||
"@sectionColor": {
|
||||
"description": "Settings section header"
|
||||
@@ -1916,7 +1989,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
@@ -1997,6 +2070,18 @@
|
||||
"@trackReleaseDate": {
|
||||
"description": "Metadata label - release date"
|
||||
},
|
||||
"trackGenre": "Жанр",
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Заголовок",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Авторские права",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
"trackDownloaded": "Скачано",
|
||||
"@trackDownloaded": {
|
||||
"description": "Metadata label - download date"
|
||||
@@ -2017,6 +2102,18 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Вставить текст песни",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Текст успешно добавлен",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Инструментальный трек",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
"trackCopiedToClipboard": "Скопировано в буфер обмена",
|
||||
"@trackCopiedToClipboard": {
|
||||
"description": "Snackbar - content copied"
|
||||
@@ -2328,6 +2425,26 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {
|
||||
"description": "Quality option - MP3 lossy format"
|
||||
},
|
||||
"qualityMp3Subtitle": "320 кбит/с (Конвертировано из FLAC)",
|
||||
"@qualityMp3Subtitle": {
|
||||
"description": "Technical spec for MP3"
|
||||
},
|
||||
"enableMp3Option": "Скачивние в MP3",
|
||||
"@enableMp3Option": {
|
||||
"description": "Setting - enable MP3 quality option"
|
||||
},
|
||||
"enableMp3OptionSubtitleOn": "MP3 качество доступно",
|
||||
"@enableMp3OptionSubtitleOn": {
|
||||
"description": "Subtitle when MP3 is enabled"
|
||||
},
|
||||
"enableMp3OptionSubtitleOff": "Скачивать FLAC и конвертировать в MP3 320 кбит/с",
|
||||
"@enableMp3OptionSubtitleOff": {
|
||||
"description": "Subtitle when MP3 is disabled"
|
||||
},
|
||||
"qualityNote": "Фактическое качество зависит от доступности треков в сервисе",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
@@ -2516,11 +2633,19 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Исполнитель / Альбом + Синглы",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Исполнитель/Альбом и Исполнитель/Сингл/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Удалить выбранные",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
},
|
||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||
"@downloadedAlbumDeleteMessage": {
|
||||
"description": "Delete confirmation with count",
|
||||
"placeholders": {
|
||||
@@ -2559,7 +2684,7 @@
|
||||
"@downloadedAlbumTapToSelect": {
|
||||
"description": "Selection hint"
|
||||
},
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"@downloadedAlbumDeleteCount": {
|
||||
"description": "Delete button text with count",
|
||||
"placeholders": {
|
||||
@@ -2572,6 +2697,16 @@
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
"downloadedAlbumDiscHeader": "Диск {discNumber}",
|
||||
"@downloadedAlbumDiscHeader": {
|
||||
"description": "Header for disc separator in multi-disc albums",
|
||||
"placeholders": {
|
||||
"discNumber": {
|
||||
"type": "int",
|
||||
"example": "1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utilityFunctions": "Функции утилиты",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
@@ -2611,5 +2746,123 @@
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Скачать дискографию",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Скачать всё",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyDownloadAllSubtitle": "{count} треков из {albumCount} релизов",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Только альбомы",
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} треков из {albumCount} альбомов",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Только синглы и EP",
|
||||
"@discographySinglesOnly": {
|
||||
"description": "Option - download only singles"
|
||||
},
|
||||
"discographySinglesOnlySubtitle": "{count} треков из {albumCount} синглов",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Выбрать альбомы...",
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Выберите конкретные альбомы или синглы",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
"discographyFetchingTracks": "Получение треков...",
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Получение {current} из {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} выбрано",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Скачать выбранное",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Добавлено {count} треков в очередь",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} добавлено, {skipped} уже скачано",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {
|
||||
"type": "int"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "Нет доступных альбомов",
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Не удалось получить некоторые альбомы",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
}
|
||||
}
|
||||
+2865
-4
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,10 @@
|
||||
"@historyNoSinglesSubtitle": {
|
||||
"description": "Empty state subtitle for singles filter"
|
||||
},
|
||||
"historySearchHint": "Search history...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Settings",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
@@ -512,6 +516,10 @@
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Translators",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
@@ -544,6 +552,26 @@
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
"aboutTelegramChannel": "Telegram Channel",
|
||||
"@aboutTelegramChannel": {
|
||||
"description": "Link to Telegram channel"
|
||||
},
|
||||
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||
"@aboutTelegramChannelSubtitle": {
|
||||
"description": "Subtitle for Telegram channel"
|
||||
},
|
||||
"aboutTelegramChat": "Telegram Community",
|
||||
"@aboutTelegramChat": {
|
||||
"description": "Link to Telegram chat group"
|
||||
},
|
||||
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||
"@aboutTelegramChatSubtitle": {
|
||||
"description": "Subtitle for Telegram chat"
|
||||
},
|
||||
"aboutSocial": "Social",
|
||||
"@aboutSocial": {
|
||||
"description": "Section for social links"
|
||||
},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
@@ -1122,6 +1150,15 @@
|
||||
"description": "Dialog title - import CSV playlist"
|
||||
},
|
||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||
"csvImportTracks": "{count} tracks from CSV",
|
||||
"@csvImportTracks": {
|
||||
"description": "Label shown in quality picker for CSV import",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1851,6 +1888,42 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Both",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
"sectionColor": "Color",
|
||||
"@sectionColor": {
|
||||
"description": "Settings section header"
|
||||
@@ -1997,6 +2070,18 @@
|
||||
"@trackReleaseDate": {
|
||||
"description": "Metadata label - release date"
|
||||
},
|
||||
"trackGenre": "Genre",
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Label",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Copyright",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
"trackDownloaded": "Downloaded",
|
||||
"@trackDownloaded": {
|
||||
"description": "Metadata label - download date"
|
||||
@@ -2017,6 +2102,18 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"@trackCopiedToClipboard": {
|
||||
"description": "Snackbar - content copied"
|
||||
@@ -2328,6 +2425,26 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {
|
||||
"description": "Quality option - MP3 lossy format"
|
||||
},
|
||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||
"@qualityMp3Subtitle": {
|
||||
"description": "Technical spec for MP3"
|
||||
},
|
||||
"enableMp3Option": "Enable MP3 Option",
|
||||
"@enableMp3Option": {
|
||||
"description": "Setting - enable MP3 quality option"
|
||||
},
|
||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||
"@enableMp3OptionSubtitleOn": {
|
||||
"description": "Subtitle when MP3 is enabled"
|
||||
},
|
||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||
"@enableMp3OptionSubtitleOff": {
|
||||
"description": "Subtitle when MP3 is disabled"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
@@ -2516,6 +2633,14 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
@@ -2572,6 +2697,16 @@
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
"@downloadedAlbumDiscHeader": {
|
||||
"description": "Header for disc separator in multi-disc albums",
|
||||
"placeholders": {
|
||||
"discNumber": {
|
||||
"type": "int",
|
||||
"example": "1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
@@ -2611,5 +2746,123 @@
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Albums Only",
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Singles & EPs Only",
|
||||
"@discographySinglesOnly": {
|
||||
"description": "Option - download only singles"
|
||||
},
|
||||
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Select Albums...",
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
"discographyFetchingTracks": "Fetching tracks...",
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} selected",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {
|
||||
"type": "int"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,10 @@
|
||||
"@historyNoSinglesSubtitle": {
|
||||
"description": "Empty state subtitle for singles filter"
|
||||
},
|
||||
"historySearchHint": "Search history...",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Settings",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
@@ -512,6 +516,10 @@
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Translators",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
@@ -544,6 +552,26 @@
|
||||
"@aboutFeatureRequestSubtitle": {
|
||||
"description": "Subtitle for feature request"
|
||||
},
|
||||
"aboutTelegramChannel": "Telegram Channel",
|
||||
"@aboutTelegramChannel": {
|
||||
"description": "Link to Telegram channel"
|
||||
},
|
||||
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||
"@aboutTelegramChannelSubtitle": {
|
||||
"description": "Subtitle for Telegram channel"
|
||||
},
|
||||
"aboutTelegramChat": "Telegram Community",
|
||||
"@aboutTelegramChat": {
|
||||
"description": "Link to Telegram chat group"
|
||||
},
|
||||
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||
"@aboutTelegramChatSubtitle": {
|
||||
"description": "Subtitle for Telegram chat"
|
||||
},
|
||||
"aboutSocial": "Social",
|
||||
"@aboutSocial": {
|
||||
"description": "Section for social links"
|
||||
},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
@@ -1122,6 +1150,15 @@
|
||||
"description": "Dialog title - import CSV playlist"
|
||||
},
|
||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||
"csvImportTracks": "{count} tracks from CSV",
|
||||
"@csvImportTracks": {
|
||||
"description": "Label shown in quality picker for CSV import",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dialogImportPlaylistMessage": {
|
||||
"description": "Dialog message - import playlist confirmation",
|
||||
"placeholders": {
|
||||
@@ -1851,6 +1888,42 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Both",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
"sectionColor": "Color",
|
||||
"@sectionColor": {
|
||||
"description": "Settings section header"
|
||||
@@ -1997,6 +2070,18 @@
|
||||
"@trackReleaseDate": {
|
||||
"description": "Metadata label - release date"
|
||||
},
|
||||
"trackGenre": "Genre",
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Label",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Copyright",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
"trackDownloaded": "Downloaded",
|
||||
"@trackDownloaded": {
|
||||
"description": "Metadata label - download date"
|
||||
@@ -2017,6 +2102,18 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
"trackCopiedToClipboard": "Copied to clipboard",
|
||||
"@trackCopiedToClipboard": {
|
||||
"description": "Snackbar - content copied"
|
||||
@@ -2328,6 +2425,26 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"qualityMp3": "MP3",
|
||||
"@qualityMp3": {
|
||||
"description": "Quality option - MP3 lossy format"
|
||||
},
|
||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||
"@qualityMp3Subtitle": {
|
||||
"description": "Technical spec for MP3"
|
||||
},
|
||||
"enableMp3Option": "Enable MP3 Option",
|
||||
"@enableMp3Option": {
|
||||
"description": "Setting - enable MP3 quality option"
|
||||
},
|
||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||
"@enableMp3OptionSubtitleOn": {
|
||||
"description": "Subtitle when MP3 is enabled"
|
||||
},
|
||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||
"@enableMp3OptionSubtitleOff": {
|
||||
"description": "Subtitle when MP3 is disabled"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
@@ -2516,6 +2633,14 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
@@ -2572,6 +2697,16 @@
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||
"@downloadedAlbumDiscHeader": {
|
||||
"description": "Header for disc separator in multi-disc albums",
|
||||
"placeholders": {
|
||||
"discNumber": {
|
||||
"type": "int",
|
||||
"example": "1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utilityFunctions": "Utility Functions",
|
||||
"@utilityFunctions": {
|
||||
"description": "Extension capability - utility functions"
|
||||
@@ -2611,5 +2746,123 @@
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
"discographyDownloadAll": "Download All",
|
||||
"@discographyDownloadAll": {
|
||||
"description": "Option - download entire discography"
|
||||
},
|
||||
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||
"@discographyDownloadAllSubtitle": {
|
||||
"description": "Subtitle showing total tracks and albums",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyAlbumsOnly": "Albums Only",
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySinglesOnly": "Singles & EPs Only",
|
||||
"@discographySinglesOnly": {
|
||||
"description": "Option - download only singles"
|
||||
},
|
||||
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||
"@discographySinglesOnlySubtitle": {
|
||||
"description": "Subtitle showing singles tracks count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"albumCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectAlbums": "Select Albums...",
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
"discographyFetchingTracks": "Fetching tracks...",
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySelectedCount": "{count} selected",
|
||||
"@discographySelectedCount": {
|
||||
"description": "Selection count badge",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownloadSelected": "Download Selected",
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||
"@discographySkippedDownloaded": {
|
||||
"description": "Snackbar - with skipped tracks count",
|
||||
"placeholders": {
|
||||
"added": {
|
||||
"type": "int"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyNoAlbums": "No albums available",
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ const List<Locale> filteredSupportedLocales = <Locale>[
|
||||
Locale('es', 'ES'),
|
||||
Locale('id'),
|
||||
Locale('pt', 'PT'),
|
||||
Locale('ja'),
|
||||
Locale('tr'),
|
||||
];
|
||||
|
||||
/// Set of locale codes for quick lookup.
|
||||
@@ -27,4 +29,6 @@ const Set<String> filteredLocaleCodes = <String>{
|
||||
'es_ES',
|
||||
'id',
|
||||
'pt_PT',
|
||||
'ja',
|
||||
'tr',
|
||||
};
|
||||
|
||||
+1
-1
@@ -43,6 +43,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeExtensions();
|
||||
ref.read(downloadHistoryProvider);
|
||||
}
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
@@ -62,7 +63,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.watch(downloadHistoryProvider);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ class AppSettings {
|
||||
final String albumFolderStructure;
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
final bool enableMp3Option;
|
||||
final String lyricsMode;
|
||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
|
||||
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -62,8 +64,10 @@ class AppSettings {
|
||||
this.albumFolderStructure = 'artist_album',
|
||||
this.showExtensionStore = true,
|
||||
this.locale = 'system',
|
||||
this.enableMp3Option = false,
|
||||
this.lyricsMode = 'embed',
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.useAllFilesAccess = false,
|
||||
this.autoExportFailedDownloads = false,
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -95,8 +99,10 @@ class AppSettings {
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
String? locale,
|
||||
bool? enableMp3Option,
|
||||
String? lyricsMode,
|
||||
String? tidalHighFormat,
|
||||
bool? useAllFilesAccess,
|
||||
bool? autoExportFailedDownloads,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -126,8 +132,10 @@ class AppSettings {
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
locale: locale ?? this.locale,
|
||||
enableMp3Option: enableMp3Option ?? this.enableMp3Option,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,11 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
enableMp3Option: json['enableMp3Option'] as bool? ?? false,
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
autoExportFailedDownloads:
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -69,6 +72,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
'enableMp3Option': instance.enableMp3Option,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
};
|
||||
|
||||
@@ -150,15 +150,12 @@ class DownloadHistoryState {
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
/// O(1) check if spotify_id exists
|
||||
bool isDownloaded(String spotifyId) =>
|
||||
_downloadedSpotifyIds.contains(spotifyId);
|
||||
|
||||
/// O(1) lookup by spotify_id
|
||||
|
||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
|
||||
_bySpotifyId[spotifyId];
|
||||
|
||||
/// O(1) lookup by ISRC
|
||||
|
||||
DownloadHistoryItem? getByIsrc(String isrc) =>
|
||||
_byIsrc[isrc];
|
||||
|
||||
@@ -177,7 +174,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return DownloadHistoryState();
|
||||
}
|
||||
|
||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||
void _loadFromDatabaseSync() {
|
||||
if (_isLoaded) return;
|
||||
_isLoaded = true;
|
||||
@@ -193,7 +189,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
_historyLog.i('Migrated history from SharedPreferences to SQLite');
|
||||
}
|
||||
|
||||
// Migrate iOS paths if container UUID changed after app update
|
||||
if (Platform.isIOS) {
|
||||
final pathsMigrated = await _db.migrateIosContainerPaths();
|
||||
if (pathsMigrated) {
|
||||
@@ -264,12 +259,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return state.getBySpotifyId(spotifyId);
|
||||
}
|
||||
|
||||
/// O(1) lookup by ISRC
|
||||
DownloadHistoryItem? getByIsrc(String isrc) {
|
||||
return state.getByIsrc(isrc);
|
||||
}
|
||||
|
||||
/// Async version with database lookup (for cases where in-memory might be stale)
|
||||
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
|
||||
final inMemory = state.getBySpotifyId(spotifyId);
|
||||
if (inMemory != null) return inMemory;
|
||||
@@ -286,7 +279,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Get database stats for debugging
|
||||
Future<int> getDatabaseCount() async {
|
||||
return await _db.getCount();
|
||||
}
|
||||
@@ -722,7 +714,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final isSingle = track.isSingle;
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
|
||||
// New option: Singles folder inside Artist folder
|
||||
if (albumFolderStructure == 'artist_album_singles') {
|
||||
if (isSingle) {
|
||||
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
|
||||
@@ -736,7 +727,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Existing behavior: Separate Albums/ and Singles/ at root
|
||||
if (isSingle) {
|
||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
||||
@@ -804,7 +794,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/// Extract year from release date (format: "2005-06-13" or "2005")
|
||||
String? _extractYear(String? releaseDate) {
|
||||
if (releaseDate == null || releaseDate.isEmpty) return null;
|
||||
final match = _yearRegex.firstMatch(releaseDate);
|
||||
@@ -1023,12 +1012,74 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
void removeItem(String id) {
|
||||
void removeItem(String id) {
|
||||
final items = state.items.where((item) => item.id != id).toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage();
|
||||
}
|
||||
|
||||
/// Export failed downloads to a TXT file
|
||||
/// Returns the file path if successful, null otherwise
|
||||
Future<String?> exportFailedDownloads() async {
|
||||
final failedItems = state.items
|
||||
.where((item) => item.status == DownloadStatus.failed)
|
||||
.toList();
|
||||
|
||||
if (failedItems.isEmpty) {
|
||||
_log.d('No failed downloads to export');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('# SpotiFLAC Failed Downloads');
|
||||
buffer.writeln('# Exported: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('# Total: ${failedItems.length} tracks');
|
||||
buffer.writeln('#');
|
||||
buffer.writeln('# Format: Track - Artist | Spotify URL | Error');
|
||||
buffer.writeln('');
|
||||
|
||||
for (final item in failedItems) {
|
||||
final track = item.track;
|
||||
final spotifyUrl = track.id.startsWith('deezer:')
|
||||
? 'https://www.deezer.com/track/${track.id.substring(7)}'
|
||||
: 'https://open.spotify.com/track/${track.id}';
|
||||
final error = item.error ?? 'Unknown error';
|
||||
buffer.writeln('${track.name} - ${track.artistName} | $spotifyUrl | $error');
|
||||
}
|
||||
|
||||
// Save to download directory
|
||||
String exportDir = state.outputDir;
|
||||
if (exportDir.isEmpty) {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
exportDir = dir.path;
|
||||
}
|
||||
|
||||
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').split('.').first;
|
||||
final fileName = 'failed_downloads_$timestamp.txt';
|
||||
final filePath = '$exportDir/$fileName';
|
||||
|
||||
final file = File(filePath);
|
||||
await file.writeAsString(buffer.toString());
|
||||
|
||||
_log.i('Exported ${failedItems.length} failed downloads to: $filePath');
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
_log.e('Failed to export failed downloads: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all failed downloads from queue
|
||||
void clearFailedDownloads() {
|
||||
final items = state.items
|
||||
.where((item) => item.status != DownloadStatus.failed)
|
||||
.toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage();
|
||||
_log.d('Cleared failed downloads from queue');
|
||||
}
|
||||
|
||||
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -1075,7 +1126,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same logic as Go backend cover.go
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
const spotifySize300 = 'ab67616d00001e02';
|
||||
const spotifySize640 = 'ab67616d0000b273';
|
||||
@@ -1192,7 +1242,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
// Skip instrumental tracks (no lyrics to embed)
|
||||
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
@@ -1323,7 +1372,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('MP3 Metadata map content: $metadata');
|
||||
|
||||
if (settings.embedLyrics) {
|
||||
final lyricsMode = settings.lyricsMode;
|
||||
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
|
||||
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
|
||||
|
||||
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
|
||||
try {
|
||||
final durationMs = track.duration * 1000;
|
||||
|
||||
@@ -1336,12 +1389,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
if (lrcContent.isNotEmpty) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
|
||||
if (shouldEmbed) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
|
||||
}
|
||||
|
||||
if (shouldSaveExternal) {
|
||||
try {
|
||||
final lrcPath = mp3Path.replaceAll(RegExp(r'\.mp3$', caseSensitive: false), '.lrc');
|
||||
await File(lrcPath).writeAsString(lrcContent);
|
||||
_log.d('External LRC file saved: $lrcPath');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save external LRC file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to fetch lyrics for MP3 embedding: $e');
|
||||
_log.w('Failed to fetch lyrics for MP3: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1376,6 +1441,162 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _embedMetadataToOpus(
|
||||
String opusPath,
|
||||
Track track, {
|
||||
String? genre,
|
||||
String? label,
|
||||
String? copyright,
|
||||
}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
String? coverPath;
|
||||
var coverUrl = track.coverUrl;
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
try {
|
||||
if (settings.maxQualityCover) {
|
||||
coverUrl = _upgradeToMaxQualityCover(coverUrl);
|
||||
_log.d('Cover URL upgraded to max quality for Opus: $coverUrl');
|
||||
}
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId =
|
||||
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||
coverPath = '${tempDir.path}/cover_opus_$uniqueId.jpg';
|
||||
|
||||
final httpClient = HttpClient();
|
||||
final request = await httpClient.getUrl(Uri.parse(coverUrl));
|
||||
final response = await request.close();
|
||||
if (response.statusCode == 200) {
|
||||
final file = File(coverPath);
|
||||
final sink = file.openWrite();
|
||||
await response.pipe(sink);
|
||||
await sink.close();
|
||||
_log.d('Cover downloaded for Opus: $coverPath');
|
||||
} else {
|
||||
_log.w('Failed to download cover for Opus: HTTP ${response.statusCode}');
|
||||
coverPath = null;
|
||||
}
|
||||
httpClient.close();
|
||||
} catch (e) {
|
||||
_log.e('Failed to download cover for Opus: $e');
|
||||
coverPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final metadata = <String, String>{
|
||||
'TITLE': track.name,
|
||||
'ARTIST': track.artistName,
|
||||
'ALBUM': track.albumName,
|
||||
};
|
||||
|
||||
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
|
||||
track.artistName;
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
}
|
||||
|
||||
if (track.discNumber != null) {
|
||||
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||
}
|
||||
|
||||
if (track.releaseDate != null) {
|
||||
metadata['DATE'] = track.releaseDate!;
|
||||
}
|
||||
|
||||
if (track.isrc != null) {
|
||||
metadata['ISRC'] = track.isrc!;
|
||||
}
|
||||
|
||||
if (genre != null && genre.isNotEmpty) {
|
||||
metadata['GENRE'] = genre;
|
||||
_log.d('Adding GENRE to Opus: $genre');
|
||||
}
|
||||
if (label != null && label.isNotEmpty) {
|
||||
metadata['ORGANIZATION'] = label;
|
||||
_log.d('Adding ORGANIZATION (label) to Opus: $label');
|
||||
}
|
||||
if (copyright != null && copyright.isNotEmpty) {
|
||||
metadata['COPYRIGHT'] = copyright;
|
||||
_log.d('Adding COPYRIGHT to Opus: $copyright');
|
||||
}
|
||||
|
||||
_log.d('Opus Metadata map content: $metadata');
|
||||
|
||||
// Handle lyrics based on lyricsMode setting
|
||||
final lyricsMode = settings.lyricsMode;
|
||||
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
|
||||
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
|
||||
|
||||
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
|
||||
try {
|
||||
final durationMs = track.duration * 1000;
|
||||
|
||||
final lrcContent = await PlatformBridge.getLyricsLRC(
|
||||
track.id,
|
||||
track.name,
|
||||
track.artistName,
|
||||
filePath: '',
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
if (lrcContent.isNotEmpty) {
|
||||
// Embed lyrics in file metadata if mode is 'embed' or 'both'
|
||||
if (shouldEmbed) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
|
||||
}
|
||||
|
||||
// Save external LRC file if mode is 'external' or 'both'
|
||||
if (shouldSaveExternal) {
|
||||
try {
|
||||
final lrcPath = opusPath.replaceAll(RegExp(r'\.opus$', caseSensitive: false), '.lrc');
|
||||
await File(lrcPath).writeAsString(lrcContent);
|
||||
_log.d('External LRC file saved: $lrcPath');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save external LRC file: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to fetch lyrics for Opus: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_log.d('Embedding tags to Opus: $metadata');
|
||||
|
||||
final result = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: opusPath,
|
||||
coverPath: coverPath != null && await File(coverPath).exists()
|
||||
? coverPath
|
||||
: null,
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
_log.d('Metadata, lyrics, and cover embedded to Opus via FFmpeg');
|
||||
} else {
|
||||
_log.w('FFmpeg Opus metadata/cover embed failed');
|
||||
}
|
||||
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
final coverFile = File(coverPath);
|
||||
if (await coverFile.exists()) {
|
||||
await coverFile.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to cleanup Opus cover file: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to embed metadata to Opus: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processQueue() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
@@ -1405,11 +1626,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (state.outputDir.isEmpty) {
|
||||
if (state.outputDir.isEmpty) {
|
||||
_log.d('Output dir empty, initializing...');
|
||||
await _initOutputDir();
|
||||
}
|
||||
|
||||
// iOS: Validate that outputDir is writable (not iCloud Drive which Go can't access)
|
||||
if (Platform.isIOS && state.outputDir.isNotEmpty) {
|
||||
final isICloudPath = state.outputDir.contains('Mobile Documents') ||
|
||||
state.outputDir.contains('CloudDocs') ||
|
||||
state.outputDir.contains('com~apple~CloudDocs');
|
||||
if (isICloudPath) {
|
||||
_log.w('iOS: iCloud Drive path detected, falling back to app Documents folder');
|
||||
_log.w('Go backend cannot write to iCloud Drive due to iOS sandboxing');
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
}
|
||||
state = state.copyWith(outputDir: musicDir.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.outputDir.isEmpty) {
|
||||
_log.d('Using fallback directory...');
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
@@ -1450,7 +1688,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_downloadCount = 0;
|
||||
}
|
||||
|
||||
_log.i(
|
||||
_log.i(
|
||||
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
||||
);
|
||||
if (_totalQueuedAtStart > 0) {
|
||||
@@ -1458,6 +1696,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
completedCount: _completedInSession,
|
||||
failedCount: _failedInSession,
|
||||
);
|
||||
|
||||
// Auto-export failed downloads if enabled
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
|
||||
final exportPath = await exportFailedDownloads();
|
||||
if (exportPath != null) {
|
||||
_log.i('Auto-exported failed downloads to: $exportPath');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Queue processing finished');
|
||||
@@ -1668,7 +1915,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
final quality = item.qualityOverride ?? state.audioQuality;
|
||||
|
||||
// Fetch extended metadata (genre, label) from Deezer if available
|
||||
String? genre;
|
||||
String? label;
|
||||
|
||||
@@ -1720,7 +1966,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||
);
|
||||
_log.d('Output dir: $outputDir');
|
||||
result = await PlatformBridge.downloadWithExtensions(
|
||||
result = await PlatformBridge.downloadWithExtensions(
|
||||
isrc: trackToDownload.isrc ?? '',
|
||||
spotifyId: trackToDownload.id,
|
||||
trackName: trackToDownload.name,
|
||||
@@ -1740,6 +1986,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
genre: genre,
|
||||
label: label,
|
||||
lyricsMode: settings.lyricsMode,
|
||||
preferredService: item.service,
|
||||
);
|
||||
} else if (state.autoFallback) {
|
||||
_log.d('Using auto-fallback mode');
|
||||
@@ -1839,9 +2086,77 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||
_log.d(
|
||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||
);
|
||||
// For HIGH quality (Tidal AAC 320kbps), convert to MP3 or Opus
|
||||
if (quality == 'HIGH') {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
_log.i('Tidal HIGH quality download, converting M4A to $tidalHighFormat...');
|
||||
|
||||
try {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
|
||||
// Convert M4A to the selected format
|
||||
final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3';
|
||||
final convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
filePath,
|
||||
format: format,
|
||||
bitrate: tidalHighFormat,
|
||||
deleteOriginal: true,
|
||||
);
|
||||
|
||||
if (convertedPath != null) {
|
||||
filePath = convertedPath;
|
||||
final bitrateDisplay = tidalHighFormat.contains('_')
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
|
||||
_log.i('Successfully converted M4A to $format: $convertedPath');
|
||||
|
||||
// Embed metadata
|
||||
_log.i('Embedding metadata to $format...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.99,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
if (format == 'mp3') {
|
||||
await _embedMetadataToMp3(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
} else {
|
||||
await _embedMetadataToOpus(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
}
|
||||
_log.d('Metadata embedded successfully');
|
||||
} else {
|
||||
_log.w('M4A to $format conversion failed, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('M4A conversion process failed: $e, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} else {
|
||||
_log.d(
|
||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||
);
|
||||
|
||||
try {
|
||||
final file = File(filePath);
|
||||
@@ -1943,6 +2258,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} catch (e) {
|
||||
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final itemAfterDownload = state.items.firstWhere(
|
||||
@@ -1965,56 +2281,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) {
|
||||
if (wasExisting) {
|
||||
_log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file');
|
||||
} else {
|
||||
_log.i('MP3 quality selected, converting FLAC to MP3...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.97,
|
||||
);
|
||||
|
||||
try {
|
||||
final mp3Path = await FFmpegService.convertFlacToMp3(
|
||||
filePath,
|
||||
bitrate: '320k',
|
||||
deleteOriginal: true,
|
||||
);
|
||||
|
||||
if (mp3Path != null) {
|
||||
filePath = mp3Path;
|
||||
actualQuality = 'MP3 320kbps';
|
||||
_log.i('Successfully converted to MP3: $mp3Path');
|
||||
|
||||
_log.i('Embedding metadata to MP3...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.99,
|
||||
);
|
||||
|
||||
final mp3BackendGenre = result['genre'] as String?;
|
||||
final mp3BackendLabel = result['label'] as String?;
|
||||
final mp3BackendCopyright = result['copyright'] as String?;
|
||||
|
||||
await _embedMetadataToMp3(
|
||||
mp3Path,
|
||||
trackToDownload,
|
||||
genre: mp3BackendGenre ?? genre,
|
||||
label: mp3BackendLabel ?? label,
|
||||
copyright: mp3BackendCopyright,
|
||||
);
|
||||
} else {
|
||||
_log.w('MP3 conversion failed, keeping FLAC file');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('MP3 conversion error: $e, keeping FLAC file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.completed,
|
||||
|
||||
@@ -146,6 +146,26 @@ class Extension {
|
||||
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
|
||||
}
|
||||
|
||||
class SearchFilter {
|
||||
final String id;
|
||||
final String? label;
|
||||
final String? icon;
|
||||
|
||||
const SearchFilter({
|
||||
required this.id,
|
||||
this.label,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
factory SearchFilter.fromJson(Map<String, dynamic> json) {
|
||||
return SearchFilter(
|
||||
id: json['id'] as String? ?? '',
|
||||
label: json['label'] as String?,
|
||||
icon: json['icon'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchBehavior {
|
||||
final bool enabled;
|
||||
final String? placeholder;
|
||||
@@ -154,6 +174,7 @@ class SearchBehavior {
|
||||
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final int? thumbnailWidth;
|
||||
final int? thumbnailHeight;
|
||||
final List<SearchFilter> filters; // Available search filters (e.g., track, album, artist, playlist)
|
||||
|
||||
const SearchBehavior({
|
||||
required this.enabled,
|
||||
@@ -163,6 +184,7 @@ class SearchBehavior {
|
||||
this.thumbnailRatio,
|
||||
this.thumbnailWidth,
|
||||
this.thumbnailHeight,
|
||||
this.filters = const [],
|
||||
});
|
||||
|
||||
factory SearchBehavior.fromJson(Map<String, dynamic> json) {
|
||||
@@ -174,6 +196,9 @@ class SearchBehavior {
|
||||
thumbnailRatio: json['thumbnailRatio'] as String?,
|
||||
thumbnailWidth: json['thumbnailWidth'] as int?,
|
||||
thumbnailHeight: json['thumbnailHeight'] as int?,
|
||||
filters: (json['filters'] as List<dynamic>?)
|
||||
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,8 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
items = decoded
|
||||
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
// Ignore JSON parse errors, use empty list
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,12 +231,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEnableMp3Option(bool enabled) {
|
||||
state = state.copyWith(enableMp3Option: enabled);
|
||||
// If MP3 is disabled and current quality is MP3, reset to LOSSLESS
|
||||
if (!enabled && state.audioQuality == 'MP3') {
|
||||
state = state.copyWith(audioQuality: 'LOSSLESS');
|
||||
}
|
||||
void setTidalHighFormat(String format) {
|
||||
state = state.copyWith(tidalHighFormat: format);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(useAllFilesAccess: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoExportFailedDownloads(bool enabled) {
|
||||
state = state.copyWith(autoExportFailedDownloads: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,12 @@ class TrackState {
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final List<SearchAlbum>? searchAlbums; // For search results (albums)
|
||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||
final bool hasSearchText; // For back button handling
|
||||
final bool isShowingRecentAccess; // For recent access mode
|
||||
final String? searchExtensionId; // Extension ID used for current search results
|
||||
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -41,12 +44,15 @@ class TrackState {
|
||||
this.artistAlbums,
|
||||
this.artistTopTracks,
|
||||
this.searchArtists,
|
||||
this.searchAlbums,
|
||||
this.searchPlaylists,
|
||||
this.hasSearchText = false,
|
||||
this.isShowingRecentAccess = false,
|
||||
this.searchExtensionId,
|
||||
this.selectedSearchFilter,
|
||||
});
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||
|
||||
TrackState copyWith({
|
||||
List<Track>? tracks,
|
||||
@@ -63,9 +69,13 @@ class TrackState {
|
||||
List<ArtistAlbum>? artistAlbums,
|
||||
List<Track>? artistTopTracks,
|
||||
List<SearchArtist>? searchArtists,
|
||||
List<SearchAlbum>? searchAlbums,
|
||||
List<SearchPlaylist>? searchPlaylists,
|
||||
bool? hasSearchText,
|
||||
bool? isShowingRecentAccess,
|
||||
String? searchExtensionId,
|
||||
String? selectedSearchFilter,
|
||||
bool clearSelectedSearchFilter = false,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
@@ -82,9 +92,12 @@ class TrackState {
|
||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
|
||||
searchArtists: searchArtists ?? this.searchArtists,
|
||||
searchAlbums: searchAlbums ?? this.searchAlbums,
|
||||
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||
searchExtensionId: searchExtensionId,
|
||||
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +140,42 @@ class SearchArtist {
|
||||
});
|
||||
}
|
||||
|
||||
class SearchAlbum {
|
||||
final String id;
|
||||
final String name;
|
||||
final String artists;
|
||||
final String? imageUrl;
|
||||
final String? releaseDate;
|
||||
final int totalTracks;
|
||||
final String albumType;
|
||||
|
||||
const SearchAlbum({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.artists,
|
||||
this.imageUrl,
|
||||
this.releaseDate,
|
||||
required this.totalTracks,
|
||||
required this.albumType,
|
||||
});
|
||||
}
|
||||
|
||||
class SearchPlaylist {
|
||||
final String id;
|
||||
final String name;
|
||||
final String owner;
|
||||
final String? imageUrl;
|
||||
final int totalTracks;
|
||||
|
||||
const SearchPlaylist({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.owner,
|
||||
this.imageUrl,
|
||||
required this.totalTracks,
|
||||
});
|
||||
}
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
int _currentRequestId = 0;
|
||||
|
||||
@@ -268,10 +317,13 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query, {String? metadataSource}) async {
|
||||
Future<void> search(String query, {String? metadataSource, String? filterOverride}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve selected filter during loading
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
||||
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -289,7 +341,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
_log.i(
|
||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions',
|
||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
||||
);
|
||||
|
||||
Map<String, dynamic> results;
|
||||
@@ -315,11 +367,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
if (source == 'deezer') {
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter);
|
||||
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
|
||||
} else {
|
||||
_log.d('Calling Spotify search API...');
|
||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2);
|
||||
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||
}
|
||||
|
||||
@@ -330,8 +382,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||
|
||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
|
||||
|
||||
final tracks = <Track>[];
|
||||
|
||||
@@ -373,25 +426,61 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists parsed successfully');
|
||||
final albums = <SearchAlbum>[];
|
||||
for (int i = 0; i < albumList.length; i++) {
|
||||
final a = albumList[i];
|
||||
try {
|
||||
if (a is Map<String, dynamic>) {
|
||||
albums.add(_parseSearchAlbum(a));
|
||||
} else {
|
||||
_log.w('Album[$i] is not a Map: ${a.runtimeType}');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to parse album[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
||||
final playlists = <SearchPlaylist>[];
|
||||
for (int i = 0; i < playlistList.length; i++) {
|
||||
final p = playlistList[i];
|
||||
try {
|
||||
if (p is Map<String, dynamic>) {
|
||||
playlists.add(_parseSearchPlaylist(p));
|
||||
} else {
|
||||
_log.w('Playlist[$i] is not a Map: ${p.runtimeType}');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to parse playlist[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully');
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: artists,
|
||||
searchAlbums: albums,
|
||||
searchPlaylists: playlists,
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading
|
||||
);
|
||||
|
||||
try {
|
||||
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
||||
@@ -423,6 +512,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -474,6 +564,15 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = const TrackState();
|
||||
}
|
||||
|
||||
/// Set selected search filter for extension search
|
||||
void setSearchFilter(String? filter) {
|
||||
if (state.selectedSearchFilter == filter) return;
|
||||
state = state.copyWith(
|
||||
selectedSearchFilter: filter,
|
||||
clearSelectedSearchFilter: filter == null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set search text state for back button handling
|
||||
void setSearchText(bool hasText) {
|
||||
if (state.hasSearchText == hasText) {
|
||||
@@ -571,6 +670,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
SearchAlbum _parseSearchAlbum(Map<String, dynamic> data) {
|
||||
return SearchAlbum(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
);
|
||||
}
|
||||
|
||||
SearchPlaylist _parseSearchPlaylist(Map<String, dynamic> data) {
|
||||
return SearchPlaylist(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
owner: data['owner'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||
if (tracksWithIsrc.isEmpty) return;
|
||||
|
||||
@@ -80,7 +80,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
|
||||
// Use extensionId if available, otherwise detect from albumId prefix
|
||||
final providerId = widget.extensionId ??
|
||||
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||
id: widget.albumId,
|
||||
name: widget.albumName,
|
||||
@@ -90,10 +92,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
});
|
||||
|
||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||
// Use provided tracks if not empty, otherwise try cache
|
||||
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
||||
_tracks = widget.tracks;
|
||||
} else {
|
||||
_tracks = _AlbumCache.get(widget.albumId);
|
||||
}
|
||||
_artistId = widget.artistId; // Use provided artist ID if available
|
||||
|
||||
if (_tracks == null) {
|
||||
if (_tracks == null || _tracks!.isEmpty) {
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
|
||||
+604
-87
@@ -204,12 +204,14 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
// Use filterOverride if provided, otherwise read from state
|
||||
final selectedFilter = filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
|
||||
|
||||
final searchKey = '${searchProvider ?? 'default'}:$query';
|
||||
final searchKey = '${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
|
||||
@@ -218,12 +220,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
|
||||
// Build options with filter if selected
|
||||
Map<String, dynamic>? options;
|
||||
if (selectedFilter != null) {
|
||||
options = {'filter': selectedFilter};
|
||||
}
|
||||
await ref.read(trackProvider.notifier).customSearch(searchProvider, query, options: options);
|
||||
} else {
|
||||
if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource, filterOverride: selectedFilter);
|
||||
}
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
@@ -460,6 +467,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
|
||||
final searchAlbums = ref.watch(trackProvider.select((s) => s.searchAlbums));
|
||||
final searchPlaylists = ref.watch(trackProvider.select((s) => s.searchPlaylists));
|
||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||
@@ -475,7 +484,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty);
|
||||
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || (searchAlbums != null && searchAlbums.isNotEmpty) || (searchPlaylists != null && searchPlaylists.isNotEmpty);
|
||||
final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
|
||||
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
@@ -495,6 +504,34 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final hasExploreContent = exploreSections.isNotEmpty;
|
||||
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && hasExploreContent;
|
||||
|
||||
// Get current search extension and its filters
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final currentSearchProvider = settings.searchProvider;
|
||||
final selectedSearchFilter = ref.watch(trackProvider.select((s) => s.selectedSearchFilter));
|
||||
Extension? currentSearchExtension;
|
||||
List<SearchFilter> searchFilters = [];
|
||||
|
||||
// Check if using extension search provider
|
||||
final isUsingExtensionSearch = currentSearchProvider != null &&
|
||||
currentSearchProvider.isNotEmpty &&
|
||||
extState.extensions.any((e) => e.id == currentSearchProvider && e.enabled);
|
||||
|
||||
if (isUsingExtensionSearch) {
|
||||
currentSearchExtension = extState.extensions.where((e) => e.id == currentSearchProvider && e.enabled).firstOrNull;
|
||||
if (currentSearchExtension?.searchBehavior?.filters.isNotEmpty == true) {
|
||||
searchFilters = currentSearchExtension!.searchBehavior!.filters;
|
||||
}
|
||||
} else {
|
||||
// Default Deezer filters
|
||||
searchFilters = const [
|
||||
SearchFilter(id: 'track', label: 'Tracks', icon: 'music'),
|
||||
SearchFilter(id: 'artist', label: 'Artists', icon: 'artist'),
|
||||
SearchFilter(id: 'album', label: 'Albums', icon: 'album'),
|
||||
SearchFilter(id: 'playlist', label: 'Playlists', icon: 'playlist'),
|
||||
];
|
||||
}
|
||||
|
||||
if (hasActualResults && isShowingRecentAccess) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
@@ -603,6 +640,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
),
|
||||
|
||||
// Search filter bar (only shown when has search results or loading search)
|
||||
if (searchFilters.isNotEmpty && (hasActualResults || isLoading))
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSearchFilterBar(
|
||||
searchFilters,
|
||||
selectedSearchFilter,
|
||||
colorScheme,
|
||||
),
|
||||
),
|
||||
|
||||
if (showRecentAccess)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildRecentAccess(recentAccessView!, colorScheme),
|
||||
@@ -651,6 +698,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
..._buildSearchResults(
|
||||
tracks: tracks,
|
||||
searchArtists: searchArtists,
|
||||
searchAlbums: searchAlbums,
|
||||
searchPlaylists: searchPlaylists,
|
||||
isLoading: isLoading,
|
||||
error: error,
|
||||
colorScheme: colorScheme,
|
||||
@@ -1529,6 +1578,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
List<Widget> _buildSearchResults({
|
||||
required List<Track> tracks,
|
||||
required List<SearchArtist>? searchArtists,
|
||||
required List<SearchAlbum>? searchAlbums,
|
||||
required List<SearchPlaylist>? searchPlaylists,
|
||||
required bool isLoading,
|
||||
required String? error,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -1571,9 +1622,42 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
if (isLoading)
|
||||
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
||||
|
||||
// Artists from default search (Deezer/Spotify) - now in vertical list style
|
||||
if (searchArtists != null && searchArtists.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)),
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(context.l10n.searchArtists, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
)),
|
||||
if (searchArtists != null && searchArtists.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < searchArtists.length; i++)
|
||||
_SearchArtistItemWidget(
|
||||
key: ValueKey('search-artist-${searchArtists[i].id}'),
|
||||
artist: searchArtists[i],
|
||||
showDivider: i < searchArtists.length - 1,
|
||||
onTap: () => _navigateToArtist(searchArtists[i].id, searchArtists[i].name, searchArtists[i].imageUrl),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Artists from extension search
|
||||
if (artistItems.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
@@ -1608,6 +1692,42 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
),
|
||||
|
||||
// Albums from default search (Deezer/Spotify)
|
||||
if (searchAlbums != null && searchAlbums.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(context.l10n.searchAlbums, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
)),
|
||||
if (searchAlbums != null && searchAlbums.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < searchAlbums.length; i++)
|
||||
_SearchAlbumItemWidget(
|
||||
key: ValueKey('search-album-${searchAlbums[i].id}'),
|
||||
album: searchAlbums[i],
|
||||
showDivider: i < searchAlbums.length - 1,
|
||||
onTap: () => _navigateToSearchAlbum(searchAlbums[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Albums from extension search
|
||||
if (albumItems.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
@@ -1642,6 +1762,42 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
),
|
||||
|
||||
// Playlists from default search (Deezer/Spotify)
|
||||
if (searchPlaylists != null && searchPlaylists.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(context.l10n.searchPlaylists, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
)),
|
||||
if (searchPlaylists != null && searchPlaylists.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < searchPlaylists.length; i++)
|
||||
_SearchPlaylistItemWidget(
|
||||
key: ValueKey('search-playlist-${searchPlaylists[i].id}'),
|
||||
playlist: searchPlaylists[i],
|
||||
showDivider: i < searchPlaylists.length - 1,
|
||||
onTap: () => _navigateToSearchPlaylist(searchPlaylists[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Playlists from extension search
|
||||
if (playlistItems.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
@@ -1716,83 +1872,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildArtistSearchResults(List<SearchArtist> artists, ColorScheme colorScheme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(context.l10n.searchArtists, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: artists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final artist = artists[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(artist.id),
|
||||
child: _buildArtistCard(artist, colorScheme),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
||||
final hasValidImage = artist.imageUrl != null &&
|
||||
artist.imageUrl!.isNotEmpty &&
|
||||
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
||||
child: Container(
|
||||
width: 110,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: artist.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 200,
|
||||
memCacheHeight: 200,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) => Icon(
|
||||
Icons.person,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 44,
|
||||
),
|
||||
)
|
||||
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
artist.name,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToArtist(String artistId, String artistName, String? imageUrl) {
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
|
||||
@@ -1805,6 +1884,50 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
}
|
||||
|
||||
void _navigateToSearchAlbum(SearchAlbum album) {
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
|
||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artistName: album.artists,
|
||||
imageUrl: album.imageUrl,
|
||||
providerId: 'deezer',
|
||||
);
|
||||
|
||||
// Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => AlbumScreen(
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.imageUrl,
|
||||
tracks: const [], // Will be fetched by AlbumScreen
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
void _navigateToSearchPlaylist(SearchPlaylist playlist) {
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
|
||||
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
ownerName: playlist.owner,
|
||||
imageUrl: playlist.imageUrl,
|
||||
providerId: 'deezer',
|
||||
);
|
||||
|
||||
// Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => PlaylistScreen(
|
||||
playlistName: playlist.name,
|
||||
coverUrl: playlist.imageUrl,
|
||||
tracks: const [], // Will be fetched
|
||||
playlistId: playlist.id,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
void _navigateToExtensionAlbum(Track albumItem) async {
|
||||
final extensionId = albumItem.source;
|
||||
if (extensionId == null || extensionId.isEmpty) {
|
||||
@@ -1912,6 +2035,106 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
return 'Paste Spotify URL or search...';
|
||||
}
|
||||
|
||||
Widget _buildSearchFilterBar(
|
||||
List<SearchFilter> filters,
|
||||
String? selectedFilter,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
// "All" chip (no filter)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilterChip(
|
||||
label: const Text('All'),
|
||||
selected: selectedFilter == null,
|
||||
onSelected: (_) {
|
||||
ref.read(trackProvider.notifier).setSearchFilter(null);
|
||||
_triggerSearchWithFilter(null);
|
||||
},
|
||||
showCheckmark: false,
|
||||
selectedColor: colorScheme.primaryContainer,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
labelStyle: TextStyle(
|
||||
color: selectedFilter == null
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: selectedFilter == null ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Filter chips from extension
|
||||
...filters.map((filter) {
|
||||
final isSelected = selectedFilter == filter.id;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilterChip(
|
||||
label: Text(filter.label ?? filter.id),
|
||||
selected: isSelected,
|
||||
onSelected: (_) {
|
||||
ref.read(trackProvider.notifier).setSearchFilter(filter.id);
|
||||
_triggerSearchWithFilter(filter.id);
|
||||
},
|
||||
showCheckmark: false,
|
||||
selectedColor: colorScheme.primaryContainer,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
avatar: filter.icon != null ? Icon(
|
||||
_getFilterIcon(filter.icon!),
|
||||
size: 18,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
) : null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getFilterIcon(String iconName) {
|
||||
switch (iconName.toLowerCase()) {
|
||||
case 'music':
|
||||
case 'track':
|
||||
case 'song':
|
||||
return Icons.music_note;
|
||||
case 'album':
|
||||
return Icons.album;
|
||||
case 'artist':
|
||||
return Icons.person;
|
||||
case 'playlist':
|
||||
return Icons.playlist_play;
|
||||
case 'video':
|
||||
return Icons.video_library;
|
||||
case 'podcast':
|
||||
return Icons.podcasts;
|
||||
default:
|
||||
return Icons.search;
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerSearchWithFilter(String? filter) {
|
||||
final text = _urlController.text.trim();
|
||||
if (text.isEmpty || text.length < _minLiveSearchChars) return;
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
||||
|
||||
// Reset last search query to force new search
|
||||
_lastSearchQuery = null;
|
||||
_performSearch(text, filterOverride: filter);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||
final hasText = _urlController.text.isNotEmpty;
|
||||
|
||||
@@ -1938,6 +2161,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
prefixIcon: _SearchProviderDropdown(
|
||||
onProviderChanged: () {
|
||||
_lastSearchQuery = null;
|
||||
// Reset filter when provider changes
|
||||
ref.read(trackProvider.notifier).setSearchFilter(null);
|
||||
setState(() {});
|
||||
final text = _urlController.text.trim();
|
||||
if (text.isNotEmpty && text.length >= _minLiveSearchChars) {
|
||||
@@ -2195,12 +2420,16 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
double thumbWidth = 56;
|
||||
double thumbHeight = 56;
|
||||
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final extensionId = track.source ?? trackState.searchExtensionId;
|
||||
final searchExtensionId =
|
||||
ref.watch(trackProvider.select((s) => s.searchExtensionId));
|
||||
final extensionId = track.source ?? searchExtensionId;
|
||||
|
||||
if (extensionId != null && extensionId.isNotEmpty) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final extension = extState.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
final extension = ref.watch(
|
||||
extensionProvider.select(
|
||||
(s) => s.extensions.where((e) => e.id == extensionId).firstOrNull,
|
||||
),
|
||||
);
|
||||
if (extension?.searchBehavior != null) {
|
||||
final size = extension!.searchBehavior!.getThumbnailSize(defaultSize: 56);
|
||||
thumbWidth = size.$1;
|
||||
@@ -2477,6 +2706,294 @@ class _CollectionItemWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget for displaying artist items from default search (Deezer/Spotify)
|
||||
class _SearchArtistItemWidget extends StatelessWidget {
|
||||
final SearchArtist artist;
|
||||
final bool showDivider;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SearchArtistItemWidget({
|
||||
super.key,
|
||||
required this.artist,
|
||||
required this.showDivider,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasValidImage = artist.imageUrl != null &&
|
||||
artist.imageUrl!.isNotEmpty &&
|
||||
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: artist.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
artist.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Artist',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 80,
|
||||
endIndent: 12,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget for displaying album items from default search (Deezer/Spotify)
|
||||
class _SearchAlbumItemWidget extends StatelessWidget {
|
||||
final SearchAlbum album;
|
||||
final bool showDivider;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SearchAlbumItemWidget({
|
||||
super.key,
|
||||
required this.album,
|
||||
required this.showDivider,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasValidImage = album.imageUrl != null &&
|
||||
album.imageUrl!.isNotEmpty &&
|
||||
Uri.tryParse(album.imageUrl!)?.hasAuthority == true;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: album.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
album.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
album.artists.isNotEmpty ? album.artists : 'Album',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 80,
|
||||
endIndent: 12,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget for displaying playlist items from default search (Deezer/Spotify)
|
||||
class _SearchPlaylistItemWidget extends StatelessWidget {
|
||||
final SearchPlaylist playlist;
|
||||
final bool showDivider;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SearchPlaylistItemWidget({
|
||||
super.key,
|
||||
required this.playlist,
|
||||
required this.showDivider,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasValidImage = playlist.imageUrl != null &&
|
||||
playlist.imageUrl!.isNotEmpty &&
|
||||
Uri.tryParse(playlist.imageUrl!)?.hasAuthority == true;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: playlist.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.playlist_play,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
playlist.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
playlist.owner.isNotEmpty ? playlist.owner : 'Playlist',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 80,
|
||||
endIndent: 12,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String albumId;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -15,12 +16,14 @@ class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
final List<Track> tracks;
|
||||
final String? playlistId; // Deezer playlist ID for fetching tracks
|
||||
|
||||
const PlaylistScreen({
|
||||
super.key,
|
||||
required this.playlistName,
|
||||
this.coverUrl,
|
||||
required this.tracks,
|
||||
this.playlistId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -31,12 +34,18 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
List<Track>? _fetchedTracks;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
List<Track> get _tracks => _fetchedTracks ?? widget.tracks;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_extractDominantColor();
|
||||
_fetchTracksIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,6 +55,65 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracksIfNeeded() async {
|
||||
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Extract numeric ID from "deezer:123" format
|
||||
String playlistId = widget.playlistId!;
|
||||
if (playlistId.startsWith('deezer:')) {
|
||||
playlistId = playlistId.substring(7);
|
||||
}
|
||||
|
||||
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
|
||||
if (!mounted) return;
|
||||
|
||||
// Go backend returns 'track_list' not 'tracks'
|
||||
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
setState(() {
|
||||
_fetchedTracks = tracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue;
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
@@ -211,15 +279,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
children: [
|
||||
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text(context.l10n.tracksCount(_tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
onPressed: _tracks.isEmpty ? null : () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
|
||||
label: Text(context.l10n.downloadAllCount(_tracks.length)),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
@@ -249,10 +317,54 @@ const SizedBox(height: 16),
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
|
||||
if (_isLoading) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
color: colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(_error!, style: TextStyle(color: colorScheme.error))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_tracks.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.errorNoTracksFound,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = widget.tracks[index];
|
||||
final track = _tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _PlaylistTrackItem(
|
||||
@@ -261,7 +373,7 @@ const SizedBox(height: 16),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: widget.tracks.length,
|
||||
childCount: _tracks.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -286,21 +398,21 @@ const SizedBox(height: 16),
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context) {
|
||||
if (widget.tracks.isEmpty) return;
|
||||
if (_tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${widget.tracks.length} tracks',
|
||||
trackName: '${_tracks.length} tracks',
|
||||
artistName: widget.playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+124
-4
@@ -366,6 +366,21 @@ final albumKey =
|
||||
});
|
||||
}
|
||||
|
||||
/// Get short badge text for quality display
|
||||
String _getQualityBadgeText(String quality) {
|
||||
// For lossless: "24-bit/96kHz" -> "24-bit"
|
||||
if (quality.contains('bit')) {
|
||||
return quality.split('/').first;
|
||||
}
|
||||
// For lossy: "OPUS 128kbps" -> "128k", "MP3 320kbps" -> "320k"
|
||||
final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality);
|
||||
if (bitrateMatch != null) {
|
||||
return '${bitrateMatch.group(1)}k';
|
||||
}
|
||||
// Fallback: return format name
|
||||
return quality.split(' ').first;
|
||||
}
|
||||
|
||||
Future<void> _deleteSelected() async {
|
||||
final count = _selectedIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -791,8 +806,12 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const Spacer(),
|
||||
_buildExportFailedButton(context, ref, colorScheme),
|
||||
const SizedBox(width: 4),
|
||||
_buildPauseResumeButton(context, ref, colorScheme),
|
||||
const SizedBox(width: 4),
|
||||
_buildClearAllButton(context, ref, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1177,6 +1196,107 @@ if (queueItems.isEmpty &&
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClearAllButton(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return TextButton.icon(
|
||||
onPressed: () => _showClearAllDialog(context, ref, colorScheme),
|
||||
icon: const Icon(Icons.clear_all, size: 18),
|
||||
label: Text(context.l10n.queueClearAll),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExportFailedButton(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
final failedCount = queueState.failedCount;
|
||||
|
||||
if (failedCount == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return TextButton.icon(
|
||||
onPressed: () => _exportFailedDownloads(context, ref),
|
||||
icon: const Icon(Icons.file_download, size: 18),
|
||||
label: Text(context.l10n.queueExportFailed),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: colorScheme.tertiary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportFailedDownloads(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
final filePath = await ref.read(downloadQueueProvider.notifier).exportFailedDownloads();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (filePath != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.queueExportFailedSuccess),
|
||||
action: SnackBarAction(
|
||||
label: context.l10n.queueExportFailedClear,
|
||||
onPressed: () {
|
||||
ref.read(downloadQueueProvider.notifier).clearFailedDownloads();
|
||||
},
|
||||
),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.queueExportFailedError),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showClearAllDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.queueClearAll),
|
||||
content: Text(context.l10n.queueClearAllMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
child: Text(context.l10n.dialogClear),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1723,7 +1843,7 @@ child: CachedNetworkImage(
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.quality != null && item.quality!.contains('bit'))
|
||||
if (item.quality != null && item.quality!.isNotEmpty)
|
||||
Positioned(
|
||||
left: 4,
|
||||
top: 4,
|
||||
@@ -1739,7 +1859,7 @@ child: CachedNetworkImage(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.quality!.split('/').first,
|
||||
_getQualityBadgeText(item.quality!),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: item.quality!.startsWith('24')
|
||||
@@ -1974,7 +2094,7 @@ child: CachedNetworkImage(
|
||||
),
|
||||
),
|
||||
if (item.quality != null &&
|
||||
item.quality!.contains('bit')) ...[
|
||||
item.quality!.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
||||
@@ -112,11 +112,10 @@ class AboutPage extends StatelessWidget {
|
||||
githubUsername: 'sachinsenal0x64',
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: context.l10n.aboutDoubleDouble,
|
||||
subtitle: context.l10n.aboutDoubleDoubleDesc,
|
||||
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||
_ContributorItem(
|
||||
name: 'sjdonado',
|
||||
description: context.l10n.aboutSjdonadoDesc,
|
||||
githubUsername: 'sjdonado',
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
@@ -185,7 +184,7 @@ _AboutSettingsItem(
|
||||
icon: Icons.forum_outlined,
|
||||
title: context.l10n.aboutTelegramChat,
|
||||
subtitle: context.l10n.aboutTelegramChatSubtitle,
|
||||
onTap: () => _launchUrl('https://t.me/spotiflacchat'),
|
||||
onTap: () => _launchUrl('https://t.me/spotiflac_chat'),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -467,11 +466,29 @@ class _TranslatorsSection extends StatelessWidget {
|
||||
flag: '🇷🇺',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Max',
|
||||
name: 'Amonoman',
|
||||
crowdinUsername: 'amonoman',
|
||||
language: 'German',
|
||||
flag: '🇩🇪',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Re*Index.(ot_inc)',
|
||||
crowdinUsername: 'ot_inc',
|
||||
language: 'Japanese',
|
||||
flag: '🇯🇵',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Kaan',
|
||||
crowdinUsername: 'glai',
|
||||
language: 'Turkish',
|
||||
flag: '🇹🇷',
|
||||
),
|
||||
_Translator(
|
||||
name: 'BedirhanGltkn',
|
||||
crowdinUsername: 'bedirhangltkn',
|
||||
language: 'Turkish',
|
||||
flag: '🇹🇷',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -3,23 +3,98 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerWidget {
|
||||
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
const DownloadSettingsPage({super.key});
|
||||
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<DownloadSettingsPage> createState() => _DownloadSettingsPageState();
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasAllFilesAccess = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initDeviceInfo();
|
||||
}
|
||||
|
||||
Future<void> _initDeviceInfo() async {
|
||||
if (Platform.isAndroid) {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
final sdkVersion = androidInfo.version.sdkInt;
|
||||
final hasAccess = await Permission.manageExternalStorage.isGranted;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_androidSdkVersion = sdkVersion;
|
||||
_hasAllFilesAccess = hasAccess;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestAllFilesAccess() async {
|
||||
final status = await Permission.manageExternalStorage.request();
|
||||
if (status.isGranted) {
|
||||
ref.read(settingsProvider.notifier).setUseAllFilesAccess(true);
|
||||
if (mounted) {
|
||||
setState(() => _hasAllFilesAccess = true);
|
||||
}
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
if (mounted) {
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.setupStorageAccessRequired),
|
||||
content: Text(context.l10n.allFilesAccessDeniedMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.setupOpenSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (shouldOpen == true) {
|
||||
await openAppSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disableAllFilesAccess() async {
|
||||
ref.read(settingsProvider.notifier).setUseAllFilesAccess(false);
|
||||
// Note: We can't revoke the permission programmatically,
|
||||
// but we can stop using it in the app
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.allFilesAccessDisabledMessage)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
final isTidalService = settings.defaultService == 'tidal';
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
@@ -99,17 +174,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
.read(settingsProvider.notifier)
|
||||
.setAskQualityBeforeDownload(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.audiotrack,
|
||||
title: context.l10n.enableMp3Option,
|
||||
subtitle: settings.enableMp3Option
|
||||
? context.l10n.enableMp3OptionSubtitleOn
|
||||
: context.l10n.enableMp3OptionSubtitleOff,
|
||||
value: settings.enableMp3Option,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEnableMp3Option(value),
|
||||
),
|
||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||
_QualityOption(
|
||||
title: context.l10n.qualityFlacLossless,
|
||||
@@ -134,16 +198,25 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('HI_RES_LOSSLESS'),
|
||||
showDivider: settings.enableMp3Option,
|
||||
showDivider: isTidalService,
|
||||
),
|
||||
if (settings.enableMp3Option)
|
||||
// Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
|
||||
if (isTidalService)
|
||||
_QualityOption(
|
||||
title: context.l10n.qualityMp3,
|
||||
subtitle: context.l10n.qualityMp3Subtitle,
|
||||
isSelected: settings.audioQuality == 'MP3',
|
||||
title: 'Lossy 320kbps',
|
||||
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||
isSelected: settings.audioQuality == 'HIGH',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('MP3'),
|
||||
.setAudioQuality('HIGH'),
|
||||
showDivider: false,
|
||||
),
|
||||
if (isTidalService && settings.audioQuality == 'HIGH')
|
||||
SettingsItem(
|
||||
icon: Icons.tune,
|
||||
title: 'Lossy Format',
|
||||
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||
onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -261,6 +334,79 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// All Files Access section (Android 13+ only)
|
||||
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionStorageAccess),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.folder_special_outlined,
|
||||
title: context.l10n.allFilesAccess,
|
||||
subtitle: _hasAllFilesAccess
|
||||
? context.l10n.allFilesAccessEnabledSubtitle
|
||||
: context.l10n.allFilesAccessDisabledSubtitle,
|
||||
value: _hasAllFilesAccess && settings.useAllFilesAccess,
|
||||
onChanged: (value) {
|
||||
if (value) {
|
||||
_requestAllFilesAccess();
|
||||
} else {
|
||||
_disableAllFilesAccess();
|
||||
}
|
||||
},
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.allFilesAccessDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
|
||||
// Auto Export Failed Downloads
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.file_download_outlined,
|
||||
title: context.l10n.settingsAutoExportFailed,
|
||||
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
|
||||
value: settings.autoExportFailedDownloads,
|
||||
onChanged: (value) {
|
||||
ref.read(settingsProvider.notifier).setAutoExportFailedDownloads(value);
|
||||
},
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
@@ -576,7 +722,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
ListTile(
|
||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||
title: Text(context.l10n.setupChooseFromFiles),
|
||||
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||
@@ -585,6 +731,24 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
// Note: iOS requires folder to have at least one file to be selectable
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
// iOS: Check if user selected iCloud Drive (not accessible by Go backend)
|
||||
if (Platform.isIOS) {
|
||||
final isICloudPath = result.contains('Mobile Documents') ||
|
||||
result.contains('CloudDocs') ||
|
||||
result.contains('com~apple~CloudDocs');
|
||||
if (isICloudPath) {
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.setupIcloudNotSupported),
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadDirectory(result);
|
||||
@@ -722,6 +886,79 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _getTidalHighFormatLabel(String format) {
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
return 'MP3 320kbps';
|
||||
case 'opus_128':
|
||||
return 'Opus 128kbps';
|
||||
default:
|
||||
return 'MP3 320kbps';
|
||||
}
|
||||
}
|
||||
|
||||
void _showTidalHighFormatPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Lossy 320kbps Format',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: const Text('MP3 320kbps'),
|
||||
subtitle: const Text('Best compatibility, ~10MB per track'),
|
||||
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: const Text('Opus 128kbps'),
|
||||
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFolderOrganizationPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
@@ -153,14 +153,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseExtensionProviders(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.lyrics,
|
||||
title: context.l10n.optionsEmbedLyrics,
|
||||
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
|
||||
value: settings.embedLyrics,
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.image,
|
||||
title: context.l10n.optionsMaxQualityCover,
|
||||
|
||||
@@ -67,10 +67,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
bool storageGranted = false;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
final manageStatus = await Permission.manageExternalStorage.status;
|
||||
// Android 13+: Only require READ_MEDIA_AUDIO by default
|
||||
// MANAGE_EXTERNAL_STORAGE is optional and can be enabled in settings
|
||||
final audioStatus = await Permission.audio.status;
|
||||
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
|
||||
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||
debugPrint('[Permission] Android 13+ check: READ_MEDIA_AUDIO=$audioStatus');
|
||||
storageGranted = audioStatus.isGranted;
|
||||
} else if (_androidSdkVersion >= 30) {
|
||||
final manageStatus = await Permission.manageExternalStorage.status;
|
||||
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
|
||||
@@ -108,44 +109,20 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
bool allGranted = false;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
var manageStatus = await Permission.manageExternalStorage.status;
|
||||
if (!manageStatus.isGranted) {
|
||||
if (mounted) {
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.setupStorageAccessRequired),
|
||||
content: Text(
|
||||
'${context.l10n.setupStorageAccessMessage}\n\n'
|
||||
'${context.l10n.setupAllowAccessToManageFiles}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.setupOpenSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldOpen == true) {
|
||||
await Permission.manageExternalStorage.request();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
manageStatus = await Permission.manageExternalStorage.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Android 13+: Only request READ_MEDIA_AUDIO by default
|
||||
// MANAGE_EXTERNAL_STORAGE is optional (can be enabled in Settings)
|
||||
var audioStatus = await Permission.audio.status;
|
||||
if (!audioStatus.isGranted && manageStatus.isGranted) {
|
||||
if (!audioStatus.isGranted) {
|
||||
audioStatus = await Permission.audio.request();
|
||||
}
|
||||
|
||||
allGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||
allGranted = audioStatus.isGranted;
|
||||
|
||||
if (audioStatus.isPermanentlyDenied) {
|
||||
_showPermissionDeniedDialog('Audio');
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
} else if (_androidSdkVersion >= 30) {
|
||||
var manageStatus = await Permission.manageExternalStorage.status;
|
||||
|
||||
@@ -517,16 +517,27 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||
// Determine audio quality string based on file type
|
||||
// Determine audio quality string - prefer stored quality from download
|
||||
String? audioQualityStr;
|
||||
final fileName = item.filePath.split('/').last;
|
||||
final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : '';
|
||||
|
||||
if (fileExt == 'MP3') {
|
||||
audioQualityStr = '320kbps';
|
||||
// Use stored quality from download history if available
|
||||
if (item.quality != null && item.quality!.isNotEmpty) {
|
||||
audioQualityStr = item.quality;
|
||||
} else if (bitDepth != null && sampleRate != null) {
|
||||
// Fallback for FLAC files without stored quality
|
||||
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
||||
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
|
||||
} else {
|
||||
// Fallback based on file extension for legacy items
|
||||
if (fileExt == 'MP3') {
|
||||
audioQualityStr = 'MP3';
|
||||
} else if (fileExt == 'OPUS' || fileExt == 'OGG') {
|
||||
audioQualityStr = 'Opus';
|
||||
} else if (fileExt == 'M4A' || fileExt == 'AAC') {
|
||||
audioQualityStr = 'AAC';
|
||||
}
|
||||
}
|
||||
|
||||
final items = <_MetadataItem>[
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:typed_data';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
/// FFmpeg service for audio conversion and remuxing
|
||||
/// Uses native MethodChannel to call FFmpegKit from local AAR
|
||||
class FFmpegService {
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg');
|
||||
|
||||
static Future<FFmpegResult> _execute(String command) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('execute', {'command': command});
|
||||
final map = Map<String, dynamic>.from(result);
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
final output = await session.getOutput() ?? '';
|
||||
|
||||
return FFmpegResult(
|
||||
success: map['success'] as bool,
|
||||
returnCode: map['returnCode'] as int,
|
||||
output: map['output'] as String,
|
||||
success: ReturnCode.isSuccess(returnCode),
|
||||
returnCode: returnCode?.getValue() ?? -1,
|
||||
output: output,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('FFmpeg execute error: $e');
|
||||
@@ -44,6 +46,47 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> convertM4aToLossy(
|
||||
String inputPath, {
|
||||
required String format,
|
||||
String? bitrate,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
String bitrateValue = format == 'opus' ? '128k' : '320k';
|
||||
if (bitrate != null && bitrate.contains('_')) {
|
||||
final parts = bitrate.split('_');
|
||||
if (parts.length == 2) {
|
||||
bitrateValue = '${parts[1]}k';
|
||||
}
|
||||
}
|
||||
|
||||
final extension = format == 'opus' ? '.opus' : '.mp3';
|
||||
final outputPath = inputPath.replaceAll('.m4a', extension);
|
||||
|
||||
String command;
|
||||
if (format == 'opus') {
|
||||
command =
|
||||
'-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
|
||||
} else {
|
||||
command =
|
||||
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
|
||||
}
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
_log.e('M4A to $format conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> convertFlacToMp3(
|
||||
String inputPath, {
|
||||
String bitrate = '320k',
|
||||
@@ -69,6 +112,56 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> convertFlacToOpus(
|
||||
String inputPath, {
|
||||
String bitrate = '128k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = inputPath.replaceAll('.flac', '.opus');
|
||||
|
||||
final command =
|
||||
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
_log.e('FLAC to Opus conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> convertFlacToLossy(
|
||||
String inputPath, {
|
||||
required String format,
|
||||
String? bitrate,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
String bitrateValue = '320k';
|
||||
if (bitrate != null && bitrate.contains('_')) {
|
||||
final parts = bitrate.split('_');
|
||||
if (parts.length == 2) {
|
||||
bitrateValue = '${parts[1]}k';
|
||||
}
|
||||
}
|
||||
|
||||
switch (format.toLowerCase()) {
|
||||
case 'opus':
|
||||
final opusBitrate = bitrate?.startsWith('opus_') == true ? bitrateValue : '128k';
|
||||
return convertFlacToOpus(inputPath, bitrate: opusBitrate, deleteOriginal: deleteOriginal);
|
||||
case 'mp3':
|
||||
default:
|
||||
final mp3Bitrate = bitrate?.startsWith('mp3_') == true ? bitrateValue : '320k';
|
||||
return convertFlacToMp3(inputPath, bitrate: mp3Bitrate, deleteOriginal: deleteOriginal);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> convertFlacToM4a(
|
||||
String inputPath, {
|
||||
String codec = 'aac',
|
||||
@@ -104,8 +197,8 @@ class FFmpegService {
|
||||
|
||||
static Future<bool> isAvailable() async {
|
||||
try {
|
||||
final version = await _channel.invokeMethod('getVersion');
|
||||
return version != null && version.toString().isNotEmpty;
|
||||
final version = await FFmpegKitConfig.getFFmpegVersion();
|
||||
return version?.isNotEmpty ?? false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -113,8 +206,7 @@ class FFmpegService {
|
||||
|
||||
static Future<String?> getVersion() async {
|
||||
try {
|
||||
final version = await _channel.invokeMethod('getVersion');
|
||||
return version as String?;
|
||||
return await FFmpegKitConfig.getFFmpegVersion();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
@@ -280,6 +372,176 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> embedMetadataToOpus({
|
||||
required String opusPath,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$opusPath" ');
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
final pictureBlock = await _createMetadataBlockPicture(coverPath);
|
||||
if (pictureBlock != null) {
|
||||
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
|
||||
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
|
||||
} else {
|
||||
_log.w('Failed to create METADATA_BLOCK_PICTURE, skipping cover');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
|
||||
}
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg Opus embed command');
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
final originalFile = File(opusPath);
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(opusPath);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('Opus metadata embedded successfully');
|
||||
return opusPath;
|
||||
} else {
|
||||
_log.e('Temp Opus output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace Opus file after metadata embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to cleanup temp Opus file: $e');
|
||||
}
|
||||
|
||||
_log.e('Opus Metadata embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
|
||||
try {
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) {
|
||||
_log.e('Cover image not found: $imagePath');
|
||||
return null;
|
||||
}
|
||||
|
||||
final imageData = await file.readAsBytes();
|
||||
|
||||
String mimeType;
|
||||
if (imagePath.toLowerCase().endsWith('.png')) {
|
||||
mimeType = 'image/png';
|
||||
} else if (imagePath.toLowerCase().endsWith('.jpg') ||
|
||||
imagePath.toLowerCase().endsWith('.jpeg')) {
|
||||
mimeType = 'image/jpeg';
|
||||
} else {
|
||||
if (imageData.length >= 8 &&
|
||||
imageData[0] == 0x89 && imageData[1] == 0x50 &&
|
||||
imageData[2] == 0x4E && imageData[3] == 0x47) {
|
||||
mimeType = 'image/png';
|
||||
} else if (imageData.length >= 2 &&
|
||||
imageData[0] == 0xFF && imageData[1] == 0xD8) {
|
||||
mimeType = 'image/jpeg';
|
||||
} else {
|
||||
mimeType = 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
final mimeBytes = utf8.encode(mimeType);
|
||||
const description = '';
|
||||
final descBytes = utf8.encode(description);
|
||||
|
||||
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
|
||||
4 + 4 + 4 + 4 + 4 + imageData.length;
|
||||
|
||||
final buffer = ByteData(blockSize);
|
||||
var offset = 0;
|
||||
|
||||
buffer.setUint32(offset, 3, Endian.big);
|
||||
offset += 4;
|
||||
|
||||
buffer.setUint32(offset, mimeBytes.length, Endian.big);
|
||||
offset += 4;
|
||||
|
||||
final blockBytes = Uint8List(blockSize);
|
||||
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
|
||||
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
|
||||
offset += mimeBytes.length;
|
||||
|
||||
final tempBuffer = ByteData(4);
|
||||
tempBuffer.setUint32(0, descBytes.length, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
|
||||
offset += descBytes.length;
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
tempBuffer.setUint32(0, imageData.length, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
blockBytes.setRange(offset, offset + imageData.length, imageData);
|
||||
|
||||
final base64String = base64Encode(blockBytes);
|
||||
|
||||
return base64String;
|
||||
} catch (e) {
|
||||
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
||||
final id3Map = <String, String>{};
|
||||
|
||||
@@ -287,7 +549,6 @@ class FFmpegService {
|
||||
final key = entry.key.toUpperCase();
|
||||
final value = entry.value;
|
||||
|
||||
// Map Vorbis comments to ID3v2 frame names
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
@@ -321,7 +582,6 @@ class FFmpegService {
|
||||
id3Map['lyrics'] = value;
|
||||
break;
|
||||
default:
|
||||
// Pass through other tags as-is
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +323,6 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns true if credentials are available (custom or env vars)
|
||||
static Future<bool> hasSpotifyCredentials() async {
|
||||
final result = await _channel.invokeMethod('hasSpotifyCredentials');
|
||||
return result as bool;
|
||||
@@ -343,11 +342,12 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('clearTrackCache');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 2, String? filter}) async {
|
||||
final result = await _channel.invokeMethod('searchDeezerAll', {
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
'filter': filter ?? '',
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
@@ -409,7 +409,6 @@ class PlatformBridge {
|
||||
return logs.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get logs since a specific index (for incremental updates)
|
||||
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
||||
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
@@ -560,7 +559,7 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
required String isrc,
|
||||
required String spotifyId,
|
||||
required String trackName,
|
||||
@@ -583,8 +582,9 @@ class PlatformBridge {
|
||||
String? genre,
|
||||
String? label,
|
||||
String lyricsMode = 'embed',
|
||||
String? preferredService,
|
||||
}) async {
|
||||
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
|
||||
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}');
|
||||
final request = jsonEncode({
|
||||
'isrc': isrc,
|
||||
'spotify_id': spotifyId,
|
||||
@@ -608,6 +608,7 @@ class PlatformBridge {
|
||||
'genre': genre ?? '',
|
||||
'label': label ?? '',
|
||||
'lyrics_mode': lyricsMode,
|
||||
'service': preferredService ?? '',
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadWithExtensions', request);
|
||||
@@ -794,7 +795,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get extension home feed
|
||||
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
|
||||
@@ -808,7 +808,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get extension browse categories
|
||||
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
|
||||
|
||||
@@ -10,7 +10,7 @@ class LogEntry {
|
||||
final String tag;
|
||||
final String message;
|
||||
final String? error;
|
||||
final bool isFromGo; // Track if this log came from Go backend
|
||||
final bool isFromGo;
|
||||
|
||||
LogEntry({
|
||||
required this.timestamp,
|
||||
@@ -47,8 +47,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
Timer? _goLogTimer;
|
||||
int _lastGoLogIndex = 0;
|
||||
|
||||
/// Whether logging is enabled (controlled by settings)
|
||||
/// User must enable "Detailed Logging" in settings to capture logs
|
||||
static bool _loggingEnabled = false;
|
||||
static bool get loggingEnabled => _loggingEnabled;
|
||||
static set loggingEnabled(bool value) {
|
||||
@@ -64,7 +62,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
int get length => _entries.length;
|
||||
|
||||
void add(LogEntry entry) {
|
||||
// Skip adding if logging is disabled (except for errors which are always logged)
|
||||
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
||||
return;
|
||||
}
|
||||
@@ -76,7 +73,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Start polling Go backend logs
|
||||
void startGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
@@ -84,13 +80,11 @@ class LogBuffer extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop polling Go backend logs
|
||||
void stopGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = null;
|
||||
}
|
||||
|
||||
/// Fetch logs from Go backend since last index
|
||||
Future<void> _fetchGoLogs() async {
|
||||
try {
|
||||
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||
@@ -103,7 +97,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
final tag = log['tag'] as String? ?? 'Go';
|
||||
final message = log['message'] as String? ?? '';
|
||||
|
||||
// Parse timestamp (format: "15:04:05.000")
|
||||
DateTime parsedTime = DateTime.now();
|
||||
if (timestamp.isNotEmpty) {
|
||||
try {
|
||||
@@ -221,7 +214,6 @@ class BufferedOutput extends LogOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Global logger instance for the app
|
||||
final log = Logger(
|
||||
printer: PrettyPrinter(
|
||||
methodCount: 0,
|
||||
|
||||
@@ -10,11 +10,15 @@ class BuiltInService {
|
||||
final String id;
|
||||
final String label;
|
||||
final List<QualityOption> qualityOptions;
|
||||
final bool isDisabled; // If true, service is grayed out (fallback only)
|
||||
final String? disabledReason;
|
||||
|
||||
const BuiltInService({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.qualityOptions,
|
||||
this.isDisabled = false,
|
||||
this.disabledReason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +31,7 @@ const _builtInServices = [
|
||||
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
|
||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||
QualityOption(id: 'HIGH', label: 'Lossy 320kbps', description: 'MP3 or Opus (smaller files)'),
|
||||
],
|
||||
),
|
||||
BuiltInService(
|
||||
@@ -46,16 +51,11 @@ const _builtInServices = [
|
||||
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
|
||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||
],
|
||||
isDisabled: true,
|
||||
disabledReason: 'Fallback only',
|
||||
),
|
||||
];
|
||||
|
||||
/// MP3 quality option (shown when enabled in settings)
|
||||
const _mp3QualityOption = QualityOption(
|
||||
id: 'MP3',
|
||||
label: 'MP3',
|
||||
description: '320kbps (converted from FLAC)',
|
||||
);
|
||||
|
||||
/// A reusable widget for selecting download service (built-in + extensions)
|
||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? trackName;
|
||||
@@ -112,34 +112,21 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
|
||||
/// Get quality options for the selected service
|
||||
List<QualityOption> _getQualityOptions() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
||||
if (builtIn != null) {
|
||||
// Add MP3 option if enabled in settings
|
||||
if (settings.enableMp3Option) {
|
||||
return [...builtIn.qualityOptions, _mp3QualityOption];
|
||||
}
|
||||
return builtIn.qualityOptions;
|
||||
}
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
||||
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
||||
// Add MP3 option for extensions too if enabled
|
||||
if (settings.enableMp3Option) {
|
||||
return [...ext.qualityOptions, _mp3QualityOption];
|
||||
}
|
||||
return ext.qualityOptions;
|
||||
}
|
||||
|
||||
// Default fallback options
|
||||
final defaultOptions = [
|
||||
return [
|
||||
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
||||
];
|
||||
if (settings.enableMp3Option) {
|
||||
return [...defaultOptions, _mp3QualityOption];
|
||||
}
|
||||
return defaultOptions;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -188,7 +175,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
@@ -196,9 +183,14 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
children: [
|
||||
for (final service in _builtInServices)
|
||||
_ServiceChip(
|
||||
label: service.label,
|
||||
label: service.isDisabled
|
||||
? '${service.label} (${service.disabledReason})'
|
||||
: service.label,
|
||||
isSelected: _selectedService == service.id,
|
||||
onTap: () => setState(() => _selectedService = service.id),
|
||||
isDisabled: service.isDisabled,
|
||||
onTap: service.isDisabled
|
||||
? null
|
||||
: () => setState(() => _selectedService = service.id),
|
||||
),
|
||||
for (final ext in downloadExtensions)
|
||||
_ServiceChip(
|
||||
@@ -237,8 +229,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
subtitle: quality.description ?? '',
|
||||
icon: _getQualityIcon(quality.id),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onSelect(quality.id, _selectedService);
|
||||
// For Tidal HIGH quality, show format picker first
|
||||
if (_selectedService == 'tidal' && quality.id == 'HIGH') {
|
||||
_showLossyFormatPicker(context);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
widget.onSelect(quality.id, _selectedService);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -257,6 +254,8 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
return Icons.high_quality;
|
||||
case 'LOSSLESS':
|
||||
return Icons.music_note;
|
||||
case 'HIGH':
|
||||
return Icons.aod;
|
||||
case 'MP3_320':
|
||||
case 'MP3':
|
||||
return Icons.audiotrack;
|
||||
@@ -267,6 +266,102 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
return Icons.music_note;
|
||||
}
|
||||
}
|
||||
|
||||
void _showLossyFormatPicker(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final currentFormat = settings.tidalHighFormat;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (modalContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
'Select Lossy Format',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Choose output format for 320kbps lossy download',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: const Text('MP3 320kbps'),
|
||||
subtitle: const Text('Best compatibility, ~10MB per track'),
|
||||
trailing: currentFormat == 'mp3_320'
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
|
||||
Navigator.pop(modalContext); // Close format picker
|
||||
Navigator.pop(context); // Close service picker
|
||||
widget.onSelect('HIGH', _selectedService);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: const Text('Opus 128kbps'),
|
||||
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||
trailing: currentFormat == 'opus_128'
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
|
||||
Navigator.pop(modalContext); // Close format picker
|
||||
Navigator.pop(context); // Close service picker
|
||||
widget.onSelect('HIGH', _selectedService);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -308,26 +403,32 @@ class _QualityOption extends StatelessWidget {
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onTap;
|
||||
final String? iconPath;
|
||||
final bool isDisabled;
|
||||
|
||||
const _ServiceChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.iconPath,
|
||||
this.isDisabled = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onTap: isDisabled ? null : onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
color: isDisabled
|
||||
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||
: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
@@ -345,7 +446,11 @@ class _ServiceChip extends StatelessWidget {
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
Icons.extension,
|
||||
size: 18,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -355,7 +460,11 @@ class _ServiceChip extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -297,6 +297,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
ffmpeg_kit_flutter_new_audio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: ffmpeg_kit_flutter_new_audio
|
||||
sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
ffmpeg_kit_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffmpeg_kit_flutter_platform_interface
|
||||
sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.2.1+64
|
||||
version: 3.3.5+70
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -59,8 +59,8 @@ dependencies:
|
||||
receive_sharing_intent: ^1.8.1
|
||||
logger: ^2.5.0
|
||||
|
||||
# FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
|
||||
# ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
|
||||
# FFmpeg for audio conversion
|
||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||
open_filex: ^4.7.0
|
||||
|
||||
# Notifications
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.2.1+64
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# Localization
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
intl: any
|
||||
|
||||
# State Management
|
||||
flutter_riverpod: ^3.1.0
|
||||
riverpod_annotation: ^4.0.0
|
||||
|
||||
# Navigation
|
||||
go_router: ^17.0.1
|
||||
|
||||
# Storage & Persistence
|
||||
shared_preferences: ^2.5.3
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.0
|
||||
sqflite: ^2.4.1
|
||||
|
||||
# HTTP & Network
|
||||
http: ^1.6.0
|
||||
dio: ^5.8.0
|
||||
|
||||
# UI Components
|
||||
cupertino_icons: ^1.0.8
|
||||
cached_network_image: ^3.4.1
|
||||
flutter_cache_manager: ^3.4.1
|
||||
flutter_svg: ^2.1.0
|
||||
|
||||
# Material Expressive 3 / Dynamic Color
|
||||
dynamic_color: ^1.7.0
|
||||
material_color_utilities: ^0.11.1
|
||||
palette_generator: ^0.3.3+4
|
||||
|
||||
# Permissions
|
||||
permission_handler: ^12.0.1
|
||||
|
||||
# File Picker
|
||||
file_picker: ^10.3.8
|
||||
|
||||
# JSON Serialization
|
||||
json_annotation: ^4.9.0
|
||||
|
||||
# Utils
|
||||
url_launcher: ^6.3.1
|
||||
device_info_plus: ^12.3.0
|
||||
share_plus: ^12.0.1
|
||||
receive_sharing_intent: ^1.8.1
|
||||
logger: ^2.5.0
|
||||
|
||||
# FFmpeg for iOS (uses plugin, Android uses custom AAR)
|
||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||
open_filex: ^4.7.0
|
||||
|
||||
# Notifications
|
||||
flutter_local_notifications: ^19.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
build_runner: ^2.10.4
|
||||
riverpod_generator: ^4.0.0
|
||||
json_serializable: ^6.11.2
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: "icon.png"
|
||||
adaptive_icon_background: "#1a1a2e"
|
||||
adaptive_icon_foreground: "icon.png"
|
||||
ios_content_mode: scaleAspectFill
|
||||
remove_alpha_ios: true
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
generate: true
|
||||
|
||||
assets:
|
||||
- assets/images/
|
||||
Reference in New Issue
Block a user