diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..c8437d38 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.41.4" +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04ade01e..b45e04e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -309,32 +309,22 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + fetch-depth: 0 # Full history needed for git-cliff - - name: Extract changelog for version + - name: Generate changelog with git-cliff id: changelog + uses: orhun/git-cliff-action@v4 + with: + config: cliff.toml + args: --latest --strip header + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OUTPUT: /tmp/changelog.txt + + - name: Show generated changelog run: | - VERSION=${{ needs.get-version.outputs.version }} - VERSION_NUM=${VERSION#v} # Remove 'v' prefix - - echo "Looking for version: $VERSION_NUM" - - # Extract changelog section for this version using sed - # Find the line with version, then print until next version header or end - CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md) - - # If no changelog found, use default message - if [ -z "$CHANGELOG" ]; then - echo "No changelog found for version $VERSION_NUM" - CHANGELOG="See CHANGELOG.md for details." - else - echo "Found changelog content" - # Remove trailing --- separator if present (CHANGELOG uses --- between versions) - CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d') - fi - - # Save to file for multiline support - echo "$CHANGELOG" > /tmp/changelog.txt - echo "Extracted changelog:" + echo "Generated changelog:" cat /tmp/changelog.txt - name: Download Android APK @@ -352,15 +342,22 @@ jobs: - name: Prepare release body run: | VERSION=${{ needs.get-version.outputs.version }} - cat > /tmp/release_body.txt << 'HEADER' - ### What's New - HEADER - - cat /tmp/changelog.txt >> /tmp/release_body.txt - REPO_OWNER="${{ github.repository_owner }}" REPO_NAME="${{ github.event.repository.name }}" - + CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD) + PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true) + + # Start with git-cliff changelog, but replace its compare footer with a + # deterministic previous-tag lookup from git. + sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt + + if [ -n "$PREVIOUS_TAG" ]; then + printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \ + "$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \ + >> /tmp/release_body.txt + fi + + # Append download section cat >> /tmp/release_body.txt << FOOTER --- @@ -396,6 +393,63 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + update-altstore: + runs-on: ubuntu-latest + needs: [get-version, build-ios, create-release] + if: ${{ needs.get-version.outputs.is_prerelease != 'true' }} + permissions: + contents: write + + steps: + - name: Checkout main branch + uses: actions/checkout@v6 + with: + ref: main + + - name: Download iOS IPA + uses: actions/download-artifact@v7 + with: + name: ios-ipa + path: ./release + + - name: Update apps.json + run: | + VERSION="${{ needs.get-version.outputs.version }}" + VERSION_NUM="${VERSION#v}" + DATE=$(date -u +%Y-%m-%d) + IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1) + + if [ -z "$IPA_FILE" ]; then + echo "WARNING: IPA file not found, skipping apps.json update" + exit 0 + fi + + IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE") + + if [ ! -f apps.json ]; then + echo "WARNING: apps.json not found on main, skipping" + exit 0 + fi + + jq --arg ver "$VERSION_NUM" \ + --arg date "$DATE" \ + --arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \ + --argjson size "$IPA_SIZE" \ + '.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \ + apps.json > apps.json.tmp && mv apps.json.tmp apps.json + + echo "Updated apps.json:" + cat apps.json + + - name: Commit and push + run: | + VERSION="${{ needs.get-version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add apps.json + git diff --cached --quiet && echo "No changes to commit" || \ + (git commit -m "chore: update AltStore source to ${VERSION}" && git push) + notify-telegram: runs-on: ubuntu-latest needs: [get-version, create-release] @@ -404,6 +458,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Download Android APK uses: actions/download-artifact@v7 @@ -417,52 +473,43 @@ jobs: name: ios-ipa path: ./release - - name: Extract changelog for version + - name: Generate changelog with git-cliff for Telegram + uses: orhun/git-cliff-action@v4 + with: + config: cliff.toml + args: --latest --strip all + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OUTPUT: /tmp/cliff_tg.txt + + - name: Convert changelog for Telegram id: changelog run: | - VERSION=${{ needs.get-version.outputs.version }} - VERSION_NUM=${VERSION#v} - - # Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead) - # Use tr -d '\r' to handle CRLF line endings from Windows - FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d') - - echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}" - echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}" - - if [ -z "$FULL_CHANGELOG" ]; then - CHANGELOG="See release notes on GitHub for details." + if [ ! -s /tmp/cliff_tg.txt ]; then + echo "See release notes on GitHub for details." > /tmp/changelog.txt else - # Convert GitHub Markdown to Telegram HTML: - # - **text** → text - # - `code` → code - # - ### Header → Header - # - Escape HTML special chars first - # - Remove > blockquote prefix - CHANGELOG=$(echo "$FULL_CHANGELOG" | \ - sed 's/^> //' | \ + # Convert Markdown to Telegram HTML + CHANGELOG=$(cat /tmp/cliff_tg.txt | \ + sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \ + sed '/^\*\*Full Changelog\*\*/d' | \ + sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \ + sed 's/ by @[A-Za-z0-9_-]\+//g' | \ + sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \ + sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \ sed 's/&/\&/g' | \ sed 's//\>/g' | \ - sed 's/`\([^`]*\)`/\1<\/code>/g' | \ sed 's/\*\*\([^*]*\)\*\*/\1<\/b>/g' | \ sed 's/^### \(.*\)$/\1<\/b>/g' | \ sed 's/^## \(.*\)$/\1<\/b>/g' | \ - sed 's/^- /• /g' | \ - sed 's/^ - / ◦ /g') - - # Take first 2500 characters, then cut at last complete line + sed 's/^- /• /g') + + # Truncate for Telegram 4096 char limit CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d') - - # Check if truncated - FULL_LEN=${#FULL_CHANGELOG} - if [ $FULL_LEN -gt 2500 ]; then - CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)' - fi + echo "$CHANGELOG" > /tmp/changelog.txt fi - echo "$CHANGELOG" > /tmp/changelog.txt - echo "DEBUG: Final changelog:" + echo "Telegram changelog:" cat /tmp/changelog.txt - name: Send to Telegram Channel diff --git a/.gitignore b/.gitignore index e2e2649f..b24e9371 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ Thumbs.db # Kiro specs (development only) .kiro/ +# Design assets (banners, mockups) +design/ + # Reference folder (development only) referensi/ @@ -64,6 +67,7 @@ AGENTS.md # Temp/misc nul +network_requests.txt # Log files *.log @@ -73,3 +77,6 @@ flutter_*.log # Development tools tool/ .claude/settings.local.json + +# FVM Version Cache +.fvm/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8b7254..9916536f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t - Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service - New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)` - SpotFetch metadata fallback integration for Spotify-blocked regions - - New backend client for `spotify.afkarxyz.fun/api` + - New backend client for `sp.afkarxyz.qzz.io/api` - Automatic fallback in Spotify metadata fetch path when primary source fails - Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC - Includes heuristic detection of lyrics stored in Comment fields @@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t - Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated` - Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers) - Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths) -- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support +- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support - Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback - Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow) - Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1750732..2c1e7b6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`. git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git ``` -3. **Install dependencies** +3. **Use FVM (Flutter Version: 3.38.1)** + ```bash + fvm use + ``` + +4. **Install dependencies** ```bash flutter pub get ``` -4. **Generate code** (for Riverpod, JSON serialization, etc.) +5. **Generate code** (for Riverpod, JSON serialization, etc.) ```bash dart run build_runner build --delete-conflicting-outputs ``` -5. **Run the app** +6. **Set up Go environment (Go Version: 1.25.7)** + ```bash + cd go_backend + mkdir -p ../android/app/libs + gomobile init + gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar . + cd .. + ``` + +7. **Run the app** ```bash flutter run ``` diff --git a/README.md b/README.md index ae6db959..4ad91b55 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,19 @@ -[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) -[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f) -[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) -
- + + + + SpotiFLAC Mobile + -Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account required. - -![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white) -![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white) +

+ + zarzet%2FSpotiFLAC-Mobile | Trendshift + +

-### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases) - ## Screenshots

@@ -24,32 +23,42 @@ Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account r

+
+ +[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) +[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58) +[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) + +[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) +[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat) + +
+ ## Extensions Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently. ### Installing Extensions 1. Go to **Store** tab in the app -2. Browse and install extensions with one tap -3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions** -4. Configure extension settings if needed -5. Set provider priority in **Settings > Extensions > Provider Priority** +2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL** +3. Browse and install extensions with one tap +4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions** +5. Configure extension settings if needed +6. Set provider priority in **Settings > Extensions > Provider Priority** ### Developing Extensions -Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation. +Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation. ## Other project ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux -## Telegram - -[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) -[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat) - ## FAQ +**Q: Why does the Store tab ask me to enter a URL?** +A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions. + **Q: Why is my download failing with "Song not found"?** A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store. @@ -68,6 +77,11 @@ 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. +**Q: Can I add SpotiFLAC to AltStore or SideStore?** +A: Yes! You can add the official source to receive updates directly within the app. Just copy this link: +https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json +In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link. + ### Want to support SpotiFLAC-Mobile? @@ -75,6 +89,18 @@ _If this software is useful and brings you value, consider supporting the projec [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) +## Contributors + +Thanks to all the amazing people who have contributed to SpotiFLAC Mobile! + + + + + +We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile. + +Interested in contributing? Check out our [Contributing Guide](CONTRIBUTING.md) to get started! + ## API Credits [hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify) @@ -82,4 +108,4 @@ _If this software is useful and brings you value, consider supporting the projec > [!TIP] > -> **Star Us**, You will receive all release notifications from GitHub without any delay ~ +> **Star Us**, You will receive all release notifications from GitHub without any delay diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index c3fd7b46..fe9b8e07 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -115,10 +115,8 @@ class DownloadService : Service() { * We must call stopSelf() within a few seconds to avoid a crash. */ override fun onTimeout(startId: Int, fgsType: Int) { - // Log the timeout for debugging android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.") - // Gracefully stop the service stopForegroundService() } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index da56220d..3fba0054 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -30,6 +30,7 @@ import org.json.JSONObject import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.security.MessageDigest import java.util.Locale class MainActivity: FlutterFragmentActivity() { @@ -38,7 +39,7 @@ class MainActivity: FlutterFragmentActivity() { "com.zarz.spotiflac/download_progress_stream" private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream" - private val STREAM_POLLING_INTERVAL_MS = 800L + private val STREAM_POLLING_INTERVAL_MS = 1200L private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var pendingSafTreeResult: MethodChannel.Result? = null private val safScanLock = Any() @@ -111,6 +112,13 @@ class MainActivity: FlutterFragmentActivity() { } } + private fun buildStableLibraryId(filePath: String): String { + val digest = MessageDigest.getInstance("SHA-1") + val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8)) + val hex = bytes.joinToString("") { "%02x".format(it) } + return "lib_$hex" + } + data class SafScanProgress( var totalFiles: Int = 0, var scannedFiles: Int = 0, @@ -469,6 +477,32 @@ class MainActivity: FlutterFragmentActivity() { lastLibraryScanProgressPayload = null } + private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String { + if (snapshotPath.isBlank()) { + return "{}" + } + + val snapshotFile = File(snapshotPath) + if (!snapshotFile.exists()) { + return "{}" + } + + val result = JSONObject() + snapshotFile.forEachLine { line -> + if (line.isBlank()) return@forEachLine + val separatorIndex = line.indexOf('\t') + if (separatorIndex <= 0 || separatorIndex >= line.length - 1) { + return@forEachLine + } + val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L + val filePath = line.substring(separatorIndex + 1) + if (filePath.isNotEmpty()) { + result.put(filePath, modTime) + } + } + return result.toString() + } + private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { val obj = JSONObject() if (treeUriStr.isBlank() || fileName.isBlank()) { @@ -703,6 +737,80 @@ class MainActivity: FlutterFragmentActivity() { } } + private fun buildUriDisplayName( + uri: Uri, + displayNameHint: String? = null, + fallbackExt: String? = null, + ): String { + val explicitName = displayNameHint?.trim().orEmpty() + if (explicitName.isNotEmpty()) return explicitName + + val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null } + val uriName = uri.lastPathSegment + val resolvedName = (docName ?: uriName ?: "").trim() + if (resolvedName.isNotEmpty()) return resolvedName + + val ext = when { + fallbackExt.isNullOrBlank().not() -> fallbackExt + isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt) + else -> "" + } + return if (ext.isNullOrBlank()) "audio" else "audio$ext" + } + + private fun readAudioMetadataFromUri( + uri: Uri, + displayNameHint: String? = null, + fallbackExt: String? = null, + ): JSONObject? { + val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt) + + try { + contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + val directPath = "/proc/self/fd/${pfd.fd}" + val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName) + if (metadataJson.isNotBlank()) { + val obj = JSONObject(metadataJson) + if (!obj.has("error")) { + return obj + } + } + } + } catch (e: Exception) { + android.util.Log.d( + "SpotiFLAC", + "Direct SAF metadata read fallback for $uri: ${e.message}", + ) + } + + val tempPath = try { + copyUriToTemp(uri, fallbackExt) + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "SAF metadata fallback copy failed for $uri: ${e.message}", + ) + null + } ?: return null + + try { + val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName) + if (metadataJson.isBlank()) return null + val obj = JSONObject(metadataJson) + return if (obj.has("error")) null else obj + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "SAF metadata temp read failed for $uri: ${e.message}", + ) + return null + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } + private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean { val srcFile = File(srcPath) if (!srcFile.exists()) return false @@ -807,6 +915,132 @@ class MainActivity: FlutterFragmentActivity() { } } + /** + * Get the parent DocumentFile directory for a SAF document URI. + * The child URI must be a tree-based document URI (e.g. from SAF tree scan). + * Returns a DocumentFile that supports findFile() for sibling lookup. + */ + private fun safParentDir(childUri: Uri): DocumentFile? { + try { + val docId = android.provider.DocumentsContract.getDocumentId(childUri) + if (docId.isNullOrEmpty()) return null + + // Document IDs typically look like "primary:Music/Album/file.cue" + // Parent would be "primary:Music/Album" + val lastSlash = docId.lastIndexOf('/') + if (lastSlash <= 0) return null + + val parentDocId = docId.substring(0, lastSlash) + + // Build a tree document URI for the parent so it supports listing/findFile + val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri) + if (treeDocId.isNullOrEmpty()) return null + + val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree( + childUri, parentDocId + ) + return DocumentFile.fromTreeUri(this, parentUri) + ?: DocumentFile.fromSingleUri(this, parentUri) + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}") + return null + } + } + + /** + * Extract the audio filename referenced by a CUE sheet file. + * Reads the FILE "name" TYPE line from the .cue text. + * Returns just the filename (no path), or null if not found. + */ + private fun extractCueAudioFileName(cueTempPath: String): String? { + try { + val lines = File(cueTempPath).readLines() + for (line in lines) { + val trimmed = line.trim().let { l -> + // Strip BOM + if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l + } + if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { + val rest = trimmed.substring(5).trim() + // Parse: "filename" TYPE or filename TYPE + val filename = if (rest.startsWith("\"")) { + val endQuote = rest.indexOf('"', 1) + if (endQuote > 0) rest.substring(1, endQuote) else rest + } else { + // Last word is the type, everything else is the filename + val parts = rest.split("\\s+".toRegex()) + if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest + } + // Return just the filename (strip any path separators) + return filename.substringAfterLast("/").substringAfterLast("\\") + } + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}") + } + return null + } + + private val cueSiblingAudioExtensions = listOf( + ".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a" + ) + + private fun getSafChildFileLookup( + dir: DocumentFile, + cache: MutableMap>, + ): Map { + val dirKey = dir.uri.toString() + return cache.getOrPut(dirKey) { + try { + buildMap { + for (child in dir.listFiles()) { + if (!child.isFile) continue + val childName = child.name?.trim().orEmpty() + if (childName.isBlank()) continue + put(childName.lowercase(Locale.ROOT), child) + } + } + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Failed to build SAF child lookup for $dirKey: ${e.message}", + ) + emptyMap() + } + } + } + + private fun resolveCueAudioSibling( + parentDir: DocumentFile, + cueName: String, + audioFileName: String?, + childLookupCache: MutableMap>, + ): DocumentFile? { + val childLookup = getSafChildFileLookup(parentDir, childLookupCache) + + val directMatch = audioFileName + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.substringAfterLast("/") + ?.substringAfterLast("\\") + ?.lowercase(Locale.ROOT) + ?.let(childLookup::get) + if (directMatch != null) { + return directMatch + } + + val cueBaseName = cueName.substringBeforeLast('.').trim() + if (cueBaseName.isBlank()) { + return null + } + + val cueBaseKey = cueBaseName.lowercase(Locale.ROOT) + for (ext in cueSiblingAudioExtensions) { + childLookup["$cueBaseKey$ext"]?.let { return it } + } + return null + } + private fun scanSafTree(treeUriStr: String): String { if (treeUriStr.isBlank()) return "[]" @@ -820,9 +1054,12 @@ class MainActivity: FlutterFragmentActivity() { it.currentFile = "Scanning folders..." } - val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") + val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() + // CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio + val cueFiles = mutableListOf>() val visitedDirUris = mutableSetOf() + val safChildLookupCache = mutableMapOf>() var traversalErrors = 0 val queue: ArrayDeque> = ArrayDeque() @@ -870,7 +1107,9 @@ class MainActivity: FlutterFragmentActivity() { } else if (child.isFile) { val name = child.name ?: continue val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + if (ext == "cue") { + cueFiles.add(child to dir) + } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { audioFiles.add(child to path) } } @@ -885,11 +1124,12 @@ class MainActivity: FlutterFragmentActivity() { } } + val totalItems = audioFiles.size + cueFiles.size updateSafScanProgress { - it.totalFiles = audioFiles.size + it.totalFiles = totalItems } - if (audioFiles.isEmpty()) { + if (audioFiles.isEmpty() && cueFiles.isEmpty()) { updateSafScanProgress { it.isComplete = true it.progressPct = 100.0 @@ -901,12 +1141,123 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors + // --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio --- + val cueReferencedAudioUris = mutableSetOf() + + for ((cueDoc, parentDir) in cueFiles) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + return "[]" + } + + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { it.currentFile = cueName } + + var tempCuePath: String? = null + var tempAudioPath: String? = null + try { + tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCuePath == null) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}") + scanned++ + continue + } + + val audioFileName = extractCueAudioFileName(tempCuePath) + + val audioDoc = resolveCueAudioSibling( + parentDir = parentDir, + cueName = cueName, + audioFileName = audioFileName, + childLookupCache = safChildLookupCache, + ) + + if (audioDoc == null) { + android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName") + errors++ + scanned++ + continue + } + + // Mark this audio file so we skip it in the regular audio pass + cueReferencedAudioUris.add(audioDoc.uri.toString()) + + // Copy audio to same temp dir so Go can resolve it + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } + val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null + + tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) + if (tempAudioPath == null) { + android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy audio for CUE $cueName") + errors++ + scanned++ + continue + } + + // Rename temp audio to its original name so Go can find it by name + val renamedAudio = File(tempDir, audioName) + val tempAudioFile = File(tempAudioPath) + if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { + tempAudioFile.renameTo(renamedAudio) + tempAudioPath = renamedAudio.absolutePath + } + + val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } + + val cueResultsJson = Gobackend.scanCueSheetForLibrary( + tempCuePath, + tempDir, + cueDoc.uri.toString(), + cueLastModified + ) + + val cueArray = JSONArray(cueResultsJson) + for (j in 0 until cueArray.length()) { + results.put(cueArray.getJSONObject(j)) + } + + android.util.Log.d( + "SpotiFLAC", + "SAF scan: CUE $cueName -> ${cueArray.length()} tracks" + ) + } catch (e: Exception) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}") + } finally { + try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + + scanned++ + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.errorCount = errors + it.progressPct = pct + } + } + + // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } return "[]" } + // Skip audio files that are represented by CUE track entries + if (cueReferencedAudioUris.contains(doc.uri.toString())) { + scanned++ + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.progressPct = pct + } + continue + } + val name = try { doc.name ?: "" } catch (_: Exception) { "" } updateSafScanProgress { it.currentFile = name @@ -914,40 +1265,24 @@ class MainActivity: FlutterFragmentActivity() { val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val tempPath = try { - copyUriToTemp(doc.uri, fallbackExt) - } catch (e: Exception) { - android.util.Log.w( - "SpotiFLAC", - "SAF scan: failed to copy ${doc.uri}: ${e.message}", - ) - null - } - if (tempPath == null) { + val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) + if (metadataObj == null) { errors++ } else { try { - val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) - if (metadataJson.isNotBlank()) { - val obj = JSONObject(metadataJson) - val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } - obj.put("filePath", doc.uri.toString()) - obj.put("fileModTime", lastModified) - results.put(obj) - } else { - errors++ - } + val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } + val stableUri = doc.uri.toString() + metadataObj.put("id", buildStableLibraryId(stableUri)) + metadataObj.put("filePath", stableUri) + metadataObj.put("fileModTime", lastModified) + results.put(metadataObj) } catch (_: Exception) { errors++ - } finally { - try { - File(tempPath).delete() - } catch (_: Exception) {} } } scanned++ - val pct = scanned.toDouble() / audioFiles.size.toDouble() * 100.0 + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 updateSafScanProgress { it.scannedFiles = scanned it.errorCount = errors @@ -965,6 +1300,8 @@ class MainActivity: FlutterFragmentActivity() { /** * Incremental SAF tree scan - only scans new or modified files. + * Supports .cue sheets: expands them into virtual track entries and + * deduplicates audio files referenced by CUE sheets. * @param treeUriStr The SAF tree URI to scan * @param existingFilesJson JSON object mapping file URI -> lastModified timestamp * @return JSON object with new/changed files and removed URIs @@ -1007,13 +1344,30 @@ class MainActivity: FlutterFragmentActivity() { it.currentFile = "Scanning folders..." } - val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") + val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() // doc, path, lastModified + // CUE files to scan: (cueDoc, parentDir, lastModified) + val cueFilesToScan = mutableListOf>() + // Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set + val unchangedCueFiles = mutableListOf>() val currentUris = mutableSetOf() val visitedDirUris = mutableSetOf() + val safChildLookupCache = mutableMapOf>() var traversalErrors = 0 - // Collect all audio files with lastModified + // Build a map of CUE base URIs -> existing virtual track URIs from the database. + // Virtual paths look like "content://...album.cue#track01". + // We need this to preserve virtual paths for unchanged CUE files. + val existingCueVirtualPaths = mutableMapOf>() // cueUri -> [virtualPaths] + for (key in existingFiles.keys) { + val hashIdx = key.indexOf("#track") + if (hashIdx > 0) { + val baseCueUri = key.substring(0, hashIdx) + existingCueVirtualPaths.getOrPut(baseCueUri) { mutableListOf() }.add(key) + } + } + + // Collect all files with lastModified val queue: ArrayDeque> = ArrayDeque() queue.add(root to "") @@ -1076,7 +1430,27 @@ class MainActivity: FlutterFragmentActivity() { val name = child.name ?: continue val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + + if (ext == "cue") { + val lastModified = try { + child.lastModified() + } catch (_: Exception) { 0L } + + // Check if any virtual track from this CUE exists with matching modTime + val virtualPaths = existingCueVirtualPaths[uriStr] + val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } + + if (existingModified != null && existingModified == lastModified) { + // CUE is unchanged — mark virtual paths as current so they aren't removed + unchangedCueFiles.add(child to dir) + for (vp in virtualPaths) { + currentUris.add(vp) + } + } else { + // CUE is new or modified — needs scanning + cueFilesToScan.add(Triple(child, dir, lastModified)) + } + } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { val existingModified = existingFiles[uriStr] val lastModified = try { child.lastModified() @@ -1104,13 +1478,14 @@ class MainActivity: FlutterFragmentActivity() { // Find removed files (in existing but not in current) val removedUris = existingFiles.keys.filter { !currentUris.contains(it) } val totalFiles = currentUris.size - val skippedCount = (totalFiles - audioFiles.size).coerceAtLeast(0) + val filesToProcess = audioFiles.size + cueFilesToScan.size + val skippedCount = (totalFiles - filesToProcess).coerceAtLeast(0) updateSafScanProgress { it.totalFiles = totalFiles } - if (audioFiles.isEmpty()) { + if (audioFiles.isEmpty() && cueFilesToScan.isEmpty()) { updateSafScanProgress { it.isComplete = true it.scannedFiles = totalFiles @@ -1128,6 +1503,152 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors + // --- CUE first pass: parse new/modified CUE sheets --- + val cueReferencedAudioUris = mutableSetOf() + + for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", skippedCount) + result.put("totalFiles", totalFiles) + result.put("cancelled", true) + return result.toString() + } + + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { it.currentFile = cueName } + + var tempCuePath: String? = null + var tempAudioPath: String? = null + try { + // Copy CUE to temp + tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCuePath == null) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}") + scanned++ + continue + } + + // Extract the audio filename from the CUE sheet text + val audioFileName = extractCueAudioFileName(tempCuePath) + + // Find the referenced audio file as a sibling in the same SAF directory + val audioDoc = resolveCueAudioSibling( + parentDir = parentDir, + cueName = cueName, + audioFileName = audioFileName, + childLookupCache = safChildLookupCache, + ) + + if (audioDoc == null) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName") + errors++ + scanned++ + continue + } + + // Mark this audio file so we skip it in the regular audio pass + cueReferencedAudioUris.add(audioDoc.uri.toString()) + + // Copy audio to same temp dir so Go can resolve it + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } + val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null + + tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) + if (tempAudioPath == null) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy audio for CUE $cueName") + errors++ + scanned++ + continue + } + + // Rename temp audio to its original name so Go can find it by name + val renamedAudio = File(tempDir, audioName) + val tempAudioFile = File(tempAudioPath) + if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { + tempAudioFile.renameTo(renamedAudio) + tempAudioPath = renamedAudio.absolutePath + } + + // Call Go to produce library scan entries for each CUE track + val cueResultsJson = Gobackend.scanCueSheetForLibrary( + tempCuePath, + tempDir, + cueDoc.uri.toString(), + cueLastModified + ) + + val cueArray = JSONArray(cueResultsJson) + for (j in 0 until cueArray.length()) { + val trackObj = cueArray.getJSONObject(j) + results.put(trackObj) + // Register each virtual path as current so deletion detection works + val virtualPath = trackObj.optString("filePath", "") + if (virtualPath.isNotBlank()) { + currentUris.add(virtualPath) + } + } + + android.util.Log.d( + "SpotiFLAC", + "SAF incremental scan: CUE $cueName -> ${cueArray.length()} tracks" + ) + } catch (e: Exception) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}") + } finally { + try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.errorCount = errors + it.progressPct = pct + } + } + + // Discover audio siblings for unchanged CUE files so we skip them + // in the regular audio pass. Copy the .cue to temp (tiny file) to extract + // the audio filename, then find the sibling by name. + for ((cueDoc, parentDir) in unchangedCueFiles) { + var tempCue: String? = null + try { + tempCue = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCue != null) { + val audioFileName = extractCueAudioFileName(tempCue) + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + val audioDoc = resolveCueAudioSibling( + parentDir = parentDir, + cueName = cueName, + audioFileName = audioFileName, + childLookupCache = safChildLookupCache, + ) + if (audioDoc != null) { + cueReferencedAudioUris.add(audioDoc.uri.toString()) + } + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to resolve audio for unchanged CUE: ${e.message}") + } finally { + try { tempCue?.let { File(it).delete() } } catch (_: Exception) {} + } + } + + // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _, lastModified) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } @@ -1140,6 +1661,22 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } + // Skip audio files that are represented by CUE track entries + if (cueReferencedAudioUris.contains(doc.uri.toString())) { + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.progressPct = pct + } + continue + } + val name = try { doc.name ?: "" } catch (_: Exception) { "" } updateSafScanProgress { it.currentFile = name @@ -1147,36 +1684,20 @@ class MainActivity: FlutterFragmentActivity() { val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val tempPath = try { - copyUriToTemp(doc.uri, fallbackExt) - } catch (e: Exception) { - android.util.Log.w( - "SpotiFLAC", - "SAF incremental scan: failed to copy ${doc.uri}: ${e.message}", - ) - null - } - if (tempPath == null) { + val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) + if (metadataObj == null) { errors++ } else { try { - val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) - if (metadataJson.isNotBlank()) { - val obj = JSONObject(metadataJson) - val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } - obj.put("filePath", doc.uri.toString()) - obj.put("fileModTime", safeLastModified) - obj.put("lastModified", safeLastModified) - results.put(obj) - } else { - errors++ - } + val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } + val stableUri = doc.uri.toString() + metadataObj.put("id", buildStableLibraryId(stableUri)) + metadataObj.put("filePath", stableUri) + metadataObj.put("fileModTime", safeLastModified) + metadataObj.put("lastModified", safeLastModified) + results.put(metadataObj) } catch (_: Exception) { errors++ - } finally { - try { - File(tempPath).delete() - } catch (_: Exception) {} } } @@ -1194,6 +1715,9 @@ class MainActivity: FlutterFragmentActivity() { } } + // Recalculate removedUris now that CUE virtual paths have been registered + val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) } + updateSafScanProgress { it.isComplete = true it.progressPct = 100.0 @@ -1201,7 +1725,7 @@ class MainActivity: FlutterFragmentActivity() { val result = JSONObject() result.put("files", results) - result.put("removedUris", JSONArray(removedUris)) + result.put("removedUris", JSONArray(finalRemovedUris)) result.put("skippedCount", skippedCount) result.put("totalFiles", totalFiles) return result.toString() @@ -1455,38 +1979,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "getSpotifyMetadata" -> { - val url = call.argument("url") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.getSpotifyMetadata(url) - } - result.success(response) - } - "searchSpotify" -> { - val query = call.argument("query") ?: "" - val limit = call.argument("limit") ?: 10 - val response = withContext(Dispatchers.IO) { - Gobackend.searchSpotify(query, limit.toLong()) - } - result.success(response) - } - "searchSpotifyAll" -> { - val query = call.argument("query") ?: "" - val trackLimit = call.argument("track_limit") ?: 15 - val artistLimit = call.argument("artist_limit") ?: 3 - val response = withContext(Dispatchers.IO) { - Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong()) - } - result.success(response) - } - "getSpotifyRelatedArtists" -> { - val artistId = call.argument("artist_id") ?: "" - val limit = call.argument("limit") ?: 12 - val response = withContext(Dispatchers.IO) { - Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong()) - } - result.success(response) - } "checkAvailability" -> { val spotifyId = call.argument("spotify_id") ?: "" val isrc = call.argument("isrc") ?: "" @@ -2120,20 +2612,6 @@ class MainActivity: FlutterFragmentActivity() { "isDownloadServiceRunning" -> { result.success(DownloadService.isServiceRunning()) } - "setSpotifyCredentials" -> { - val clientId = call.argument("client_id") ?: "" - val clientSecret = call.argument("client_secret") ?: "" - withContext(Dispatchers.IO) { - Gobackend.setSpotifyAPICredentials(clientId, clientSecret) - } - result.success(null) - } - "hasSpotifyCredentials" -> { - val hasCredentials = withContext(Dispatchers.IO) { - Gobackend.checkSpotifyCredentials() - } - result.success(hasCredentials) - } "preWarmTrackCache" -> { val tracksJson = call.argument("tracks") ?: "[]" withContext(Dispatchers.IO) { @@ -2164,6 +2642,28 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + // Tidal search API + "searchTidalAll" -> { + val query = call.argument("query") ?: "" + val trackLimit = call.argument("track_limit") ?: 15 + val artistLimit = call.argument("artist_limit") ?: 2 + val filter = call.argument("filter") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter) + } + result.success(response) + } + // Qobuz search API + "searchQobuzAll" -> { + val query = call.argument("query") ?: "" + val trackLimit = call.argument("track_limit") ?: 15 + val artistLimit = call.argument("artist_limit") ?: 2 + val filter = call.argument("filter") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter) + } + result.success(response) + } "getDeezerRelatedArtists" -> { val artistId = call.argument("artist_id") ?: "" val limit = call.argument("limit") ?: 12 @@ -2180,6 +2680,22 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "getQobuzMetadata" -> { + val resourceType = call.argument("resource_type") ?: "" + val resourceId = call.argument("resource_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getQobuzMetadata(resourceType, resourceId) + } + result.success(response) + } + "getTidalMetadata" -> { + val resourceType = call.argument("resource_type") ?: "" + val resourceId = call.argument("resource_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getTidalMetadata(resourceType, resourceId) + } + result.success(response) + } "parseDeezerUrl" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { @@ -2187,6 +2703,13 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "parseQobuzUrl" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.parseQobuzURLExport(url) + } + result.success(response) + } "parseTidalUrl" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { @@ -2415,6 +2938,15 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "searchTracksWithMetadataProviders" -> { + val query = call.argument("query") ?: "" + val limit = call.argument("limit") ?: 20 + val includeExtensions = call.argument("include_extensions") ?: true + val response = withContext(Dispatchers.IO) { + Gobackend.searchTracksWithMetadataProvidersJSON(query, limit.toLong(), includeExtensions) + } + result.success(response) + } "enrichTrackWithExtension" -> { val extensionId = call.argument("extension_id") ?: "" val trackJson = call.argument("track") ?: "{}" @@ -2620,6 +3152,25 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } + "setStoreRegistryUrl" -> { + val registryUrl = call.argument("registry_url") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setStoreRegistryURLJSON(registryUrl) + } + result.success(null) + } + "getStoreRegistryUrl" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getStoreRegistryURLJSON() + } + result.success(response) + } + "clearStoreRegistryUrl" -> { + withContext(Dispatchers.IO) { + Gobackend.clearStoreRegistryURLJSON() + } + result.success(null) + } "getStoreExtensions" -> { val forceRefresh = call.argument("force_refresh") ?: false val response = withContext(Dispatchers.IO) { @@ -2695,6 +3246,18 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "scanLibraryFolderIncrementalFromSnapshot" -> { + val folderPath = call.argument("folder_path") ?: "" + val snapshotPath = call.argument("snapshot_path") ?: "" + val response = withContext(Dispatchers.IO) { + safScanActive = false + Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON( + folderPath, + snapshotPath, + ) + } + result.success(response) + } "scanSafTree" -> { val treeUri = call.argument("tree_uri") ?: "" val response = withContext(Dispatchers.IO) { @@ -2710,6 +3273,16 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "scanSafTreeIncrementalFromSnapshot" -> { + val treeUri = call.argument("tree_uri") ?: "" + val snapshotPath = call.argument("snapshot_path") ?: "" + val response = withContext(Dispatchers.IO) { + val existingFilesJson = + loadExistingFilesJsonFromSnapshot(snapshotPath) + scanSafTreeIncremental(treeUri, existingFilesJson) + } + result.success(response) + } "getSafFileModTimes" -> { val uris = call.argument("uris") ?: "[]" val response = withContext(Dispatchers.IO) { @@ -2740,13 +3313,10 @@ class MainActivity: FlutterFragmentActivity() { try { if (filePath.startsWith("content://")) { val uri = Uri.parse(filePath) - val tempPath = copyUriToTemp(uri) - ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" - try { - Gobackend.readAudioMetadataJSON(tempPath) - } finally { - try { File(tempPath).delete() } catch (_: Exception) {} - } + val metadata = readAudioMetadataFromUri(uri) + ?: return@withContext """{"error":"Failed to read SAF audio metadata"}""" + metadata.put("filePath", filePath) + metadata.toString() } else { Gobackend.readAudioMetadataJSON(filePath) } @@ -2756,6 +3326,89 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + // CUE Sheet Parsing + "parseCueSheet" -> { + val cuePath = call.argument("cue_path") ?: "" + val audioDir = call.argument("audio_dir") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (cuePath.startsWith("content://")) { + val uri = Uri.parse(cuePath) + val tempCuePath = copyUriToTemp(uri, ".cue") + ?: return@withContext """{"error":"Failed to copy CUE file to temp"}""" + var tempAudioPath: String? = null + try { + // Extract audio filename from CUE text + val audioFileName = extractCueAudioFileName(tempCuePath) + + // Try to find the audio sibling in SAF + var audioDoc: DocumentFile? = null + val parentDir = safParentDir(uri) + if (parentDir != null && !audioFileName.isNullOrBlank()) { + audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } + } + + // Fallback: try common extensions with the CUE base name + if (audioDoc == null && parentDir != null) { + val cueName = try { + DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: "" + } catch (_: Exception) { "" } + val cueBaseName = cueName.substringBeforeLast('.') + if (cueBaseName.isNotBlank()) { + val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") + for (ext in commonExts) { + audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } + if (audioDoc != null) break + audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } + if (audioDoc != null) break + } + } + } + + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + if (audioDoc != null) { + // Copy audio to same temp dir with original name + val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } + val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null + val copiedAudio = copyUriToTemp(audioDoc.uri, fallbackExt) + if (copiedAudio != null) { + val renamedAudio = File(tempDir, audioName) + val copiedFile = File(copiedAudio) + if (renamedAudio.absolutePath != copiedFile.absolutePath) { + copiedFile.renameTo(renamedAudio) + } + tempAudioPath = renamedAudio.absolutePath + } + } + + // Parse with audio in temp dir; Go will resolve there + val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir) + + // Replace the temp audio_path with the SAF content:// URI + // so Dart knows it's a SAF file and handles it accordingly + if (audioDoc != null) { + val resultObj = JSONObject(resultJson) + resultObj.put("audio_path", audioDoc.uri.toString()) + // Also pass the original CUE URI for reference + resultObj.put("cue_path", cuePath) + resultObj.toString() + } else { + resultJson + } + } finally { + try { File(tempCuePath).delete() } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + } else { + Gobackend.parseCueSheet(cuePath, audioDir) + } + } catch (e: Exception) { + """{"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/apps.json b/apps.json new file mode 100644 index 00000000..fd8aa9e2 --- /dev/null +++ b/apps.json @@ -0,0 +1,18 @@ +{ + "name": "SpotiFLAC Source", + "identifier": "com.zarzet.spotiflac.source", + "subtitle": "FLAC Downloader for iOS", + "apps": [ + { + "name": "SpotiFLAC", + "bundleIdentifier": "com.zarzet.spotiflac", + "developerName": "zarzet", + "version": "3.8.6", + "versionDate": "2026-03-16", + "downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-ios-unsigned.ipa", + "localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.", + "iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png", + "size": 33676960 + } + ] +} diff --git a/assets/images/banner-readme-dark.png b/assets/images/banner-readme-dark.png new file mode 100644 index 00000000..13c40d0b Binary files /dev/null and b/assets/images/banner-readme-dark.png differ diff --git a/assets/images/banner-readme-light.png b/assets/images/banner-readme-light.png new file mode 100644 index 00000000..2aaaba69 Binary files /dev/null and b/assets/images/banner-readme-light.png differ diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..b5680d4b --- /dev/null +++ b/cliff.toml @@ -0,0 +1,103 @@ +# git-cliff configuration for SpotiFLAC Mobile +# https://git-cliff.org/docs/configuration + +[changelog] +# Template for the changelog body +body = """ +{%- macro remote_url() -%} + https://github.com/zarzet/SpotiFLAC-Mobile +{%- endmacro -%} + +{% if version %}\ + ## {{ version | trim_start_matches(pat="v") }} +{% else %}\ + ## Unreleased +{% endif %}\ + +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\ + {{ commit.message | upper_first }}\ + {% if commit.github.pr_number %} \ + ([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\ + {% endif %}\ + {%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %} + {%- endfor %} +{% endfor %} + +{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + + ### New Contributors +{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} + * @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} in \ + [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ + {%- endif %} +{%- endfor %} +{%- endif -%} + +{% if version %} + {% if previous.version %} + **Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}) + {% endif %} +{% else -%} + {% raw %}\n{% endraw %} +{% endif %} +""" +# Remove leading and trailing whitespace +trim = true + +[git] +# Parse conventional commits +conventional_commits = true +filter_unconventional = true + +# Process each line of a commit as an individual commit +split_commits = false + +# Regex for preprocessing the commit messages +commit_preprocessors = [ + # Strip conventional commit prefix for cleaner messages + # (group header already shows the type) +] + +# Regex for parsing and grouping commits +commit_parsers = [ + # Skip noise: translation commits from Crowdin + { message = "^New translations", skip = true }, + { message = "^Update source file", skip = true }, + # Skip merge commits + { message = "^Merge", skip = true }, + # Skip version bump commits + { message = "^v\\d+", skip = true }, + { message = "^chore: update VirusTotal", skip = true }, + + # Group by conventional commit type + { message = "^feat", group = "New Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^doc", group = "Documentation" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(deps\\)", group = "Dependencies" }, + { message = "^chore\\(l10n\\)", skip = true }, + { message = "^chore|^ci", group = "Chores" }, +] + +# Protect breaking changes from being skipped +protect_breaking_commits = true + +# Filter out commits by matching patterns +filter_commits = false + +# Tag pattern for version detection +tag_pattern = "v[0-9].*" + +# Sort commits by newest first +sort_commits = "newest" + +[remote.github] +owner = "zarzet" +repo = "SpotiFLAC-Mobile" diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index faf4ccf2..7a906f8e 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -1566,7 +1566,14 @@ func base64StdDecode(dst, src []byte) (int, error) { } func extractAnyCoverArt(filePath string) ([]byte, string, error) { + return extractAnyCoverArtWithHint(filePath, "") +} + +func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) { ext := strings.ToLower(filepath.Ext(filePath)) + if ext == "" { + ext = strings.ToLower(filepath.Ext(displayNameHint)) + } switch ext { case ".flac": @@ -1587,7 +1594,19 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) { return extractOggCoverArt(filePath) case ".m4a": - return nil, "", fmt.Errorf("M4A cover extraction not yet supported") + data, err := extractCoverFromM4A(filePath) + if err != nil { + return nil, "", err + } + mimeType := "image/jpeg" + if len(data) >= 8 && + data[0] == 0x89 && + data[1] == 0x50 && + data[2] == 0x4E && + data[3] == 0x47 { + mimeType = "image/png" + } + return data, mimeType, nil default: return nil, "", fmt.Errorf("unsupported format: %s", ext) @@ -1595,6 +1614,10 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) { } func SaveCoverToCache(filePath, cacheDir string) (string, error) { + return SaveCoverToCacheWithHint(filePath, "", cacheDir) +} + +func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) { cacheKey := filePath if stat, err := os.Stat(filePath); err == nil { cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano()) @@ -1611,7 +1634,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) { return pngPath, nil } - imageData, mimeType, err := extractAnyCoverArt(filePath) + imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint) if err != nil { return "", err } diff --git a/go_backend/cover.go b/go_backend/cover.go index fd0ed822..10c89963 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -104,7 +104,6 @@ func upgradeDeezerCover(coverURL string) string { return coverURL } - // Replace any size pattern with 1800x1800 upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg") if upgraded != coverURL { GoLog("[Cover] Deezer: upgraded to 1800x1800") diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go new file mode 100644 index 00000000..8cbf1f61 --- /dev/null +++ b/go_backend/cue_parser.go @@ -0,0 +1,590 @@ +package gobackend + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// CueSheet represents a parsed .cue file +type CueSheet struct { + // Album-level metadata + Performer string `json:"performer"` + Title string `json:"title"` + FileName string `json:"file_name"` + FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc. + Genre string `json:"genre,omitempty"` + Date string `json:"date,omitempty"` + Comment string `json:"comment,omitempty"` + Composer string `json:"composer,omitempty"` + Tracks []CueTrack `json:"tracks"` +} + +// CueTrack represents a single track in a cue sheet +type CueTrack struct { + Number int `json:"number"` + Title string `json:"title"` + Performer string `json:"performer"` + ISRC string `json:"isrc,omitempty"` + Composer string `json:"composer,omitempty"` + // Index positions in seconds (fractional) + StartTime float64 `json:"start_time"` // INDEX 01 in seconds + PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present) +} + +// CueSplitInfo represents the information needed to split a CUE+audio file +type CueSplitInfo struct { + CuePath string `json:"cue_path"` + AudioPath string `json:"audio_path"` + Album string `json:"album"` + Artist string `json:"artist"` + Genre string `json:"genre,omitempty"` + Date string `json:"date,omitempty"` + Tracks []CueSplitTrack `json:"tracks"` +} + +// CueSplitTrack has the FFmpeg split parameters for a single track +type CueSplitTrack struct { + Number int `json:"number"` + Title string `json:"title"` + Artist string `json:"artist"` + ISRC string `json:"isrc,omitempty"` + Composer string `json:"composer,omitempty"` + StartSec float64 `json:"start_sec"` + EndSec float64 `json:"end_sec"` // -1 means until end of file +} + +var ( + reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`) + reQuoted = regexp.MustCompile(`"([^"]*)"`) +) + +// ParseCueFile parses a .cue file and returns a CueSheet +func ParseCueFile(cuePath string) (*CueSheet, error) { + f, err := os.Open(cuePath) + if err != nil { + return nil, fmt.Errorf("failed to open cue file: %w", err) + } + defer f.Close() + + sheet := &CueSheet{} + var currentTrack *CueTrack + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // Handle BOM at start of file + if strings.HasPrefix(line, "\xef\xbb\xbf") { + line = strings.TrimPrefix(line, "\xef\xbb\xbf") + line = strings.TrimSpace(line) + } + + upper := strings.ToUpper(line) + + // REM commands (album-level metadata) + if strings.HasPrefix(upper, "REM ") { + matches := reRemCommand.FindStringSubmatch(line) + if len(matches) == 3 { + key := strings.ToUpper(matches[1]) + value := unquoteCue(matches[2]) + switch key { + case "GENRE": + sheet.Genre = value + case "DATE": + sheet.Date = value + case "COMMENT": + sheet.Comment = value + case "COMPOSER": + if currentTrack != nil { + currentTrack.Composer = value + } else { + sheet.Composer = value + } + } + } + continue + } + + if strings.HasPrefix(upper, "PERFORMER ") { + value := unquoteCue(line[len("PERFORMER "):]) + if currentTrack != nil { + currentTrack.Performer = value + } else { + sheet.Performer = value + } + continue + } + + if strings.HasPrefix(upper, "TITLE ") { + value := unquoteCue(line[len("TITLE "):]) + if currentTrack != nil { + currentTrack.Title = value + } else { + sheet.Title = value + } + continue + } + + if strings.HasPrefix(upper, "FILE ") { + rest := line[len("FILE "):] + // Extract filename and type + // Format: FILE "filename.flac" WAVE + // or: FILE filename.flac WAVE + fname, ftype := parseCueFileLine(rest) + sheet.FileName = fname + sheet.FileType = ftype + continue + } + + if strings.HasPrefix(upper, "TRACK ") { + // Save previous track + if currentTrack != nil { + sheet.Tracks = append(sheet.Tracks, *currentTrack) + } + + parts := strings.Fields(line) + trackNum := 0 + if len(parts) >= 2 { + trackNum, _ = strconv.Atoi(parts[1]) + } + + currentTrack = &CueTrack{ + Number: trackNum, + PreGap: -1, + } + continue + } + + if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil { + parts := strings.Fields(line) + if len(parts) >= 3 { + indexNum, _ := strconv.Atoi(parts[1]) + timeSec := parseCueTimestamp(parts[2]) + switch indexNum { + case 0: + currentTrack.PreGap = timeSec + case 1: + currentTrack.StartTime = timeSec + } + } + continue + } + + if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil { + currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):]) + continue + } + + // SONGWRITER (used as composer sometimes) + if strings.HasPrefix(upper, "SONGWRITER ") { + value := unquoteCue(line[len("SONGWRITER "):]) + if currentTrack != nil { + currentTrack.Composer = value + } else { + sheet.Composer = value + } + continue + } + } + + // Don't forget the last track + if currentTrack != nil { + sheet.Tracks = append(sheet.Tracks, *currentTrack) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading cue file: %w", err) + } + + if len(sheet.Tracks) == 0 { + return nil, fmt.Errorf("no tracks found in cue file") + } + + return sheet, nil +} + +// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds +func parseCueTimestamp(ts string) float64 { + parts := strings.Split(ts, ":") + if len(parts) != 3 { + return 0 + } + + minutes, _ := strconv.Atoi(parts[0]) + seconds, _ := strconv.Atoi(parts[1]) + frames, _ := strconv.Atoi(parts[2]) + + return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0 +} + +// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg +func formatCueTimestamp(seconds float64) string { + if seconds < 0 { + return "0" + } + hours := int(seconds) / 3600 + mins := (int(seconds) % 3600) / 60 + secs := seconds - float64(hours*3600) - float64(mins*60) + return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs) +} + +// unquoteCue removes surrounding quotes from a CUE value +func unquoteCue(s string) string { + s = strings.TrimSpace(s) + if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 { + return matches[1] + } + return s +} + +// parseCueFileLine parses the FILE command's filename and type +func parseCueFileLine(rest string) (string, string) { + rest = strings.TrimSpace(rest) + + var filename, ftype string + + if strings.HasPrefix(rest, "\"") { + // Quoted filename + endQuote := strings.Index(rest[1:], "\"") + if endQuote >= 0 { + filename = rest[1 : endQuote+1] + remaining := strings.TrimSpace(rest[endQuote+2:]) + ftype = remaining + } else { + filename = rest + } + } else { + // Unquoted filename - last word is the type + parts := strings.Fields(rest) + if len(parts) >= 2 { + ftype = parts[len(parts)-1] + filename = strings.Join(parts[:len(parts)-1], " ") + } else if len(parts) == 1 { + filename = parts[0] + } + } + + return filename, strings.TrimSpace(ftype) +} + +// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet. +// It checks relative to the cue file's directory. +func ResolveCueAudioPath(cuePath string, cueFileName string) string { + cueDir := filepath.Dir(cuePath) + + // 1. Try the exact filename from the .cue + candidate := filepath.Join(cueDir, cueFileName) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + + // 2. Try common case variations + baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName)) + commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"} + for _, ext := range commonExts { + candidate = filepath.Join(cueDir, baseName+ext) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + // Try uppercase ext + candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext)) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + + // 3. Try to find any audio file with the same base name as the .cue file + cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath)) + for _, ext := range commonExts { + candidate = filepath.Join(cueDir, cueBase+ext) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + + // 4. If there's only one audio file in the directory, use that + entries, err := os.ReadDir(cueDir) + if err == nil { + audioExts := map[string]bool{ + ".flac": true, ".wav": true, ".ape": true, ".mp3": true, + ".ogg": true, ".wv": true, ".m4a": true, ".aiff": true, + } + var audioFiles []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + if audioExts[ext] { + audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name())) + } + } + if len(audioFiles) == 1 { + return audioFiles[0] + } + } + + return "" +} + +// BuildCueSplitInfo creates the split information from a parsed CUE sheet. +// This is returned to the Dart side so FFmpeg can perform the splitting. +// audioDir, if non-empty, overrides the directory for audio file resolution. +func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) { + resolveDir := cuePath + if audioDir != "" { + // Create a virtual path in audioDir so ResolveCueAudioPath looks there + resolveDir = filepath.Join(audioDir, filepath.Base(cuePath)) + } + audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName) + if audioPath == "" { + return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName) + } + + info := &CueSplitInfo{ + CuePath: cuePath, + AudioPath: audioPath, + Album: sheet.Title, + Artist: sheet.Performer, + Genre: sheet.Genre, + Date: sheet.Date, + } + + for i, track := range sheet.Tracks { + performer := track.Performer + if performer == "" { + performer = sheet.Performer + } + + composer := track.Composer + if composer == "" { + composer = sheet.Composer + } + + // End time is the start of the next track, or -1 for the last track + endSec := float64(-1) + if i+1 < len(sheet.Tracks) { + nextTrack := sheet.Tracks[i+1] + // Use pre-gap of next track if available, otherwise its start time + if nextTrack.PreGap >= 0 { + endSec = nextTrack.PreGap + } else { + endSec = nextTrack.StartTime + } + } + + info.Tracks = append(info.Tracks, CueSplitTrack{ + Number: track.Number, + Title: track.Title, + Artist: performer, + ISRC: track.ISRC, + Composer: composer, + StartSec: track.StartTime, + EndSec: endSec, + }) + } + + return info, nil +} + +// ParseCueFileJSON parses a .cue file and returns JSON with split info. +// This is the main entry point called from Dart via the platform bridge. +// audioDir, if non-empty, overrides the directory used for resolving the +// referenced audio file (useful when the .cue was copied to a temp dir +// but the audio still lives in the original location, e.g. SAF). +func ParseCueFileJSON(cuePath string, audioDir string) (string, error) { + sheet, err := ParseCueFile(cuePath) + if err != nil { + return "", fmt.Errorf("failed to parse cue file: %w", err) + } + + info, err := BuildCueSplitInfo(cuePath, sheet, audioDir) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(info) + if err != nil { + return "", fmt.Errorf("failed to marshal cue split info: %w", err) + } + + return string(jsonBytes), nil +} + +// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult +// entries, one per track. This is used by the library scanner to populate the +// library with individual track entries from a single CUE+FLAC album. +func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) { + sheet, err := ParseCueFile(cuePath) + if err != nil { + return nil, err + } + audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "") + if err != nil { + return nil, err + } + return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime) +} + +// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters +// for SAF (Storage Access Framework) scenarios: +// - audioDir: if non-empty, overrides the directory used to find the audio file +// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for +// virtual file paths (e.g. a content:// URI). IDs are also based on this. +// - fileModTime: if > 0, used as the FileModTime for all results instead of +// stat-ing the cuePath on disk (useful when the real file lives behind SAF) +func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { + sheet, err := ParseCueFile(cuePath) + if err != nil { + return nil, err + } + audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir) + if err != nil { + return nil, err + } + return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime) +} + +func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) { + if sheet == nil { + return "", fmt.Errorf("cue sheet is nil for %s", cuePath) + } + resolveBase := cuePath + if audioDir != "" { + resolveBase = filepath.Join(audioDir, filepath.Base(cuePath)) + } + audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName) + if audioPath == "" { + return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName) + } + return audioPath, nil +} + +func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { + if sheet == nil { + return nil, fmt.Errorf("cue sheet is nil for %s", cuePath) + } + + // Try to get quality info from the audio file + var bitDepth, sampleRate int + var totalDurationSec float64 + audioExt := strings.ToLower(filepath.Ext(audioPath)) + switch audioExt { + case ".flac": + quality, qErr := GetAudioQuality(audioPath) + if qErr == nil { + bitDepth = quality.BitDepth + sampleRate = quality.SampleRate + if quality.SampleRate > 0 && quality.TotalSamples > 0 { + totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate) + } + } + case ".mp3": + quality, qErr := GetMP3Quality(audioPath) + if qErr == nil { + sampleRate = quality.SampleRate + totalDurationSec = float64(quality.Duration) + } + } + + // Extract cover from audio file for all tracks + var coverPath string + libraryCoverCacheMu.RLock() + coverCacheDir := libraryCoverCacheDir + libraryCoverCacheMu.RUnlock() + if coverCacheDir != "" { + cp, err := SaveCoverToCache(audioPath, coverCacheDir) + if err == nil && cp != "" { + coverPath = cp + } + } + + // Determine the base path for virtual paths and IDs + pathBase := cuePath + if virtualPathPrefix != "" { + pathBase = virtualPathPrefix + } + + // Determine fileModTime + modTime := fileModTime + if modTime <= 0 { + if info, err := os.Stat(cuePath); err == nil { + modTime = info.ModTime().UnixMilli() + } + } + + var results []LibraryScanResult + for i, track := range sheet.Tracks { + performer := track.Performer + if performer == "" { + performer = sheet.Performer + } + if performer == "" { + performer = "Unknown Artist" + } + + title := track.Title + if title == "" { + title = fmt.Sprintf("Track %02d", track.Number) + } + + album := sheet.Title + if album == "" { + album = "Unknown Album" + } + + // Calculate duration for this track + var duration int + if i+1 < len(sheet.Tracks) { + nextStart := sheet.Tracks[i+1].StartTime + if sheet.Tracks[i+1].PreGap >= 0 { + nextStart = sheet.Tracks[i+1].PreGap + } + duration = int(nextStart - track.StartTime) + } else if totalDurationSec > 0 { + duration = int(totalDurationSec - track.StartTime) + } + + id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number)) + + // Use a virtual file path that includes the track number to ensure + // uniqueness in the database (file_path has a UNIQUE constraint). + // Format: /path/to/album.cue#track01 or content://...album.cue#track01 + virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number) + + result := LibraryScanResult{ + ID: id, + TrackName: title, + ArtistName: performer, + AlbumName: album, + AlbumArtist: sheet.Performer, + FilePath: virtualFilePath, + CoverPath: coverPath, + ScannedAt: scanTime, + ISRC: track.ISRC, + TrackNumber: track.Number, + DiscNumber: 1, + Duration: duration, + ReleaseDate: sheet.Date, + BitDepth: bitDepth, + SampleRate: sampleRate, + Genre: sheet.Genre, + Format: "cue+" + strings.TrimPrefix(audioExt, "."), + } + + result.FileModTime = modTime + + results = append(results, result) + } + + return results, nil +} diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 568e61c4..a389f3c8 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -256,6 +256,7 @@ type deezerAlbumFull struct { NbTracks int `json:"nb_tracks"` RecordType string `json:"record_type"` Label string `json:"label"` + Copyright string `json:"copyright"` Genres struct { Data []deezerGenre `json:"data"` } `json:"genres"` @@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { } type AlbumExtendedMetadata struct { - Genre string - Label string + Genre string + Label string + Copyright string } func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { @@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str } result := &AlbumExtendedMetadata{ - Genre: strings.Join(genres, ", "), - Label: album.Label, + Genre: strings.Join(genres, ", "), + Label: album.Label, + Copyright: album.Copyright, } c.cacheMu.Lock() @@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() - GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label) + GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright) return result, nil } diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index 01e5c654..974ffcf6 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { } } if deezerID != "" { - return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID) + if err := verifyDeezerTrack(req, deezerID); err != nil { + GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err) + // Don't reject direct IDs from request payload — they're presumably correct. + } + return trackURL, nil } - // Try resolving Deezer ID from Spotify ID via SongLink + // Try SongLink spotifyID := strings.TrimSpace(req.SpotifyID) if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) { songlink := NewSongLinkClient() availability, err := songlink.CheckTrackAvailability(spotifyID, "") if err == nil && availability.Deezer && availability.DeezerURL != "" { - return availability.DeezerURL, nil + resolvedID := extractDeezerIDFromURL(availability.DeezerURL) + if resolvedID != "" { + if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { + GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr) + // Fall through to ISRC search instead of using wrong track. + } else { + return availability.DeezerURL, nil + } + } else { + return availability.DeezerURL, nil + } } } - // Try resolving from ISRC + // Try ISRC isrc := strings.TrimSpace(req.ISRC) if isrc != "" { ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) defer cancel() track, err := GetDeezerClient().SearchByISRC(ctx, isrc) if err == nil && track != nil { - deezerID = songLinkExtractDeezerTrackID(track) - if deezerID != "" { - return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + resolvedID := songLinkExtractDeezerTrackID(track) + if resolvedID != "" { + if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { + GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr) + return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr) + } + return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil } } } @@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { return "", fmt.Errorf("could not resolve Deezer track URL") } +func verifyDeezerTrack(req DownloadRequest, deezerID string) error { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID) + if err != nil { + return nil // Can't verify — don't block the download. + } + resolved := resolvedTrackInfo{ + Title: trackResp.Track.Name, + ArtistName: trackResp.Track.Artists, + Duration: trackResp.Track.DurationMS / 1000, + } + if !trackMatchesRequest(req, resolved, "Deezer") { + return fmt.Errorf("expected '%s - %s', got '%s - %s'", + req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title) + } + GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title) + return nil +} + type deezerMusicDLRequest struct { Platform string `json:"platform"` URL string `json:"url"` @@ -394,11 +433,6 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { } } - spotifyURL, err := resolveSpotifyURLForYoinkify(req) - if err != nil { - return DeezerDownloadResult{}, err - } - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ "title": req.TrackName, "artist": req.ArtistName, @@ -461,6 +495,17 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { } if downloadErr != nil || deezerURLErr != nil { + spotifyURL, err := resolveSpotifyURLForYoinkify(req) + if err != nil { + if deezerURLErr != nil { + return DeezerDownloadResult{}, fmt.Errorf( + "deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w", + deezerURLErr, + err, + ) + } + return DeezerDownloadResult{}, err + } downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID) if downloadErr != nil { if errors.Is(downloadErr, ErrDownloadCancelled) { diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go index cd922285..30b68551 100644 --- a/go_backend/duplicate.go +++ b/go_backend/duplicate.go @@ -34,7 +34,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex { return idx } - // Slow path: need to build index // Use per-directory mutex to prevent multiple goroutines from building simultaneously buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{}) mu := buildLock.(*sync.Mutex) diff --git a/go_backend/exports.go b/go_backend/exports.go index 08efb1fb..39184350 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -32,126 +32,6 @@ func ParseSpotifyURL(url string) (string, error) { return string(jsonBytes), nil } -func SetSpotifyAPICredentials(clientID, clientSecret string) { - SetSpotifyCredentials(clientID, clientSecret) -} - -func CheckSpotifyCredentials() bool { - return HasSpotifyCredentials() -} - -func GetSpotifyMetadata(spotifyURL string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - client, err := NewSpotifyMetadataClient() - if err != nil { - if shouldTrySpotFetchFallback(err) { - data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL) - if apiErr == nil { - jsonBytes, marshalErr := json.Marshal(data) - if marshalErr != nil { - return "", marshalErr - } - return string(jsonBytes), nil - } - } - return "", err - } - data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) - if err != nil { - if shouldTrySpotFetchFallback(err) { - fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL) - if apiErr == nil { - jsonBytes, marshalErr := json.Marshal(fallbackData) - if marshalErr != nil { - return "", marshalErr - } - return string(jsonBytes), nil - } - } - return "", err - } - - jsonBytes, err := json.Marshal(data) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func SearchSpotify(query string, limit int) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - client, err := NewSpotifyMetadataClient() - if err != nil { - return "", err - } - results, err := client.SearchTracks(ctx, query, limit) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(results) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - client, err := NewSpotifyMetadataClient() - if err != nil { - return "", err - } - results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(results) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - - client, err := NewSpotifyMetadataClient() - if err != nil { - return "", err - } - - normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:")) - if normalizedArtistID == "" { - return "", fmt.Errorf("invalid Spotify artist ID") - } - - artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit) - if err != nil { - return "", err - } - - resp := map[string]interface{}{ - "artists": artists, - } - jsonBytes, err := json.Marshal(resp) - if err != nil { - return "", err - } - return string(jsonBytes), nil -} - func CheckAvailability(spotifyID, isrc string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) @@ -248,6 +128,7 @@ type DownloadResult struct { TrackNumber int DiscNumber int ISRC string + CoverURL string Genre string Label string Copyright string @@ -255,6 +136,36 @@ type DownloadResult struct { DecryptionKey string } +func preferredReleaseMetadata( + req DownloadRequest, + album string, + releaseDate string, + trackNumber int, + discNumber int, +) (string, string, int, int) { + preferredAlbum := strings.TrimSpace(req.AlbumName) + if preferredAlbum == "" { + preferredAlbum = album + } + + preferredReleaseDate := strings.TrimSpace(req.ReleaseDate) + if preferredReleaseDate == "" { + preferredReleaseDate = releaseDate + } + + preferredTrackNumber := req.TrackNumber + if preferredTrackNumber == 0 { + preferredTrackNumber = trackNumber + } + + preferredDiscNumber := req.DiscNumber + if preferredDiscNumber == 0 { + preferredDiscNumber = discNumber + } + + return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber +} + func buildDownloadSuccessResponse( req DownloadRequest, result DownloadResult, @@ -273,25 +184,16 @@ func buildDownloadSuccessResponse( artist = req.ArtistName } - album := result.Album - if album == "" { - album = req.AlbumName - } - - releaseDate := result.ReleaseDate - if releaseDate == "" { - releaseDate = req.ReleaseDate - } - - trackNumber := result.TrackNumber - if trackNumber == 0 { - trackNumber = req.TrackNumber - } - - discNumber := result.DiscNumber - if discNumber == 0 { - discNumber = req.DiscNumber - } + // Preserve requested release metadata when available so mixed-provider + // fallback downloads from the same source album do not get split into + // different albums just because Tidal/Qobuz report variant titles/dates. + album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata( + req, + result.Album, + result.ReleaseDate, + result.TrackNumber, + result.DiscNumber, + ) isrc := result.ISRC if isrc == "" { @@ -313,6 +215,11 @@ func buildDownloadSuccessResponse( copyright = req.Copyright } + coverURL := strings.TrimSpace(result.CoverURL) + if coverURL == "" { + coverURL = strings.TrimSpace(req.CoverURL) + } + return DownloadResponse{ Success: true, Message: message, @@ -329,7 +236,7 @@ func buildDownloadSuccessResponse( TrackNumber: trackNumber, DiscNumber: discNumber, ISRC: isrc, - CoverURL: req.CoverURL, + CoverURL: coverURL, Genre: genre, Label: label, Copyright: copyright, @@ -382,7 +289,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { return } - if req.ISRC == "" || (req.Genre != "" && req.Label != "") { + if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") { return } @@ -404,8 +311,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { if req.Label == "" && extMeta.Label != "" { req.Label = extMeta.Label } - if req.Genre != "" || req.Label != "" { - GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label) + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + } + if req.Genre != "" || req.Label != "" || req.Copyright != "" { + GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright) } } @@ -474,6 +384,7 @@ func DownloadTrack(requestJSON string) (string, error) { TrackNumber: qobuzResult.TrackNumber, DiscNumber: qobuzResult.DiscNumber, ISRC: qobuzResult.ISRC, + CoverURL: qobuzResult.CoverURL, LyricsLRC: qobuzResult.LyricsLRC, } } @@ -682,6 +593,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { TrackNumber: qobuzResult.TrackNumber, DiscNumber: qobuzResult.DiscNumber, ISRC: qobuzResult.ISRC, + CoverURL: qobuzResult.CoverURL, LyricsLRC: qobuzResult.LyricsLRC, } } else if !errors.Is(qobuzErr, ErrDownloadCancelled) { @@ -835,6 +747,26 @@ func ReadFileMetadata(filePath string) (string, error) { } } } else if isM4A { + meta, err := ReadM4ATags(filePath) + if err == nil && meta != nil { + result["title"] = meta.Title + result["artist"] = meta.Artist + result["album"] = meta.Album + result["album_artist"] = meta.AlbumArtist + result["date"] = meta.Date + if meta.Date == "" { + result["date"] = meta.Year + } + result["track_number"] = meta.TrackNumber + result["disc_number"] = meta.DiscNumber + result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics + result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright + result["composer"] = meta.Composer + result["comment"] = meta.Comment + } quality, qualityErr := GetM4AQuality(filePath) if qualityErr == nil { result["bit_depth"] = quality.BitDepth @@ -901,6 +833,32 @@ func ReadFileMetadata(filePath string) (string, error) { return string(jsonBytes), nil } +// ParseCueSheet parses a .cue file and returns JSON with split information. +// This is called from Dart to get track listing and timing data for CUE splitting. +// audioDir, if non-empty, overrides the directory used for resolving the +// referenced audio file (useful for SAF temp file scenarios). +func ParseCueSheet(cuePath string, audioDir string) (string, error) { + return ParseCueFileJSON(cuePath, audioDir) +} + +// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of +// LibraryScanResult entries (one per track). This is the SAF-friendly variant: +// - audioDir overrides where the referenced audio file is resolved +// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI) +// - fileModTime is stamped on every result (pass 0 to stat cuePath instead) +func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) { + scanTime := time.Now().UTC().Format(time.RFC3339) + results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime) + if err != nil { + return "[]", err + } + jsonBytes, err := json.Marshal(results) + if err != nil { + return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err) + } + return string(jsonBytes), nil +} + // EditFileMetadata writes metadata to an audio file. // For FLAC files, uses native Go FLAC library. // For MP3/Opus, returns the metadata map so Dart can use FFmpeg. @@ -1197,6 +1155,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) ( return string(jsonBytes), nil } +func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) { + downloader := NewTidalDownloader() + results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(results) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) { + downloader := NewQobuzDownloader() + results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(results) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func GetDeezerRelatedArtists(artistID string, limit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() @@ -1250,6 +1238,66 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) { return string(jsonBytes), nil } +func GetQobuzMetadata(resourceType, resourceID string) (string, error) { + downloader := NewQobuzDownloader() + + var data interface{} + var err error + + switch resourceType { + case "track": + data, err = downloader.GetTrackMetadata(resourceID) + case "album": + data, err = downloader.GetAlbumMetadata(resourceID) + case "artist": + data, err = downloader.GetArtistMetadata(resourceID) + case "playlist": + data, err = downloader.GetPlaylistMetadata(resourceID) + default: + return "", fmt.Errorf("unsupported Qobuz resource type: %s", resourceType) + } + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func GetTidalMetadata(resourceType, resourceID string) (string, error) { + downloader := NewTidalDownloader() + + var data interface{} + var err error + + switch resourceType { + case "track": + data, err = downloader.GetTrackMetadata(resourceID) + case "album": + data, err = downloader.GetAlbumMetadata(resourceID) + case "artist": + data, err = downloader.GetArtistMetadata(resourceID) + case "playlist": + data, err = downloader.GetPlaylistMetadata(resourceID) + default: + return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType) + } + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func ParseDeezerURLExport(url string) (string, error) { resourceType, resourceID, err := parseDeezerURL(url) if err != nil { @@ -1269,6 +1317,25 @@ func ParseDeezerURLExport(url string) (string, error) { return string(jsonBytes), nil } +func ParseQobuzURLExport(url string) (string, error) { + resourceType, resourceID, err := parseQobuzURL(url) + if err != nil { + return "", err + } + + result := map[string]string{ + "type": resourceType, + "id": resourceID, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func ParseTidalURLExport(url string) (string, error) { resourceType, resourceID, err := parseTidalURL(url) if err != nil { @@ -1329,10 +1396,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) { return "", err } - result := map[string]string{ - "genre": metadata.Genre, - "label": metadata.Label, - } + result := buildDeezerExtendedMetadataResult(metadata) jsonBytes, err := json.Marshal(result) if err != nil { @@ -1352,7 +1416,8 @@ func SearchDeezerByISRC(isrc string) (string, error) { return "", err } - jsonBytes, err := json.Marshal(track) + result := buildDeezerISRCSearchResult(track) + jsonBytes, err := json.Marshal(result) if err != nil { return "", err } @@ -1360,6 +1425,55 @@ func SearchDeezerByISRC(isrc string) (string, error) { return string(jsonBytes), nil } +func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string { + if metadata == nil { + return map[string]string{ + "genre": "", + "label": "", + "copyright": "", + } + } + + return map[string]string{ + "genre": metadata.Genre, + "label": metadata.Label, + "copyright": metadata.Copyright, + } +} + +func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} { + if track == nil { + return map[string]interface{}{} + } + + result := map[string]interface{}{ + "spotify_id": track.SpotifyID, + "artists": track.Artists, + "name": track.Name, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.Images, + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "external_urls": track.ExternalURL, + "isrc": track.ISRC, + "album_id": track.AlbumID, + "artist_id": track.ArtistID, + "album_type": track.AlbumType, + } + + if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" { + result["id"] = deezerID + result["track_id"] = deezerID + result["success"] = true + } + + return result +} + func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -1405,7 +1519,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { return string(jsonBytes), nil } - // For artists/playlists, SongLink doesn't provide direct mapping return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType) } @@ -1413,28 +1526,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - var spotifyErr error - - client, err := NewSpotifyMetadataClient() - if err != nil { - LogWarn("Spotify", "Credentials not configured, falling back to Deezer") - spotifyErr = err - } else { - data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) - if err == nil { - jsonBytes, err := json.Marshal(data) - if err != nil { - return "", err - } - return string(jsonBytes), nil - } - - spotifyErr = err - if !shouldTrySpotFetchFallback(err) { - return "", err - } - } - spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL) if apiErr == nil { GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n") @@ -1448,9 +1539,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { parsed, parseErr := parseSpotifyURI(spotifyURL) if parseErr != nil { - if spotifyErr != nil { - return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr) - } return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr) } @@ -1461,15 +1549,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { } if parsed.Type == "artist" { - if spotifyErr != nil { - return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr) - } - return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr) + return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr) } - if spotifyErr != nil { - return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr) - } return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr) } @@ -1670,6 +1752,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error { if strings.HasSuffix(lower, ".flac") { coverData, err = ExtractCoverArt(audioPath) + } else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") { + coverData, err = extractCoverFromM4A(audioPath) } else if strings.HasSuffix(lower, ".mp3") { coverData, _, err = extractMP3CoverArt(audioPath) } else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") { @@ -1800,114 +1884,58 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath) - // When search_online is true, search for metadata from internet - // Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated) + // When search_online is true, search for metadata from internet using the + // configured metadata-provider priority. if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" { GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName) searchQuery := req.TrackName + " " + req.ArtistName found := false - // 1) Try Deezer first (reliable, no credentials needed) - GoLog("[ReEnrich] Trying Deezer search...\n") deezerClient := GetDeezerClient() - { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track") - cancel() - if err == nil && len(deezerResults.Tracks) > 0 { - track := deezerResults.Tracks[0] - GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName) - req.SpotifyID = "deezer:" + track.SpotifyID - req.AlbumName = track.AlbumName - req.AlbumArtist = track.AlbumArtist - req.TrackNumber = track.TrackNumber - req.DiscNumber = track.DiscNumber - req.ReleaseDate = track.ReleaseDate - req.ISRC = track.ISRC - if track.Images != "" { - req.CoverURL = track.Images - } - req.DurationMs = int64(track.DurationMS) - found = true - } else if err != nil { - GoLog("[ReEnrich] Deezer search failed: %v\n", err) - } - } - - // 2) Try extension metadata providers (spotify-web etc) if Deezer failed - if !found { - GoLog("[ReEnrich] Trying extension metadata providers...\n") - manager := GetExtensionManager() - extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5) - if extErr == nil && len(extTracks) > 0 { - track := extTracks[0] - GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName) - if track.SpotifyID != "" { - req.SpotifyID = track.SpotifyID - } else if track.DeezerID != "" { - req.SpotifyID = "deezer:" + track.DeezerID - } else { - req.SpotifyID = track.ID - } - req.AlbumName = track.AlbumName - req.AlbumArtist = track.AlbumArtist - req.TrackNumber = track.TrackNumber - req.DiscNumber = track.DiscNumber - req.ReleaseDate = track.ReleaseDate - req.ISRC = track.ISRC - coverURL := track.ResolvedCoverURL() - if coverURL != "" { - req.CoverURL = coverURL - } - req.DurationMs = int64(track.DurationMS) - if track.Genre != "" { - req.Genre = track.Genre - } - if track.Label != "" { - req.Label = track.Label - } - if track.Copyright != "" { - req.Copyright = track.Copyright - } - found = true - } else if extErr != nil { - GoLog("[ReEnrich] Extension search failed: %v\n", extErr) - } - } - - // 3) Try Spotify built-in API as last resort (will be deprecated) - if !found { - GoLog("[ReEnrich] Trying Spotify API (fallback)...\n") - spotifyClient, spotifyErr := NewSpotifyMetadataClient() - if spotifyErr == nil { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5) - cancel() - if err == nil && len(results.Tracks) > 0 { - track := results.Tracks[0] - GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName) - req.SpotifyID = track.SpotifyID - req.AlbumName = track.AlbumName - req.AlbumArtist = track.AlbumArtist - req.TrackNumber = track.TrackNumber - req.DiscNumber = track.DiscNumber - req.ReleaseDate = track.ReleaseDate - req.ISRC = track.ISRC - if track.Images != "" { - req.CoverURL = track.Images - } - req.DurationMs = int64(track.DurationMS) - found = true - } else if err != nil { - GoLog("[ReEnrich] Spotify search failed: %v\n", err) - } + GoLog("[ReEnrich] Trying metadata providers in configured priority...\n") + manager := GetExtensionManager() + tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true) + if searchErr == nil && len(tracks) > 0 { + track := tracks[0] + GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName) + if track.SpotifyID != "" { + req.SpotifyID = track.SpotifyID + } else if track.DeezerID != "" { + req.SpotifyID = "deezer:" + track.DeezerID + } else if track.QobuzID != "" { + req.SpotifyID = "qobuz:" + track.QobuzID + } else if track.TidalID != "" { + req.SpotifyID = "tidal:" + track.TidalID } else { - GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr) + req.SpotifyID = track.ID } + req.AlbumName = track.AlbumName + req.AlbumArtist = track.AlbumArtist + req.TrackNumber = track.TrackNumber + req.DiscNumber = track.DiscNumber + req.ReleaseDate = track.ReleaseDate + req.ISRC = track.ISRC + coverURL := track.ResolvedCoverURL() + if coverURL != "" { + req.CoverURL = coverURL + } + req.DurationMs = int64(track.DurationMS) + if track.Genre != "" { + req.Genre = track.Genre + } + if track.Label != "" { + req.Label = track.Label + } + if track.Copyright != "" { + req.Copyright = track.Copyright + } + found = true + } else if searchErr != nil { + GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr) } - // Try to get extended metadata (genre, label) from Deezer if not already set - if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") { + // Try to get extended metadata from Deezer if not already set + if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC) cancel() @@ -1918,7 +1946,10 @@ func ReEnrichFile(requestJSON string) (string, error) { if req.Label == "" && extMeta.Label != "" { req.Label = extMeta.Label } - GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label) + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + } + GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright) } } @@ -1987,8 +2018,15 @@ func ReEnrichFile(requestJSON string) (string, error) { } }() - // Fetch lyrics + // Preserve existing lyrics when online enrichment does not return a replacement. var lyricsLRC string + existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath) + if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" { + lyricsLRC = existingLyrics + GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n") + } + + // Fetch lyrics if req.EmbedLyrics { client := NewLyricsClient() durationSec := float64(req.DurationMs) / 1000.0 @@ -2069,7 +2107,6 @@ func ReEnrichFile(requestJSON string) (string, error) { return string(jsonBytes), nil } - // MP3/Opus: return metadata map for Dart to use FFmpeg // Don't cleanup cover temp — Dart needs it for FFmpeg embed cleanupCover = false result := map[string]interface{}{ @@ -2305,6 +2342,21 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { return string(jsonBytes), nil } +func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) { + manager := GetExtensionManager() + tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(tracks) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func DownloadWithExtensionsJSON(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { @@ -2690,6 +2742,28 @@ func HandleURLWithExtensionJSON(url string) (string, error) { artistResponse["albums"] = albums } + if len(result.Artist.Releases) > 0 { + releases := make([]map[string]interface{}, len(result.Artist.Releases)) + for i, release := range result.Artist.Releases { + releaseType := release.AlbumType + if releaseType == "" { + releaseType = "album" + } + releases[i] = map[string]interface{}{ + "id": release.ID, + "name": release.Name, + "artists": release.Artists, + "images": release.CoverURL, + "cover_url": release.CoverURL, + "release_date": release.ReleaseDate, + "total_tracks": release.TotalTracks, + "album_type": releaseType, + "provider_id": release.ProviderID, + } + } + artistResponse["releases"] = releases + } + if len(result.Artist.TopTracks) > 0 { topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks)) for i, track := range result.Artist.TopTracks { @@ -2939,6 +3013,27 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { "provider_id": artist.ProviderID, } + if len(artist.Releases) > 0 { + releases := make([]map[string]interface{}, len(artist.Releases)) + for i, release := range artist.Releases { + releaseType := release.AlbumType + if releaseType == "" { + releaseType = "album" + } + releases[i] = map[string]interface{}{ + "id": release.ID, + "name": release.Name, + "artists": release.Artists, + "cover_url": release.CoverURL, + "release_date": release.ReleaseDate, + "total_tracks": release.TotalTracks, + "album_type": releaseType, + "provider_id": release.ProviderID, + } + } + response["releases"] = releases + } + if artist.HeaderImage != "" { response["header_image"] = artist.HeaderImage } @@ -3086,6 +3181,45 @@ func InitExtensionStoreJSON(cacheDir string) error { return nil } +func SetStoreRegistryURLJSON(registryURL string) error { + store := GetExtensionStore() + if store == nil { + return fmt.Errorf("extension store not initialized") + } + + resolved, err := ResolveRegistryURL(registryURL) + if err != nil { + return err + } + + if err := requireHTTPSURL(resolved, "registry"); err != nil { + return err + } + + store.SetRegistryURL(resolved) + return nil +} + +func ClearStoreRegistryURLJSON() error { + store := GetExtensionStore() + if store == nil { + return fmt.Errorf("extension store not initialized") + } + + store.SetRegistryURL("") + store.ClearCache() + return nil +} + +func GetStoreRegistryURLJSON() (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + return store.GetRegistryURL(), nil +} + func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { store := GetExtensionStore() if store == nil { @@ -3243,6 +3377,10 @@ func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (str return ScanLibraryFolderIncremental(folderPath, existingFilesJSON) } +func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) { + return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath) +} + func GetLibraryScanProgressJSON() string { return GetLibraryScanProgress() } @@ -3254,3 +3392,7 @@ func CancelLibraryScanJSON() { func ReadAudioMetadataJSON(filePath string) (string, error) { return ReadAudioMetadata(filePath) } + +func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) { + return ReadAudioMetadataWithDisplayName(filePath, displayName) +} diff --git a/go_backend/exports_deezer_metadata_test.go b/go_backend/exports_deezer_metadata_test.go new file mode 100644 index 00000000..763965fc --- /dev/null +++ b/go_backend/exports_deezer_metadata_test.go @@ -0,0 +1,59 @@ +package gobackend + +import "testing" + +func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) { + result := buildDeezerExtendedMetadataResult(nil) + + if result["genre"] != "" { + t.Fatalf("expected empty genre, got %q", result["genre"]) + } + if result["label"] != "" { + t.Fatalf("expected empty label, got %q", result["label"]) + } + if result["copyright"] != "" { + t.Fatalf("expected empty copyright, got %q", result["copyright"]) + } +} + +func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) { + result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{ + Genre: "Rock", + Label: "EMI", + Copyright: "(C) Queen", + }) + + if result["genre"] != "Rock" { + t.Fatalf("unexpected genre: %q", result["genre"]) + } + if result["label"] != "EMI" { + t.Fatalf("unexpected label: %q", result["label"]) + } + if result["copyright"] != "(C) Queen" { + t.Fatalf("unexpected copyright: %q", result["copyright"]) + } +} + +func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) { + result := buildDeezerISRCSearchResult(&TrackMetadata{ + SpotifyID: "deezer:3135556", + Name: "Love Of My Life", + Artists: "Queen", + AlbumName: "A Night at the Opera", + ISRC: "GBUM71029604", + ReleaseDate: "1975-11-21", + }) + + if result["spotify_id"] != "deezer:3135556" { + t.Fatalf("unexpected spotify_id: %v", result["spotify_id"]) + } + if result["id"] != "3135556" { + t.Fatalf("unexpected id: %v", result["id"]) + } + if result["track_id"] != "3135556" { + t.Fatalf("unexpected track_id: %v", result["track_id"]) + } + if result["success"] != true { + t.Fatalf("expected success=true, got %v", result["success"]) + } +} diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go new file mode 100644 index 00000000..44faf294 --- /dev/null +++ b/go_backend/exports_test.go @@ -0,0 +1,115 @@ +package gobackend + +import "testing" + +func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) { + req := DownloadRequest{ + TrackName: "Bonus Track", + ArtistName: "Artist", + AlbumName: "Album (Deluxe)", + AlbumArtist: "Artist", + ReleaseDate: "2024-01-01", + TrackNumber: 14, + DiscNumber: 1, + ISRC: "REQ123", + CoverURL: "https://example.com/cover.jpg", + Genre: "Pop", + Label: "Label", + Copyright: "Copyright", + } + + result := DownloadResult{ + Title: "Bonus Track", + Artist: "Artist", + Album: "Album", + ReleaseDate: "2023-12-01", + TrackNumber: 2, + DiscNumber: 9, + ISRC: "RES456", + } + + resp := buildDownloadSuccessResponse( + req, + result, + "tidal", + "ok", + "/tmp/test.flac", + false, + ) + + if resp.Album != req.AlbumName { + t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName) + } + if resp.ReleaseDate != req.ReleaseDate { + t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate) + } + if resp.TrackNumber != req.TrackNumber { + t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber) + } + if resp.DiscNumber != req.DiscNumber { + t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber) + } + if resp.Artist != result.Artist { + t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist) + } + if resp.ISRC != result.ISRC { + t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC) + } +} + +func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) { + album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata( + DownloadRequest{ + AlbumName: "Album (Deluxe Edition)", + ReleaseDate: "2024-01-01", + TrackNumber: 13, + DiscNumber: 2, + }, + "Album", + "2023-01-01", + 3, + 1, + ) + + if album != "Album (Deluxe Edition)" { + t.Fatalf("album = %q", album) + } + if releaseDate != "2024-01-01" { + t.Fatalf("release date = %q", releaseDate) + } + if trackNumber != 13 { + t.Fatalf("track number = %d", trackNumber) + } + if discNumber != 2 { + t.Fatalf("disc number = %d", discNumber) + } +} + +func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) { + req := DownloadRequest{ + TrackName: "Track", + ArtistName: "Artist", + AlbumName: "Album", + AlbumArtist: "Artist", + } + + result := DownloadResult{ + Title: "Track", + Artist: "Artist", + Album: "Album", + CoverURL: "https://cdn.qobuz.test/cover.jpg", + } + + resp := buildDownloadSuccessResponse( + req, + result, + "qobuz", + "ok", + "/tmp/test.flac", + false, + ) + + if resp.CoverURL != result.CoverURL { + t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL) + } +} diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 5c73e251..d2a1f8d3 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -151,7 +151,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens if exists { versionCompare := compareVersions(manifest.Version, existingVersion) if versionCompare > 0 { - // This is an upgrade - call UpgradeExtension return m.UpgradeExtension(filePath) } else if versionCompare == 0 { return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion) @@ -429,7 +428,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx SourceDir: dirPath, } - // Restore enabled state from settings store store := GetExtensionSettingsStore() if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil { if enabled, ok := enabledVal.(bool); ok { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index a1a419b5..a3e46450 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -70,6 +70,7 @@ type ExtArtistMetadata struct { HeaderImage string `json:"header_image,omitempty"` Listeners int `json:"listeners,omitempty"` Albums []ExtAlbumMetadata `json:"albums,omitempty"` + Releases []ExtAlbumMetadata `json:"releases,omitempty"` TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` ProviderID string `json:"provider_id"` } @@ -327,6 +328,12 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat } artist.ProviderID = p.extension.ID + for i := range artist.Releases { + artist.Releases[i].ProviderID = p.extension.ID + for j := range artist.Releases[i].Tracks { + artist.Releases[i].Tracks[j].ProviderID = p.extension.ID + } + } return &artist, nil } @@ -484,7 +491,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext return &urlResult, nil } -const ExtDownloadTimeout = 5 * time.Minute +const ExtDownloadTimeout = DownloadTimeout func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { if !p.extension.Manifest.IsDownloadProvider() { @@ -600,8 +607,30 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ( return nil, nil } - var allTracks []ExtTrackMetadata + providerByID := make(map[string]*ExtensionProviderWrapper, len(providers)) + orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers)) for _, provider := range providers { + providerByID[provider.extension.ID] = provider + } + for _, providerID := range GetMetadataProviderPriority() { + if provider := providerByID[providerID]; provider != nil { + orderedProviders = append(orderedProviders, provider) + delete(providerByID, providerID) + } + } + if len(providerByID) > 0 { + remainingIDs := make([]string, 0, len(providerByID)) + for providerID := range providerByID { + remainingIDs = append(remainingIDs, providerID) + } + sort.Strings(remainingIDs) + for _, providerID := range remainingIDs { + orderedProviders = append(orderedProviders, providerByID[providerID]) + } + } + + var allTracks []ExtTrackMetadata + for _, provider := range orderedProviders { result, err := provider.SearchTracks(query, limit) if err != nil { GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err) @@ -621,6 +650,8 @@ var providerPriorityMu sync.RWMutex var metadataProviderPriority []string var metadataProviderPriorityMu sync.RWMutex +var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks + func SetProviderPriority(providerIDs []string) { providerPriorityMu.Lock() defer providerPriorityMu.Unlock() @@ -644,8 +675,30 @@ func GetProviderPriority() []string { func SetMetadataProviderPriority(providerIDs []string) { metadataProviderPriorityMu.Lock() defer metadataProviderPriorityMu.Unlock() - metadataProviderPriority = providerIDs - GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs) + + sanitized := make([]string, 0, len(providerIDs)+3) + seen := map[string]struct{}{} + for _, providerID := range providerIDs { + providerID = strings.TrimSpace(providerID) + if providerID == "" || providerID == "spotify" { + continue + } + if _, exists := seen[providerID]; exists { + continue + } + seen[providerID] = struct{}{} + sanitized = append(sanitized, providerID) + } + for _, providerID := range []string{"deezer", "qobuz", "tidal"} { + if _, exists := seen[providerID]; exists { + continue + } + seen[providerID] = struct{}{} + sanitized = append(sanitized, providerID) + } + + metadataProviderPriority = sanitized + GoLog("[Extension] Metadata provider priority set: %v\n", sanitized) } func GetMetadataProviderPriority() []string { @@ -653,7 +706,7 @@ func GetMetadataProviderPriority() []string { defer metadataProviderPriorityMu.RUnlock() if len(metadataProviderPriority) == 0 { - return []string{"deezer", "spotify"} + return []string{"deezer", "qobuz", "tidal"} } result := make([]string, len(metadataProviderPriority)) @@ -670,6 +723,165 @@ func isBuiltInProvider(providerID string) bool { } } +func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata { + deezerID := "" + tidalID := "" + qobuzID := "" + prefixedID := strings.TrimSpace(track.SpotifyID) + + switch providerID { + case "deezer": + deezerID = strings.TrimPrefix(prefixedID, "deezer:") + case "tidal": + tidalID = strings.TrimPrefix(prefixedID, "tidal:") + case "qobuz": + qobuzID = strings.TrimPrefix(prefixedID, "qobuz:") + } + + return ExtTrackMetadata{ + ID: prefixedID, + Name: track.Name, + Artists: track.Artists, + AlbumName: track.AlbumName, + AlbumArtist: track.AlbumArtist, + DurationMS: track.DurationMS, + CoverURL: track.Images, + Images: track.Images, + ReleaseDate: track.ReleaseDate, + TrackNumber: track.TrackNumber, + DiscNumber: track.DiscNumber, + ISRC: track.ISRC, + ProviderID: providerID, + SpotifyID: prefixedID, + DeezerID: deezerID, + TidalID: tidalID, + QobuzID: qobuzID, + AlbumType: track.AlbumType, + } +} + +func metadataTrackDedupKey(track ExtTrackMetadata) string { + if isrc := strings.TrimSpace(track.ISRC); isrc != "" { + return "isrc:" + strings.ToUpper(isrc) + } + if spotifyID := strings.TrimSpace(track.SpotifyID); spotifyID != "" { + return "spotify:" + spotifyID + } + if providerID := strings.TrimSpace(track.ProviderID); providerID != "" && strings.TrimSpace(track.ID) != "" { + return providerID + ":" + strings.TrimSpace(track.ID) + } + return strings.TrimSpace(track.Name) + "|" + strings.TrimSpace(track.Artists) +} + +func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) { + switch providerID { + case "deezer": + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track") + if err != nil { + return nil, err + } + + tracks := make([]ExtTrackMetadata, 0, len(results.Tracks)) + for _, track := range results.Tracks { + tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer")) + } + return tracks, nil + case "qobuz": + return NewQobuzDownloader().SearchTracks(query, limit) + case "tidal": + return NewTidalDownloader().SearchTracks(query, limit) + default: + return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID) + } +} + +func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) { + priority := GetMetadataProviderPriority() + if limit <= 0 { + limit = 20 + } + + extensionProviders := make(map[string]*ExtensionProviderWrapper) + if includeExtensions { + for _, provider := range m.GetMetadataProviders() { + extensionProviders[provider.extension.ID] = provider + } + } + + orderedProviderIDs := make([]string, 0, len(priority)+len(extensionProviders)) + seenProviderIDs := make(map[string]struct{}, len(priority)+len(extensionProviders)) + for _, providerID := range priority { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + continue + } + orderedProviderIDs = append(orderedProviderIDs, providerID) + seenProviderIDs[providerID] = struct{}{} + } + if includeExtensions { + remainingIDs := make([]string, 0, len(extensionProviders)) + for providerID := range extensionProviders { + if _, exists := seenProviderIDs[providerID]; exists { + continue + } + remainingIDs = append(remainingIDs, providerID) + } + sort.Strings(remainingIDs) + orderedProviderIDs = append(orderedProviderIDs, remainingIDs...) + } + + tracks := make([]ExtTrackMetadata, 0, limit) + seenTracks := make(map[string]struct{}) + for _, providerID := range orderedProviderIDs { + var ( + providerTracks []ExtTrackMetadata + err error + ) + + if isBuiltInProvider(providerID) { + providerTracks, err = searchBuiltInMetadataTracksFunc(providerID, query, limit) + } else { + if !includeExtensions { + continue + } + provider := extensionProviders[providerID] + if provider == nil { + continue + } + var result *ExtSearchResult + result, err = provider.SearchTracks(query, limit) + if result != nil { + providerTracks = result.Tracks + } + } + + if err != nil { + GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err) + continue + } + + for _, track := range providerTracks { + key := metadataTrackDedupKey(track) + if key == "" { + continue + } + if _, exists := seenTracks[key]; exists { + continue + } + seenTracks[key] = struct{}{} + tracks = append(tracks, track) + if len(tracks) >= limit { + return tracks, nil + } + } + } + + return tracks, nil +} + func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { priority := GetProviderPriority() extManager := GetExtensionManager() @@ -765,6 +977,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if enrichedTrack.Artists != "" { req.ArtistName = enrichedTrack.Artists } + if enrichedTrack.AlbumName != "" && req.AlbumName == "" { + GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName) + req.AlbumName = enrichedTrack.AlbumName + } + if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" { + req.AlbumArtist = enrichedTrack.AlbumArtist + } + if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 { + GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS) + req.DurationMS = enrichedTrack.DurationMS + } + if enrichedTrack.CoverURL != "" && req.CoverURL == "" { + req.CoverURL = enrichedTrack.CoverURL + } + if enrichedTrack.ID != "" && req.SpotifyID == "" { + GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID) + req.SpotifyID = enrichedTrack.ID + } if enrichedTrack.Label != "" && req.Label == "" { GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label) req.Label = enrichedTrack.Label @@ -785,6 +1015,77 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } + // If key metadata is still missing after extension enrichment, search + // configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same + // logic that ReEnrichFile uses. + if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) && + req.TrackName != "" && req.ArtistName != "" && + (req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") { + + searchQuery := req.TrackName + " " + req.ArtistName + GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery) + + tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true) + if searchErr == nil && len(tracks) > 0 { + track := tracks[0] + GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n", + track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC) + + if track.AlbumName != "" && req.AlbumName == "" { + req.AlbumName = track.AlbumName + } + if track.AlbumArtist != "" && req.AlbumArtist == "" { + req.AlbumArtist = track.AlbumArtist + } + if track.ReleaseDate != "" && req.ReleaseDate == "" { + req.ReleaseDate = track.ReleaseDate + } + if track.ISRC != "" && req.ISRC == "" { + req.ISRC = track.ISRC + } + if track.TrackNumber > 0 && req.TrackNumber == 0 { + req.TrackNumber = track.TrackNumber + } + if track.DiscNumber > 0 && req.DiscNumber == 0 { + req.DiscNumber = track.DiscNumber + } + if track.CoverURL != "" && req.CoverURL == "" { + req.CoverURL = track.CoverURL + } + if track.Genre != "" && req.Genre == "" { + req.Genre = track.Genre + } + if track.Label != "" && req.Label == "" { + req.Label = track.Label + } + if track.Copyright != "" && req.Copyright == "" { + req.Copyright = track.Copyright + } + } else if searchErr != nil { + GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr) + } + + // Try Deezer extended metadata if we have ISRC + if req.ISRC != "" && + (req.Genre == "" || req.Label == "" || req.Copyright == "") { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC) + cancel() + if err == nil && extMeta != nil { + if req.Genre == "" && extMeta.Genre != "" { + req.Genre = extMeta.Genre + } + if req.Label == "" && extMeta.Label != "" { + req.Label = extMeta.Label + } + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + } + GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright) + } + } + } + if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) && (!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) { @@ -878,6 +1179,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } + // Always pass enriched metadata from req so Flutter can + // embed it — fills gaps from metadata provider search. + if req.AlbumName != "" && resp.Album == "" { + resp.Album = req.AlbumName + } + if req.AlbumArtist != "" && resp.AlbumArtist == "" { + resp.AlbumArtist = req.AlbumArtist + } + if req.ReleaseDate != "" && resp.ReleaseDate == "" { + resp.ReleaseDate = req.ReleaseDate + } + if req.ISRC != "" && resp.ISRC == "" { + resp.ISRC = req.ISRC + } + if req.TrackNumber > 0 && resp.TrackNumber == 0 { + resp.TrackNumber = req.TrackNumber + } + if req.DiscNumber > 0 && resp.DiscNumber == 0 { + resp.DiscNumber = req.DiscNumber + } + if req.CoverURL != "" && resp.CoverURL == "" { + resp.CoverURL = req.CoverURL + } + return resp, nil } @@ -928,7 +1253,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) if isBuiltInProvider(providerIDNormalized) { - if (req.Genre == "" || req.Label == "") && req.ISRC != "" { + if (req.Genre == "" || req.Label == "" || req.Copyright == "") && + req.ISRC != "" { GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) deezerClient := GetDeezerClient() @@ -943,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro req.Label = extMeta.Label GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label) } + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright) + } } else if err != nil { GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err) } @@ -1150,6 +1480,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon TrackNumber: qobuzResult.TrackNumber, DiscNumber: qobuzResult.DiscNumber, ISRC: qobuzResult.ISRC, + CoverURL: qobuzResult.CoverURL, } } err = qobuzErr @@ -1192,6 +1523,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon TrackNumber: result.TrackNumber, DiscNumber: result.DiscNumber, ISRC: result.ISRC, + CoverURL: result.CoverURL, Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, @@ -1431,6 +1763,12 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID } } + for i := range handleResult.Artist.Releases { + handleResult.Artist.Releases[i].ProviderID = p.extension.ID + for j := range handleResult.Artist.Releases[i].Tracks { + handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID + } + } for i := range handleResult.Artist.TopTracks { handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID } diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go new file mode 100644 index 00000000..3112de44 --- /dev/null +++ b/go_backend/extension_providers_test.go @@ -0,0 +1,68 @@ +package gobackend + +import "testing" + +func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) { + original := GetMetadataProviderPriority() + defer SetMetadataProviderPriority(original) + + SetMetadataProviderPriority([]string{"tidal"}) + got := GetMetadataProviderPriority() + want := []string{"tidal", "deezer", "qobuz"} + if len(got) != len(want) { + t.Fatalf("unexpected priority length: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want) + } + } +} + +func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) { + originalPriority := GetMetadataProviderPriority() + originalSearch := searchBuiltInMetadataTracksFunc + defer func() { + SetMetadataProviderPriority(originalPriority) + searchBuiltInMetadataTracksFunc = originalSearch + }() + + SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"}) + + var calls []string + searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) { + calls = append(calls, providerID) + switch providerID { + case "qobuz": + return []ExtTrackMetadata{ + {ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"}, + }, nil + case "tidal": + return []ExtTrackMetadata{ + {ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"}, + {ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"}, + }, nil + case "deezer": + return []ExtTrackMetadata{ + {ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"}, + }, nil + default: + return nil, nil + } + } + + manager := GetExtensionManager() + tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false) + if err != nil { + t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err) + } + if len(tracks) != 3 { + t.Fatalf("unexpected track count: got %d want 3", len(tracks)) + } + if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" { + t.Fatalf("unexpected track provider order: %+v", tracks) + } + if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" { + t.Fatalf("unexpected provider call order: %v", calls) + } +} diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index c45b52c1..58068a42 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex } type ExtensionRuntime struct { - extensionID string - manifest *ExtensionManifest - settings map[string]interface{} - httpClient *http.Client - cookieJar http.CookieJar - dataDir string - vm *goja.Runtime + extensionID string + manifest *ExtensionManifest + settings map[string]interface{} + httpClient *http.Client + downloadClient *http.Client + cookieJar http.CookieJar + dataDir string + vm *goja.Runtime storageMu sync.RWMutex storageCache map[string]interface{} @@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { storageFlushDelay: defaultStorageFlushDelay, } + runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second) + runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout) + + return runtime +} + +func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client { // Extension sandbox enforces HTTPS-only domains. Do not apply global // allow_http scheme downgrade here, because some extension APIs (e.g. // spotify-web) will redirect http -> https and can end up in 301 loops. // We still reuse sharedTransport so insecure TLS compatibility mode remains effective. client := &http.Client{ Transport: sharedTransport, - Timeout: 30 * time.Second, + Timeout: timeout, Jar: jar, } client.CheckRedirect = func(req *http.Request, via []*http.Request) error { @@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { } return nil } - runtime.httpClient = client - - return runtime + return client } type RedirectBlockedError struct { diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 414f174c..92312c98 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") } - resp, err := r.httpClient.Do(req) + client := r.downloadClient + if client == nil { + client = r.httpClient + } + + resp, err := client.Do(req) if err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 2fa17e84..22ac2d0a 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "sync" "time" ) @@ -129,9 +130,8 @@ var ( ) const ( - defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json" - cacheTTL = 30 * time.Minute - cacheFileName = "store_cache.json" + cacheTTL = 30 * time.Minute + cacheFileName = "store_cache.json" ) func InitExtensionStore(cacheDir string) *ExtensionStore { @@ -140,7 +140,7 @@ func InitExtensionStore(cacheDir string) *ExtensionStore { if extensionStore == nil { extensionStore = &ExtensionStore{ - registryURL: defaultRegistryURL, + registryURL: "", // No default - user must provide a registry URL cacheDir: cacheDir, cacheTTL: cacheTTL, } @@ -149,6 +149,36 @@ func InitExtensionStore(cacheDir string) *ExtensionStore { return extensionStore } +// SetRegistryURL updates the registry URL and clears the in-memory cache +// so the next fetch will use the new URL. Disk cache is also cleared. +func (s *ExtensionStore) SetRegistryURL(registryURL string) { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + if s.registryURL == registryURL { + return + } + + s.registryURL = registryURL + s.cache = nil + s.cacheTime = time.Time{} + + // Clear disk cache since it's from a different registry + if s.cacheDir != "" { + cachePath := filepath.Join(s.cacheDir, cacheFileName) + os.Remove(cachePath) + } + + LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL) +} + +// GetRegistryURL returns the currently configured registry URL. +func (s *ExtensionStore) GetRegistryURL() string { + s.cacheMu.RLock() + defer s.cacheMu.RUnlock() + return s.registryURL +} + func GetExtensionStore() *ExtensionStore { extensionStoreMu.Lock() defer extensionStoreMu.Unlock() @@ -206,6 +236,11 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error s.cacheMu.Lock() defer s.cacheMu.Unlock() + // Check if a registry URL has been configured + if s.registryURL == "" { + return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first") + } + if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL { LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions)) return s.cache, nil @@ -336,6 +371,81 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) return nil } +// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL. +// +// Accepted formats: +// - https://raw.githubusercontent.com/owner/repo//registry.json → returned as-is +// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via +// the GitHub API to discover the default branch, then converted to the raw URL +// - Any other HTTPS URL → returned as-is (assumed to be a direct link) +func ResolveRegistryURL(input string) (string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", fmt.Errorf("registry URL is empty") + } + + // Already a fully-qualified raw URL – keep it. + if strings.Contains(input, "raw.githubusercontent.com") { + return input, nil + } + + // Try to match https://github.com//[/...] + const ghPrefix = "https://github.com/" + if !strings.HasPrefix(input, ghPrefix) { + // Also accept http:// and upgrade silently. + const ghPrefixHTTP = "http://github.com/" + if strings.HasPrefix(input, ghPrefixHTTP) { + input = "https://github.com/" + input[len(ghPrefixHTTP):] + } else { + // Not a GitHub URL – return as-is. + return input, nil + } + } + + path := input[len(ghPrefix):] + parts := strings.SplitN(path, "/", 3) // owner, repo, [rest] + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("invalid GitHub URL: expected github.com//") + } + owner := parts[0] + repo := strings.TrimSuffix(parts[1], ".git") + + branch := resolveGitHubDefaultBranch(owner, repo) + + resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch) + LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch) + return resolved, nil +} + +// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's +// default branch. Falls back to "main" on any error. +func resolveGitHubDefaultBranch(owner, repo string) string { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo) + client := NewHTTPClientWithTimeout(10 * time.Second) + + resp, err := client.Get(apiURL) + if err != nil { + LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err) + return "main" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo) + return "main" + } + + var info struct { + DefaultBranch string `json:"default_branch"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" { + LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo) + return "main" + } + + return info.DefaultBranch +} + func requireHTTPSURL(rawURL string, context string) error { if rawURL == "" { return fmt.Errorf("%s URL is empty", context) @@ -374,12 +484,10 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor queryLower := toLower(query) for _, ext := range extensions { - // Filter by category if category != "" && ext.Category != category { continue } - // Filter by query if query != "" { if !containsIgnoreCase(ext.Name, queryLower) && !containsIgnoreCase(ext.DisplayName, queryLower) && diff --git a/go_backend/httputil.go b/go_backend/httputil.go index b54ec489..05e4af8f 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -31,7 +31,7 @@ func getRandomUserAgent() string { const ( DefaultTimeout = 60 * time.Second - DownloadTimeout = 120 * time.Second + DownloadTimeout = 24 * time.Hour SongLinkTimeout = 30 * time.Second DefaultMaxRetries = 3 DefaultRetryDelay = 1 * time.Second @@ -346,11 +346,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur return min(nextDelay, config.MaxDelay) } -// Returns 60 seconds as default if header is missing or invalid +// Returns 0 if the header is missing or invalid so callers can keep their +// normal exponential backoff instead of stalling for an arbitrary minute. func getRetryAfterDuration(resp *http.Response) time.Duration { retryAfter := resp.Header.Get("Retry-After") if retryAfter == "" { - return 60 * time.Second // Default wait time + return 0 } if seconds, err := strconv.Atoi(retryAfter); err == nil { @@ -364,7 +365,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration { } } - return 60 * time.Second // Default + return 0 } func ReadResponseBody(resp *http.Response) ([]byte, error) { diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index d9e53663..ee8874d1 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -1,10 +1,12 @@ package gobackend import ( + "bufio" "encoding/json" "fmt" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -63,6 +65,7 @@ var supportedAudioFormats = map[string]bool{ ".mp3": true, ".opus": true, ".ogg": true, + ".cue": true, } type libraryAudioFileInfo struct { @@ -70,6 +73,11 @@ type libraryAudioFileInfo struct { modTime int64 } +type scannedCueFileInfo struct { + sheet *CueSheet + audioPath string +} + func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) { var files []libraryAudioFileInfo @@ -143,12 +151,7 @@ func ScanLibraryFolder(folderPath string) (string, error) { return "[]", err } - audioFiles := make([]string, 0, len(audioFileInfos)) - for _, fileInfo := range audioFileInfos { - audioFiles = append(audioFiles, fileInfo.path) - } - - totalFiles := len(audioFiles) + totalFiles := len(audioFileInfos) libraryScanProgressMu.Lock() libraryScanProgress.TotalFiles = totalFiles libraryScanProgressMu.Unlock() @@ -166,7 +169,31 @@ func ScanLibraryFolder(folderPath string) (string, error) { scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 - for i, filePath := range audioFiles { + // Track audio files referenced by .cue sheets to avoid duplicates + cueReferencedAudioFiles := make(map[string]bool) + parsedCueFiles := make(map[string]scannedCueFileInfo) + + // First pass: scan .cue files to collect referenced audio paths + for _, fileInfo := range audioFileInfos { + filePath := fileInfo.path + ext := strings.ToLower(filepath.Ext(filePath)) + if ext == ".cue" { + sheet, err := ParseCueFile(filePath) + if err == nil && sheet.FileName != "" { + audioPath := ResolveCueAudioPath(filePath, sheet.FileName) + if audioPath != "" { + parsedCueFiles[filePath] = scannedCueFileInfo{ + sheet: sheet, + audioPath: audioPath, + } + cueReferencedAudioFiles[audioPath] = true + } + } + } + } + + for i, fileInfo := range audioFileInfos { + filePath := fileInfo.path select { case <-cancelCh: return "[]", fmt.Errorf("scan cancelled") @@ -179,7 +206,42 @@ func ScanLibraryFolder(folderPath string) (string, error) { libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100 libraryScanProgressMu.Unlock() - result, err := scanAudioFile(filePath, scanTime) + ext := strings.ToLower(filepath.Ext(filePath)) + + // Handle .cue files: produce multiple track results + if ext == ".cue" { + var cueResults []LibraryScanResult + cueInfo, ok := parsedCueFiles[filePath] + if ok { + cueResults, err = scanCueSheetForLibrary( + filePath, + cueInfo.sheet, + cueInfo.audioPath, + "", + fileInfo.modTime, + scanTime, + ) + } else { + cueResults, err = ScanCueFileForLibrary(filePath, scanTime) + } + if err != nil { + errorCount++ + GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err) + continue + } + results = append(results, cueResults...) + GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults)) + continue + } + + // Skip audio files that are referenced by a .cue sheet + // (they will be represented by the cue sheet's track entries instead) + if cueReferencedAudioFiles[filePath] { + GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath)) + continue + } + + result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime) if err != nil { errorCount++ GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err) @@ -205,7 +267,15 @@ func ScanLibraryFolder(folderPath string) (string, error) { } func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { - ext := strings.ToLower(filepath.Ext(filePath)) + return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0) +} + +func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) { + return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime) +} + +func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) { + ext := resolveLibraryAudioExt(filePath, displayNameHint) result := &LibraryScanResult{ ID: generateLibraryID(filePath), @@ -214,15 +284,17 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { Format: strings.TrimPrefix(ext, "."), } - if info, err := os.Stat(filePath); err == nil { + if knownModTime > 0 { + result.FileModTime = knownModTime + } else if info, err := os.Stat(filePath); err == nil { result.FileModTime = info.ModTime().UnixMilli() } libraryCoverCacheMu.RLock() coverCacheDir := libraryCoverCacheDir libraryCoverCacheMu.RUnlock() - if coverCacheDir != "" && ext != ".m4a" { - coverPath, err := SaveCoverToCache(filePath, coverCacheDir) + if coverCacheDir != "" { + coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir) if err == nil && coverPath != "" { result.CoverPath = coverPath } @@ -236,15 +308,31 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { case ".mp3": return scanMP3File(filePath, result) case ".opus", ".ogg": - return scanOggFile(filePath, result) + return scanOggFile(filePath, result, displayNameHint) default: - return scanFromFilename(filePath, result) + return scanFromFilename(filePath, displayNameHint, result) } } -func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) { +func resolveLibraryAudioExt(filePath, displayNameHint string) string { + ext := strings.ToLower(filepath.Ext(filePath)) + if ext != "" { + return ext + } + return strings.ToLower(filepath.Ext(displayNameHint)) +} + +func libraryDisplayNameOrPath(filePath, displayNameHint string) string { + if displayNameHint != "" { + return displayNameHint + } + return filePath +} + +func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) { + nameSource := libraryDisplayNameOrPath(filePath, displayNameHint) if result.TrackName == "" { - result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource)) } if result.ArtistName == "" { result.ArtistName = "Unknown Artist" @@ -257,7 +345,7 @@ func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) { func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { metadata, err := ReadMetadata(filePath) if err != nil { - return scanFromFilename(filePath, result) + return scanFromFilename(filePath, "", result) } result.TrackName = metadata.Title @@ -279,26 +367,43 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul } } - applyDefaultLibraryMetadata(filePath, result) + applyDefaultLibraryMetadata(filePath, "", result) return result, nil } func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { + metadata, err := ReadM4ATags(filePath) + if err == nil && metadata != nil { + result.TrackName = metadata.Title + result.ArtistName = metadata.Artist + result.AlbumName = metadata.Album + result.AlbumArtist = metadata.AlbumArtist + result.ISRC = metadata.ISRC + result.TrackNumber = metadata.TrackNumber + result.DiscNumber = metadata.DiscNumber + result.ReleaseDate = metadata.Date + if result.ReleaseDate == "" { + result.ReleaseDate = metadata.Year + } + result.Genre = metadata.Genre + } + quality, err := GetM4AQuality(filePath) if err == nil { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate } - return scanFromFilename(filePath, result) + applyDefaultLibraryMetadata(filePath, "", result) + return result, nil } func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { metadata, err := ReadID3Tags(filePath) if err != nil { GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err) - return scanFromFilename(filePath, result) + return scanFromFilename(filePath, "", result) } result.TrackName = metadata.Title @@ -325,16 +430,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult } } - applyDefaultLibraryMetadata(filePath, result) + applyDefaultLibraryMetadata(filePath, "", result) return result, nil } -func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { +func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) { metadata, err := ReadOggVorbisComments(filePath) if err != nil { GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err) - return scanFromFilename(filePath, result) + return scanFromFilename(filePath, displayNameHint, result) } result.TrackName = metadata.Title @@ -357,13 +462,14 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult } } - applyDefaultLibraryMetadata(filePath, result) + applyDefaultLibraryMetadata(filePath, displayNameHint, result) return result, nil } -func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { - filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) +func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) { + nameSource := libraryDisplayNameOrPath(filePath, displayNameHint) + filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource)) parts := strings.SplitN(filename, " - ", 2) if len(parts) == 2 { @@ -386,7 +492,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR dir := filepath.Dir(filePath) result.AlbumName = filepath.Base(dir) - if result.AlbumName == "." || result.AlbumName == "" { + if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" { result.AlbumName = "Unknown Album" } @@ -433,8 +539,12 @@ func CancelLibraryScan() { } func ReadAudioMetadata(filePath string) (string, error) { + return ReadAudioMetadataWithDisplayName(filePath, "") +} + +func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) { scanTime := time.Now().UTC().Format(time.RFC3339) - result, err := scanAudioFile(filePath, scanTime) + result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0) if err != nil { return "", err } @@ -450,7 +560,43 @@ func ReadAudioMetadata(filePath string) (string, error) { // ScanLibraryFolderIncremental performs an incremental scan of the library folder // existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis) // Only files that are new or have changed modification time will be scanned -func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) { +func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) { + existingFiles := make(map[string]int64) + if snapshotPath == "" { + return existingFiles, nil + } + + file, err := os.Open(snapshotPath) + if err != nil { + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + continue + } + modTime, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + continue + } + existingFiles[parts[1]] = modTime + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return existingFiles, nil +} + +func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) { if folderPath == "" { return "{}", fmt.Errorf("folder path is empty") } @@ -463,13 +609,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, return "{}", fmt.Errorf("path is not a folder: %s", folderPath) } - existingFiles := make(map[string]int64) - if existingFilesJSON != "" && existingFilesJSON != "{}" { - if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil { - GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err) - } - } - GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles)) libraryScanProgressMu.Lock() @@ -501,10 +640,31 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, // Find files to scan (new or modified) var filesToScan []libraryAudioFileInfo skippedCount := 0 + existingCueTrackModTimes := make(map[string]int64) + for existingPath, modTime := range existingFiles { + if idx := strings.LastIndex(existingPath, "#track"); idx > 0 { + baseCuePath := existingPath[:idx] + if _, exists := existingCueTrackModTimes[baseCuePath]; !exists { + existingCueTrackModTimes[baseCuePath] = modTime + } + } + } for _, f := range currentFiles { existingModTime, exists := existingFiles[f.path] if !exists { + // For .cue files, also check if any virtual path entries exist + if strings.ToLower(filepath.Ext(f.path)) == ".cue" { + if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks { + // CUE file exists in DB via virtual paths; check if modTime changed + if f.modTime == cueTrackModTime { + skippedCount++ + } else { + filesToScan = append(filesToScan, f) + } + continue + } + } filesToScan = append(filesToScan, f) } else if f.modTime != existingModTime { filesToScan = append(filesToScan, f) @@ -515,7 +675,16 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, var deletedPaths []string for existingPath := range existingFiles { - if !currentPathSet[existingPath] { + // For CUE virtual paths (e.g. "/path/album.cue#track01"), + // check if the base .cue file still exists on disk + if idx := strings.LastIndex(existingPath, "#track"); idx > 0 { + baseCuePath := existingPath[:idx] + if currentPathSet[baseCuePath] { + continue // Base .cue file still exists, not deleted + } + // Base CUE file is gone, mark virtual path as deleted + deletedPaths = append(deletedPaths, existingPath) + } else if !currentPathSet[existingPath] { deletedPaths = append(deletedPaths, existingPath) } } @@ -544,6 +713,26 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 + // Track audio files referenced by .cue sheets to avoid duplicates (incremental) + cueReferencedAudioFilesInc := make(map[string]bool) + parsedCueFiles := make(map[string]scannedCueFileInfo) + for _, f := range filesToScan { + ext := strings.ToLower(filepath.Ext(f.path)) + if ext == ".cue" { + sheet, err := ParseCueFile(f.path) + if err == nil && sheet.FileName != "" { + audioPath := ResolveCueAudioPath(f.path, sheet.FileName) + if audioPath != "" { + parsedCueFiles[f.path] = scannedCueFileInfo{ + sheet: sheet, + audioPath: audioPath, + } + cueReferencedAudioFilesInc[audioPath] = true + } + } + } + } + for i, f := range filesToScan { select { case <-cancelCh: @@ -557,7 +746,39 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100 libraryScanProgressMu.Unlock() - result, err := scanAudioFile(f.path, scanTime) + ext := strings.ToLower(filepath.Ext(f.path)) + + // Handle .cue files: produce multiple track results + if ext == ".cue" { + var cueResults []LibraryScanResult + cueInfo, ok := parsedCueFiles[f.path] + if ok { + cueResults, err = scanCueSheetForLibrary( + f.path, + cueInfo.sheet, + cueInfo.audioPath, + "", + f.modTime, + scanTime, + ) + } else { + cueResults, err = ScanCueFileForLibrary(f.path, scanTime) + } + if err != nil { + errorCount++ + GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err) + continue + } + results = append(results, cueResults...) + continue + } + + // Skip audio files referenced by .cue sheets + if cueReferencedAudioFilesInc[f.path] { + continue + } + + result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime) if err != nil { errorCount++ GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err) @@ -591,3 +812,24 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, return string(jsonBytes), nil } + +// ScanLibraryFolderIncremental performs an incremental scan of the library folder +// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis) +// Only files that are new or have changed modification time will be scanned +func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) { + existingFiles := make(map[string]int64) + if existingFilesJSON != "" && existingFilesJSON != "{}" { + if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil { + GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err) + } + } + return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles) +} + +func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) { + existingFiles, err := loadExistingFilesSnapshot(snapshotPath) + if err != nil { + return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err) + } + return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles) +} diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 1a6c4771..60d3aa85 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -3,6 +3,7 @@ package gobackend import ( "encoding/json" "fmt" + "io" "math" "net/http" "net/url" @@ -121,12 +122,12 @@ func GetLyricsProviderOrder() []string { // GetAvailableLyricsProviders returns metadata about all available providers. func GetAvailableLyricsProviders() []map[string]interface{} { return []map[string]interface{}{ - {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"}, + {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"}, {"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"}, - {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"}, - {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"}, - {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"}, - {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"}, + {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"}, + {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"}, + {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"}, + {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"}, } } @@ -431,6 +432,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time { return now.Add(10 * time.Minute) } +func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) { + if len(lines) == 0 { + return nil, fmt.Errorf("Spotify Lyrics API returned empty lines") + } + if syncType == "" { + if len(lines) > 0 && lines[0].StartTimeMs > 0 { + syncType = "LINE_SYNCED" + } else { + syncType = "UNSYNCED" + } + } + return &LyricsResponse{ + Lines: lines, + SyncType: syncType, + Instrumental: false, + PlainLyrics: plainLyrics, + Provider: "Spotify Lyrics API", + Source: "Spotify Lyrics API", + }, nil +} + +func plainLyricsFromTimedLines(lines []LyricsLine) string { + parts := make([]string, 0, len(lines)) + for _, line := range lines { + words := strings.TrimSpace(line.Words) + if words == "" { + continue + } + parts = append(parts, words) + } + return strings.Join(parts, "\n") +} + +func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) { + var lrcPayload string + if err := json.Unmarshal(body, &lrcPayload); err == nil { + trimmed := strings.TrimSpace(lrcPayload) + if trimmed == "" { + return nil, fmt.Errorf("Spotify Lyrics API returned empty payload") + } + + lines := parseSyncedLyrics(trimmed) + if len(lines) > 0 { + return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines)) + } + + plainLines := plainTextLyricsLines(trimmed) + return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed) + } + + var apiResp SpotifyLyricsAPIResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err) + } + + if apiResp.Error { + msg := strings.TrimSpace(apiResp.Message) + if msg == "" { + msg = "Spotify Lyrics API returned error" + } + return nil, fmt.Errorf("%s", msg) + } + + lines := make([]LyricsLine, 0, len(apiResp.Lines)) + for _, line := range apiResp.Lines { + words := strings.TrimSpace(line.Words) + if words == "" { + continue + } + startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag) + lines = append(lines, LyricsLine{ + StartTimeMs: startMs, + Words: words, + EndTimeMs: 0, + }) + } + + for i := 0; i < len(lines)-1; i++ { + nextStart := lines[i+1].StartTimeMs + if nextStart > lines[i].StartTimeMs { + lines[i].EndTimeMs = nextStart + } + } + if len(lines) > 0 { + last := len(lines) - 1 + if lines[last].EndTimeMs == 0 { + lines[last].EndTimeMs = lines[last].StartTimeMs + 5000 + } + } + + return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines)) +} + func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) { now := time.Now() if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) { @@ -449,7 +543,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo spotifyID = parsed.ID } - apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID)) + apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID)) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -462,13 +556,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo } defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err) + } + if resp.StatusCode != 200 { if resp.StatusCode == http.StatusTooManyRequests { retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now) setSpotifyLyricsRateLimitUntil(retryUntil) } var payload map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil { + if err := json.Unmarshal(bodyBytes, &payload); err == nil { if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) } @@ -479,63 +578,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode) } - var apiResp SpotifyLyricsAPIResponse - if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { - return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err) - } - - if apiResp.Error { - msg := strings.TrimSpace(apiResp.Message) - if msg == "" { - msg = "Spotify Lyrics API returned error" - } - return nil, fmt.Errorf("%s", msg) - } - - result := &LyricsResponse{ - Lines: make([]LyricsLine, 0, len(apiResp.Lines)), - SyncType: apiResp.SyncType, - Instrumental: false, - PlainLyrics: "", - Provider: "Spotify Lyrics API", - Source: "Spotify Lyrics API", - } - - for _, line := range apiResp.Lines { - words := strings.TrimSpace(line.Words) - if words == "" { - continue - } - startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag) - result.Lines = append(result.Lines, LyricsLine{ - StartTimeMs: startMs, - Words: words, - EndTimeMs: 0, - }) - } - - if len(result.Lines) > 1 { - for i := 0; i < len(result.Lines)-1; i++ { - nextStart := result.Lines[i+1].StartTimeMs - if nextStart > result.Lines[i].StartTimeMs { - result.Lines[i].EndTimeMs = nextStart - } - } - last := len(result.Lines) - 1 - if result.Lines[last].EndTimeMs == 0 { - result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000 - } - } - - if len(result.Lines) == 0 { - return nil, fmt.Errorf("Spotify Lyrics API returned empty lines") - } - - if result.SyncType == "" { - result.SyncType = "LINE_SYNCED" - } - - return result, nil + return parseSpotifyLyricsResponseBody(bodyBytes) } func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 538b0b89..8fe350ad 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -4,121 +4,25 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "net/url" - "regexp" "strings" - "sync" "time" ) // AppleMusicClient fetches lyrics from Apple Music. -// Uses a scraped JWT token for search and a proxy for lyrics. +// Uses Paxsenix endpoints for search and lyrics. type AppleMusicClient struct { httpClient *http.Client } -// Apple Music token manager — singleton with mutex for thread safety -type appleTokenManager struct { - mu sync.Mutex - token string -} - -var globalAppleTokenManager = &appleTokenManager{} - -func (m *appleTokenManager) getToken(client *http.Client) (string, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.token != "" { - return m.token, nil - } - - // Step 1: Fetch the Apple Music beta page - req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to fetch Apple Music page: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read Apple Music page: %w", err) - } - - // Step 2: Find the index JS file URL - indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`) - match := indexJsRegex.Find(body) - if match == nil { - return "", fmt.Errorf("could not find index JS script URL on Apple Music page") - } - - indexJsURL := "https://beta.music.apple.com" + string(match) - - // Step 3: Fetch the JS file - jsReq, err := http.NewRequest("GET", indexJsURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create JS request: %w", err) - } - jsReq.Header.Set("User-Agent", getRandomUserAgent()) - - jsResp, err := client.Do(jsReq) - if err != nil { - return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err) - } - defer jsResp.Body.Close() - - jsBody, err := io.ReadAll(jsResp.Body) - if err != nil { - return "", fmt.Errorf("failed to read Apple Music JS: %w", err) - } - - // Step 4: Extract JWT token (starts with eyJh) - tokenRegex := regexp.MustCompile(`eyJh[^"]*`) - tokenMatch := tokenRegex.Find(jsBody) - if tokenMatch == nil { - return "", fmt.Errorf("could not find JWT token in Apple Music JS") - } - - m.token = string(tokenMatch) - GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token)) - return m.token, nil -} - -func (m *appleTokenManager) clearToken() { - m.mu.Lock() - defer m.mu.Unlock() - m.token = "" -} - -type appleMusicSearchResponse struct { - Results struct { - Songs *struct { - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - } `json:"data"` - } `json:"songs"` - } `json:"results"` - Resources *struct { - Songs map[string]struct { - Attributes struct { - Name string `json:"name"` - ArtistName string `json:"artistName"` - AlbumName string `json:"albumName"` - URL string `json:"url"` - Artwork struct { - URL string `json:"url"` - } `json:"artwork"` - } `json:"attributes"` - } `json:"songs"` - } `json:"resources"` +type appleMusicSearchResult struct { + ID string `json:"id"` + SongName string `json:"songName"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + Duration int `json:"duration"` } // PaxResponse represents the lyrics proxy response for word-by-word / line lyrics @@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient { } } +func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult { + if len(results) == 0 { + return nil + } + + normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName))) + normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName))) + if normalizedArtist == "" { + normalizedArtist = strings.ToLower(strings.TrimSpace(artistName)) + } + + bestIndex := 0 + bestScore := -1 + for i := range results { + result := &results[i] + score := 0 + + candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName))) + candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName))) + + switch { + case candidateTrack == normalizedTrack: + score += 50 + case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack): + score += 25 + } + + switch { + case candidateArtist == normalizedArtist: + score += 60 + case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist): + score += 30 + } + + if durationSec > 0 && result.Duration > 0 { + diff := math.Abs(float64(result.Duration)/1000.0 - durationSec) + if diff <= durationToleranceSec { + score += 20 + } + } + + if score > bestScore { + bestScore = score + bestIndex = i + } + } + + return &results[bestIndex] +} + // SearchSong searches for a song on Apple Music and returns its ID. -func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) { +func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { query := trackName + " " + artistName if strings.TrimSpace(query) == "" { return "", fmt.Errorf("empty search query") } - token, err := globalAppleTokenManager.getToken(c.httpClient) - if err != nil { - return "", fmt.Errorf("apple music token error: %w", err) - } - encodedQuery := url.QueryEscape(query) - searchURL := fmt.Sprintf( - "https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl", - encodedQuery, - ) + searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery) req, err := http.NewRequest("GET", searchURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Origin", "https://music.apple.com") - req.Header.Set("Referer", "https://music.apple.com/") req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("Accept", "application/json") @@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err } defer resp.Body.Close() - if resp.StatusCode == 401 { - globalAppleTokenManager.clearToken() - return "", fmt.Errorf("apple music token expired") - } - if resp.StatusCode != 200 { return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode) } - var searchResp appleMusicSearchResponse + var searchResp []appleMusicSearchResult if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { return "", fmt.Errorf("failed to decode apple music response: %w", err) } - if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 { + best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec) + if best == nil || strings.TrimSpace(best.ID) == "" { return "", fmt.Errorf("no songs found on apple music") } - return searchResp.Results.Songs.Data[0].ID, nil + return strings.TrimSpace(best.ID), nil } // FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID. @@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics( durationSec float64, multiPersonWordByWord bool, ) (*LyricsResponse, error) { - songID, err := c.SearchSong(trackName, artistName) + songID, err := c.SearchSong(trackName, artistName, durationSec) if err != nil { return nil, err } diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index f962a110..37dfbea7 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -3,6 +3,8 @@ package gobackend import ( "encoding/json" "fmt" + "io" + "math" "net/http" "net/url" "strings" @@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct { func NewMusixmatchClient() *MusixmatchClient { return &MusixmatchClient{ httpClient: NewMetadataHTTPClient(15 * time.Second), - baseURL: "http://158.180.60.95", + baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics", } } -// searchAndGetLyrics searches for a song and retrieves its lyrics in one call. -// The Musixmatch proxy returns both search result and lyrics in a single response. -func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) { +func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) { if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" { - return nil, fmt.Errorf("empty track or artist name") + return "", fmt.Errorf("empty track or artist name") } - encodedArtist := url.QueryEscape(artistName) - encodedTrack := url.QueryEscape(trackName) - - fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack) + params := url.Values{} + params.Set("t", trackName) + params.Set("a", artistName) + params.Set("type", lyricsType) + params.Set("format", "lrc") + if durationSec > 0 { + params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec)))) + } + if strings.TrimSpace(language) != "" { + params.Set("l", strings.ToLower(strings.TrimSpace(language))) + } + fullURL := c.baseURL + "?" + params.Encode() req, err := http.NewRequest("GET", fullURL, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return "", fmt.Errorf("failed to create request: %w", err) } + req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("musixmatch search failed: %w", err) + return "", fmt.Errorf("musixmatch request failed: %w", err) } defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read musixmatch response: %w", err) + } + if resp.StatusCode != 200 { - return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode) + trimmed := strings.TrimSpace(string(body)) + if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload { + return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg) + } + return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode) } - var result musixmatchSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode musixmatch response: %w", err) + var lrcPayload string + if err := json.Unmarshal(body, &lrcPayload); err == nil { + lrcPayload = strings.TrimSpace(lrcPayload) + if lrcPayload == "" { + return "", fmt.Errorf("empty musixmatch lyrics payload") + } + return lrcPayload, nil } - return &result, nil + trimmed := strings.TrimSpace(string(body)) + if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload { + return "", fmt.Errorf("%s", errMsg) + } + if trimmed != "" && !strings.HasPrefix(trimmed, "{") { + return trimmed, nil + } + return "", fmt.Errorf("failed to decode musixmatch response") } // FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code. -func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) { +func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) { lang := strings.ToLower(strings.TrimSpace(language)) - if songID <= 0 || lang == "" { - return nil, fmt.Errorf("invalid song id or language") + if lang == "" { + return nil, fmt.Errorf("invalid language") } - fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang)) - - req, err := http.NewRequest("GET", fullURL, nil) + lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("musixmatch language fetch failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode) + return nil, err } - var result musixmatchSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err) + lines := parseSyncedLyrics(lrcText) + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "LINE_SYNCED", + PlainLyrics: plainLyricsFromTimedLines(lines), + Provider: "Musixmatch", + Source: fmt.Sprintf("Musixmatch (%s)", lang), + }, nil } - if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { - lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "LINE_SYNCED", - Provider: "Musixmatch", - Source: fmt.Sprintf("Musixmatch (%s)", lang), - }, nil - } - } - - if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { - lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) - - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "UNSYNCED", - PlainLyrics: result.UnsyncedLyrics.Lyrics, - Provider: "Musixmatch", - Source: fmt.Sprintf("Musixmatch (%s)", lang), - }, nil - } + plainLines := plainTextLyricsLines(lrcText) + if len(plainLines) > 0 { + return &LyricsResponse{ + Lines: plainLines, + SyncType: "UNSYNCED", + PlainLyrics: lrcText, + Provider: "Musixmatch", + Source: fmt.Sprintf("Musixmatch (%s)", lang), + }, nil } return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang) @@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) // FetchLyrics searches Musixmatch and returns parsed LyricsResponse. func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) { - result, err := c.searchAndGetLyrics(trackName, artistName) - if err != nil { - return nil, err - } - - if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 { - localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred) + if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" { + localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred) if localizedErr == nil { return localized, nil } GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr) } - if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { - lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "LINE_SYNCED", - Provider: "Musixmatch", - Source: "Musixmatch", - }, nil - } + lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "") + if err != nil { + return nil, err } - if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { - lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) + lines := parseSyncedLyrics(lrcText) + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "LINE_SYNCED", + PlainLyrics: plainLyricsFromTimedLines(lines), + Provider: "Musixmatch", + Source: "Musixmatch", + }, nil + } - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "UNSYNCED", - PlainLyrics: result.UnsyncedLyrics.Lyrics, - Provider: "Musixmatch", - Source: "Musixmatch", - }, nil - } + plainLines := plainTextLyricsLines(lrcText) + if len(plainLines) > 0 { + return &LyricsResponse{ + Lines: plainLines, + SyncType: "UNSYNCED", + PlainLyrics: lrcText, + Provider: "Musixmatch", + Source: "Musixmatch", + }, nil } return nil, fmt.Errorf("no lyrics found on musixmatch") diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go index f6ce6b6c..c6741e19 100644 --- a/go_backend/lyrics_netease.go +++ b/go_backend/lyrics_netease.go @@ -9,8 +9,7 @@ import ( "time" ) -// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com). -// This is a direct public API — no proxy dependency. +// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints. type NeteaseClient struct { httpClient *http.Client } @@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) return 0, fmt.Errorf("empty search query") } - searchURL := "http://music.163.com/api/search/pc" + searchURL := "https://lyrics.paxsenix.org/netease/search" params := url.Values{} - params.Set("s", query) - params.Set("type", "1") - params.Set("limit", "1") - params.Set("offset", "0") + params.Set("q", query) fullURL := searchURL + "?" + params.Encode() @@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) // FetchLyricsByID fetches synced lyrics for a given Netease song ID. func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) { - lyricsURL := "http://music.163.com/api/song/lyric" + lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics" params := url.Values{} params.Set("id", fmt.Sprintf("%d", songID)) - params.Set("lv", "1") - params.Set("tv", "1") - params.Set("rv", "1") fullURL := lyricsURL + "?" + params.Encode() diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index dde3631d..95461031 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -1,45 +1,31 @@ package gobackend import ( - "bytes" "encoding/json" "fmt" "io" + "math" "net/http" - "net/url" "strings" "time" ) // QQMusicClient fetches lyrics from QQ Music. -// Search uses public QQ Music API, lyrics use the paxsenix proxy. +// Uses Paxsenix metadata lookup for lyrics. type QQMusicClient struct { httpClient *http.Client } -type qqMusicSearchResponse struct { - Data struct { - Song struct { - List []struct { - Title string `json:"title"` - Singer []struct { - Name string `json:"name"` - } `json:"singer"` - Album struct { - Name string `json:"name"` - } `json:"album"` - ID int64 `json:"id"` - } `json:"list"` - } `json:"song"` - } `json:"data"` +type qqLyricsMetadataRequest struct { + Artist []string `json:"artist"` + Album string `json:"album,omitempty"` + SongID int64 `json:"songid,omitempty"` + Title string `json:"title"` + Duration int64 `json:"duration,omitempty"` } -// QQ Music lyrics request payload for paxsenix proxy -type qqLyricsPayload struct { - Artist []string `json:"artist"` - Album string `json:"album"` - ID int64 `json:"id"` - Title string `json:"title"` +type qqLyricsMetadataResponse struct { + Lyrics []paxLyrics `json:"lyrics"` } func NewQQMusicClient() *QQMusicClient { @@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient { } } -// searchSong searches QQ Music and returns the song info needed for lyrics fetch. -func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) { - query := trackName + " " + artistName - if strings.TrimSpace(query) == "" { - return nil, fmt.Errorf("empty search query") +// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata. +func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) { + payload := qqLyricsMetadataRequest{ + Artist: []string{artistName}, + Title: trackName, + } + if durationSec > 0 { + payload.Duration = int64(math.Round(durationSec)) } - searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp" - params := url.Values{} - params.Set("format", "json") - params.Set("inCharset", "utf8") - params.Set("outCharset", "utf8") - params.Set("platform", "yqq.json") - params.Set("new_json", "1") - params.Set("w", query) - - fullURL := searchURL + "?" + params.Encode() - - req, err := http.NewRequest("GET", fullURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("qqmusic search failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode) - } - - var searchResp qqMusicSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return nil, fmt.Errorf("failed to decode qqmusic response: %w", err) - } - - if len(searchResp.Data.Song.List) == 0 { - return nil, fmt.Errorf("no songs found on qqmusic") - } - - song := searchResp.Data.Song.List[0] - - var artists []string - for _, singer := range song.Singer { - artists = append(artists, singer.Name) - } - - return &qqLyricsPayload{ - Artist: artists, - Album: song.Album.Name, - ID: song.ID, - Title: song.Title, - }, nil -} - -// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info. -func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) { - lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php" + lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata" payloadBytes, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("failed to marshal payload: %w", err) } - req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes)) + req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes))) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := c.httpClient.Do(req) @@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, return bodyStr, nil } +func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) { + var response qqLyricsMetadataResponse + if err := json.Unmarshal([]byte(rawJSON), &response); err != nil { + return "", fmt.Errorf("failed to parse qq metadata lyrics response") + } + if len(response.Lyrics) == 0 { + return "", fmt.Errorf("qq metadata lyrics response was empty") + } + return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil +} + // FetchLyrics searches QQ Music and returns parsed LyricsResponse. func (c *QQMusicClient) FetchLyrics( trackName, @@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics( durationSec float64, multiPersonWordByWord bool, ) (*LyricsResponse, error) { - payload, err := c.searchSong(trackName, artistName) - if err != nil { - return nil, err - } - - rawLyrics, err := c.fetchLyricsByPayload(payload) + rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec) if err != nil { return nil, err } @@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics( return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg) } - // Try to parse as pax format (word-by-word or line) - lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord) + lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord) if err != nil { - // If pax parsing fails, try to use as direct LRC text - lrcText = rawLyrics + if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil { + lrcText = fallback + } else { + lrcText = rawLyrics + } } lines := parseSyncedLyrics(lrcText) diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 29ab2d02..fdd856ec 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) { return extractLyricsFromSidecarLRC(filePath) } + if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") { + lyrics, err := extractLyricsFromM4A(filePath) + if err == nil && strings.TrimSpace(lyrics) != "" { + return lyrics, nil + } + return extractLyricsFromSidecarLRC(filePath) + } + if strings.HasSuffix(lower, ".mp3") { meta, err := ReadID3Tags(filePath) if err == nil && meta != nil { @@ -581,6 +589,299 @@ func ExtractLyrics(filePath string) (string, error) { return extractLyricsFromSidecarLRC(filePath) } +func ReadM4ATags(filePath string) (*AudioMetadata, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil, err + } + + ilst, err := findM4AIlstAtom(f, fi.Size()) + if err != nil { + return nil, err + } + + metadata := &AudioMetadata{} + start := ilst.offset + ilst.headerSize + end := ilst.offset + ilst.size + for pos := start; pos+8 <= end; { + header, err := readAtomHeaderAt(f, pos, fi.Size()) + if err != nil { + return nil, err + } + if header.size == 0 { + header.size = end - pos + } + if header.size < header.headerSize { + return nil, fmt.Errorf("invalid atom size for %s", header.typ) + } + + switch header.typ { + case "\xa9nam": + metadata.Title, _ = readM4ATextValue(f, header, fi.Size()) + case "\xa9ART": + metadata.Artist, _ = readM4ATextValue(f, header, fi.Size()) + case "\xa9alb": + metadata.Album, _ = readM4ATextValue(f, header, fi.Size()) + case "aART": + metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size()) + case "\xa9day": + metadata.Date, _ = readM4ATextValue(f, header, fi.Size()) + metadata.Year = metadata.Date + case "\xa9gen": + metadata.Genre, _ = readM4ATextValue(f, header, fi.Size()) + case "\xa9wrt": + metadata.Composer, _ = readM4ATextValue(f, header, fi.Size()) + case "\xa9cmt": + metadata.Comment, _ = readM4ATextValue(f, header, fi.Size()) + case "cprt": + metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size()) + case "\xa9lyr": + metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size()) + case "trkn": + metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size()) + case "disk": + metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size()) + case "----": + name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size()) + if freeformErr == nil { + switch strings.ToUpper(strings.TrimSpace(name)) { + case "ISRC": + metadata.ISRC = value + case "LABEL", "ORGANIZATION": + metadata.Label = value + case "COMMENT": + if metadata.Comment == "" { + metadata.Comment = value + } + case "COMPOSER": + if metadata.Composer == "" { + metadata.Composer = value + } + case "COPYRIGHT": + if metadata.Copyright == "" { + metadata.Copyright = value + } + case "LYRICS", "UNSYNCEDLYRICS": + if metadata.Lyrics == "" { + metadata.Lyrics = value + } + } + } + } + + pos += header.size + } + + if metadata.Title == "" && + metadata.Artist == "" && + metadata.Album == "" && + metadata.AlbumArtist == "" && + metadata.Lyrics == "" && + metadata.TrackNumber == 0 && + metadata.DiscNumber == 0 { + return nil, fmt.Errorf("no M4A tags found") + } + + return metadata, nil +} + +func extractLyricsFromM4A(filePath string) (string, error) { + metadata, err := ReadM4ATags(filePath) + if err != nil { + return "", err + } + if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" { + return "", fmt.Errorf("no lyrics found in file") + } + return metadata.Lyrics, nil +} + +func extractCoverFromM4A(filePath string) ([]byte, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil, err + } + fileSize := fi.Size() + + ilst, err := findM4AIlstAtom(f, fileSize) + if err != nil { + return nil, err + } + + bodyStart := ilst.offset + ilst.headerSize + bodySize := ilst.size - ilst.headerSize + + covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("cover atom not found") + } + + dataStart := covr.offset + covr.headerSize + dataSize := covr.size - covr.headerSize + + dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("data atom not found in cover") + } + + // data atom: header + 4 bytes type indicator + 4 bytes locale + imgStart := dataAtom.offset + dataAtom.headerSize + 8 + imgLen := dataAtom.size - dataAtom.headerSize - 8 + if imgLen <= 0 { + return nil, fmt.Errorf("empty cover data") + } + + buf := make([]byte, imgLen) + if _, err := f.ReadAt(buf, imgStart); err != nil { + return nil, err + } + + return buf, nil +} + +// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags. +// It tries two common layouts: +// 1. moov > udta > meta > ilst (iTunes, FFmpeg default) +// 2. moov > meta > ilst (some encoders omit the udta wrapper) +func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) { + moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) + if err != nil || !found { + return atomHeader{}, fmt.Errorf("moov not found") + } + + moovBodyStart := moov.offset + moov.headerSize + moovBodySize := moov.size - moov.headerSize + + // Path 1: moov > udta > meta > ilst + if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok { + udtaBodyStart := udta.offset + udta.headerSize + udtaBodySize := udta.size - udta.headerSize + if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 { + metaBodyStart := meta.offset + meta.headerSize + 4 + metaBodySize := meta.size - meta.headerSize - 4 + if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 { + return ilst, nil + } + } + } + + // Path 2: moov > meta > ilst (no udta wrapper) + if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok { + metaBodyStart := meta.offset + meta.headerSize + 4 + metaBodySize := meta.size - meta.headerSize - 4 + if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 { + return ilst, nil + } + } + + return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)") +} + +func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) { + payloadStart := dataAtom.offset + dataAtom.headerSize + 8 + payloadLen := dataAtom.size - dataAtom.headerSize - 8 + if payloadLen <= 0 { + return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ) + } + + buf := make([]byte, payloadLen) + if _, err := f.ReadAt(buf, payloadStart); err != nil { + return nil, err + } + return buf, nil +} + +func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) { + dataStart := parent.offset + parent.headerSize + dataSize := parent.size - parent.headerSize + + dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("data atom not found in %s", parent.typ) + } + return readM4ADataAtomPayload(f, dataAtom) +} + +func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) { + payload, err := readM4ADataPayload(f, parent, fileSize) + if err != nil { + return "", err + } + return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil +} + +func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) { + payload, err := readM4ADataPayload(f, parent, fileSize) + if err != nil { + return 0, err + } + if len(payload) < 4 { + return 0, fmt.Errorf("index payload too short in %s", parent.typ) + } + return int(binary.BigEndian.Uint16(payload[2:4])), nil +} + +func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) { + start := parent.offset + parent.headerSize + end := parent.offset + parent.size + + var nameValue string + var dataValue string + for pos := start; pos+8 <= end; { + header, err := readAtomHeaderAt(f, pos, fileSize) + if err != nil { + return "", "", err + } + if header.size == 0 { + header.size = end - pos + } + if header.size < header.headerSize { + return "", "", fmt.Errorf("invalid atom size for %s", header.typ) + } + + switch header.typ { + case "mean": + // Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip. + case "name": + // The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text. + // It does NOT contain a nested "data" atom, so read the payload directly. + payloadStart := header.offset + header.headerSize + 4 + payloadLen := header.size - header.headerSize - 4 + if payloadLen > 0 { + buf := make([]byte, payloadLen) + if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil { + nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00")) + } + } + case "data": + payload, payloadErr := readM4ADataAtomPayload(f, header) + if payloadErr == nil { + dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00")) + } + } + + pos += header.size + } + + if nameValue == "" || dataValue == "" { + return "", "", fmt.Errorf("freeform M4A tag incomplete") + } + + return nameValue, dataValue, nil +} + func extractLyricsFromSidecarLRC(filePath string) (string, error) { ext := filepath.Ext(filePath) base := strings.TrimSuffix(filePath, ext) @@ -743,15 +1044,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) { return AudioQuality{}, err } - buf := make([]byte, 24) + buf := make([]byte, 32) if _, err := f.ReadAt(buf, sampleOffset); err != nil { return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err) } - sampleRate := int(buf[22])<<8 | int(buf[23]) - bitDepth := 16 - if atomType == "alac" { - bitDepth = 24 + // AudioSampleEntry layout from the box type field: + // [0:4] type ("mp4a"/"alac") + // [4:10] SampleEntry.reserved + // [10:12] data_reference_index + // [12:20] reserved[8] + // [20:22] channelcount + // [22:24] samplesize (bit depth) + // [24:26] pre_defined + // [26:28] reserved + // [28:32] samplerate (16.16 fixed-point) + sampleRate := int(buf[28])<<8 | int(buf[29]) + bitDepth := int(buf[22])<<8 | int(buf[23]) + if bitDepth <= 0 { + bitDepth = 16 + if atomType == "alac" { + bitDepth = 24 + } } return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil @@ -874,7 +1188,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string if bestIdx >= 0 { absolute := readPos - int64(len(tail)) + int64(bestIdx) - if absolute+24 > fileSize { + if absolute+32 > fileSize { return 0, "", fmt.Errorf("audio info not found in M4A file") } return absolute, bestType, nil diff --git a/go_backend/progress.go b/go_backend/progress.go index 95ba3fde..2117d242 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -34,10 +34,16 @@ var ( downloadDir string downloadDirMu sync.RWMutex - multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} - multiMu sync.RWMutex + multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} + multiMu sync.RWMutex + multiProgressDirty = true + cachedMultiProgress = "{\"items\":{}}" ) +func markMultiProgressDirtyLocked() { + multiProgressDirty = true +} + func getProgress() DownloadProgress { multiMu.RLock() defer multiMu.RUnlock() @@ -58,13 +64,25 @@ func getProgress() DownloadProgress { func GetMultiProgress() string { multiMu.RLock() - defer multiMu.RUnlock() + if !multiProgressDirty { + cached := cachedMultiProgress + multiMu.RUnlock() + return cached + } + multiMu.RUnlock() + multiMu.Lock() + defer multiMu.Unlock() + if !multiProgressDirty { + return cachedMultiProgress + } jsonBytes, err := json.Marshal(multiProgress) if err != nil { return "{\"items\":{}}" } - return string(jsonBytes) + cachedMultiProgress = string(jsonBytes) + multiProgressDirty = false + return cachedMultiProgress } func GetItemProgress(itemID string) string { @@ -90,6 +108,7 @@ func StartItemProgress(itemID string) { IsDownloading: true, Status: "downloading", } + markMultiProgressDirtyLocked() } func SetItemBytesTotal(itemID string, total int64) { @@ -98,6 +117,7 @@ func SetItemBytesTotal(itemID string, total int64) { if item, ok := multiProgress.Items[itemID]; ok { item.BytesTotal = total + markMultiProgressDirtyLocked() } } @@ -110,6 +130,7 @@ func SetItemBytesReceived(itemID string, received int64) { if item.BytesTotal > 0 { item.Progress = float64(received) / float64(item.BytesTotal) } + markMultiProgressDirtyLocked() } } @@ -123,6 +144,7 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa if item.BytesTotal > 0 { item.Progress = float64(received) / float64(item.BytesTotal) } + markMultiProgressDirtyLocked() } } @@ -134,6 +156,7 @@ func CompleteItemProgress(itemID string) { item.Progress = 1.0 item.IsDownloading = false item.Status = "completed" + markMultiProgressDirtyLocked() } } @@ -149,6 +172,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal if bytesTotal > 0 { item.BytesTotal = bytesTotal } + markMultiProgressDirtyLocked() } } @@ -159,6 +183,7 @@ func SetItemFinalizing(itemID string) { if item, ok := multiProgress.Items[itemID]; ok { item.Progress = 1.0 item.Status = "finalizing" + markMultiProgressDirtyLocked() } } @@ -167,6 +192,7 @@ func RemoveItemProgress(itemID string) { defer multiMu.Unlock() delete(multiProgress.Items, itemID) + markMultiProgressDirtyLocked() } func ClearAllItemProgress() { @@ -174,6 +200,7 @@ func ClearAllItemProgress() { defer multiMu.Unlock() multiProgress.Items = make(map[string]*ItemProgress) + markMultiProgressDirtyLocked() } func setDownloadDir(path string) error { diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 092c0e96..cba711b0 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -28,21 +28,41 @@ type QobuzDownloader struct { var ( globalQobuzDownloader *QobuzDownloader qobuzDownloaderOnce sync.Once + qobuzGetTrackByIDFunc = func(q *QobuzDownloader, trackID int64) (*QobuzTrack, error) { + return q.GetTrackByID(trackID) + } + qobuzSearchTrackByISRCWithDurationFunc = func(q *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) { + return q.SearchTrackByISRCWithDuration(isrc, expectedDurationSec) + } + qobuzSearchTrackByMetadataWithDurationFunc = func(q *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { + return q.SearchTrackByMetadataWithDuration(trackName, artistName, expectedDurationSec) + } + songLinkCheckTrackAvailabilityFunc = func(client *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) { + return client.CheckTrackAvailability(spotifyTrackID, isrc) + } ) const ( qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id=" qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query=" + qobuzAlbumGetBaseURL = "https://www.qobuz.com/api.json/0.2/album/get?album_id=" + qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id=" + qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id=" qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/" + qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" - qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" + qobuzStoreBaseURL = "https://www.qobuz.com/us-en" + qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" + qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/" qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id=" qobuzDebugKeyXORMask = byte(0x5A) ) var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`) +var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`) +var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`) var qobuzDebugKeyObfuscated = []byte{ 0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b, @@ -58,23 +78,409 @@ type QobuzTrack struct { ISRC string `json:"isrc"` Duration int `json:"duration"` TrackNumber int `json:"track_number"` + MediaNumber int `json:"media_number"` MaximumBitDepth int `json:"maximum_bit_depth"` MaximumSamplingRate float64 `json:"maximum_sampling_rate"` + Version string `json:"version"` Album struct { + ID string `json:"id"` + QobuzID int64 `json:"qobuz_id"` + TracksCount int `json:"tracks_count"` Title string `json:"title"` ReleaseDate string `json:"release_date_original"` - Image struct { - Large string `json:"large"` + ProductType string `json:"product_type"` + ReleaseType string `json:"release_type"` + Artist struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"artist"` + Artists []qobuzArtistRef `json:"artists"` + Image struct { + Thumbnail string `json:"thumbnail"` + Small string `json:"small"` + Large string `json:"large"` } `json:"image"` } `json:"album"` Performer struct { + ID int64 `json:"id"` Name string `json:"name"` } `json:"performer"` } +type qobuzImageSet struct { + Thumbnail string `json:"thumbnail"` + Small string `json:"small"` + Large string `json:"large"` +} + +type qobuzArtistRef struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type qobuzLabelRef struct { + Name string `json:"name"` +} + +type qobuzGenreRef struct { + Name string `json:"name"` +} + +type qobuzAlbumDetails struct { + ID string `json:"id"` + QobuzID int64 `json:"qobuz_id"` + Title string `json:"title"` + ReleaseDateOriginal string `json:"release_date_original"` + TracksCount int `json:"tracks_count"` + ProductType string `json:"product_type"` + ReleaseType string `json:"release_type"` + Image qobuzImageSet `json:"image"` + Artist qobuzArtistRef `json:"artist"` + Artists []qobuzArtistRef `json:"artists"` + Genre qobuzGenreRef `json:"genre"` + Label qobuzLabelRef `json:"label"` + Copyright string `json:"copyright"` + Tracks struct { + Items []QobuzTrack `json:"items"` + } `json:"tracks"` +} + +type qobuzArtistDetails struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Image qobuzImageSet `json:"image"` +} + +type qobuzPlaylistDetails struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ImageRectangle []string `json:"image_rectangle"` + ImageRectangleMini []string `json:"image_rectangle_mini"` + TracksCount int `json:"tracks_count"` + Owner struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"owner"` + Tracks struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Items []QobuzTrack `json:"items"` + } `json:"tracks"` +} + +func qobuzFirstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func qobuzPrefixedID(id string) string { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return "" + } + if strings.HasPrefix(trimmed, "qobuz:") { + return trimmed + } + return "qobuz:" + trimmed +} + +func qobuzPrefixedNumericID(id int64) string { + if id <= 0 { + return "" + } + return fmt.Sprintf("qobuz:%d", id) +} + +func qobuzNormalizeReleaseDate(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + if _, err := time.Parse("2006-01-02", trimmed); err == nil { + return trimmed + } + if parsed, err := time.Parse("Jan 2, 2006", trimmed); err == nil { + return parsed.Format("2006-01-02") + } + return trimmed +} + +func qobuzNormalizeAlbumType(releaseType, productType string, totalTracks int) string { + kind := strings.ToLower(strings.TrimSpace(releaseType)) + if kind == "" { + kind = strings.ToLower(strings.TrimSpace(productType)) + } + switch kind { + case "album", "single", "ep", "compilation": + return kind + } + if totalTracks > 0 && totalTracks <= 3 { + return "single" + } + return "album" +} + +func qobuzArtistsDisplayName(artists []qobuzArtistRef, fallback string) string { + names := make([]string, 0, len(artists)) + seen := make(map[string]struct{}, len(artists)) + for _, artist := range artists { + name := strings.TrimSpace(artist.Name) + if name == "" { + continue + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + names = append(names, name) + } + if len(names) == 0 { + return strings.TrimSpace(fallback) + } + return strings.Join(names, ", ") +} + +func qobuzTrackDisplayTitle(track *QobuzTrack) string { + if track == nil { + return "" + } + title := strings.TrimSpace(track.Title) + version := strings.TrimSpace(track.Version) + if title == "" || version == "" { + return title + } + return fmt.Sprintf("%s (%s)", title, version) +} + +func qobuzTrackAlbumImage(track *QobuzTrack) string { + if track == nil { + return "" + } + return qobuzFirstNonEmpty( + track.Album.Image.Large, + track.Album.Image.Small, + track.Album.Image.Thumbnail, + ) +} + +func qobuzAlbumImage(album *qobuzAlbumDetails) string { + if album == nil { + return "" + } + return qobuzFirstNonEmpty( + album.Image.Large, + album.Image.Small, + album.Image.Thumbnail, + ) +} + +func qobuzTrackArtistID(track *QobuzTrack) string { + if track == nil { + return "" + } + if track.Performer.ID > 0 { + return qobuzPrefixedNumericID(track.Performer.ID) + } + return qobuzPrefixedNumericID(track.Album.Artist.ID) +} + +func qobuzTrackArtistName(track *QobuzTrack) string { + if track == nil { + return "" + } + return strings.TrimSpace(track.Performer.Name) +} + +func qobuzTrackAlbumArtist(track *QobuzTrack) string { + if track == nil { + return "" + } + return qobuzArtistsDisplayName(track.Album.Artists, track.Album.Artist.Name) +} + +func qobuzTrackAlbumType(track *QobuzTrack) string { + if track == nil { + return "album" + } + return qobuzNormalizeAlbumType( + track.Album.ReleaseType, + track.Album.ProductType, + track.Album.TracksCount, + ) +} + +func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata { + if track == nil { + return TrackMetadata{} + } + return TrackMetadata{ + SpotifyID: qobuzPrefixedNumericID(track.ID), + Artists: qobuzTrackArtistName(track), + Name: qobuzTrackDisplayTitle(track), + AlbumName: strings.TrimSpace(track.Album.Title), + AlbumArtist: qobuzTrackAlbumArtist(track), + DurationMS: track.Duration * 1000, + Images: qobuzTrackAlbumImage(track), + ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate), + TrackNumber: track.TrackNumber, + TotalTracks: track.Album.TracksCount, + DiscNumber: track.MediaNumber, + ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID), + ISRC: strings.TrimSpace(track.ISRC), + AlbumID: qobuzPrefixedID(track.Album.ID), + ArtistID: qobuzTrackArtistID(track), + AlbumType: qobuzTrackAlbumType(track), + } +} + +func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata { + if track == nil { + return AlbumTrackMetadata{} + } + return AlbumTrackMetadata{ + SpotifyID: qobuzPrefixedNumericID(track.ID), + Artists: qobuzTrackArtistName(track), + Name: qobuzTrackDisplayTitle(track), + AlbumName: strings.TrimSpace(track.Album.Title), + AlbumArtist: qobuzTrackAlbumArtist(track), + DurationMS: track.Duration * 1000, + Images: qobuzTrackAlbumImage(track), + ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate), + TrackNumber: track.TrackNumber, + TotalTracks: track.Album.TracksCount, + DiscNumber: track.MediaNumber, + ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID), + ISRC: strings.TrimSpace(track.ISRC), + AlbumID: qobuzPrefixedID(track.Album.ID), + AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)), + AlbumType: qobuzTrackAlbumType(track), + } +} + +func qobuzAlbumToAlbumInfo(album *qobuzAlbumDetails) AlbumInfoMetadata { + if album == nil { + return AlbumInfoMetadata{} + } + return AlbumInfoMetadata{ + TotalTracks: album.TracksCount, + Name: strings.TrimSpace(album.Title), + ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), + Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), + ArtistId: qobuzPrefixedNumericID(album.Artist.ID), + Images: qobuzAlbumImage(album), + Genre: strings.TrimSpace(album.Genre.Name), + Label: strings.TrimSpace(album.Label.Name), + Copyright: strings.TrimSpace(album.Copyright), + } +} + +func qobuzAlbumToArtistAlbum(album *qobuzAlbumDetails) ArtistAlbumMetadata { + if album == nil { + return ArtistAlbumMetadata{} + } + return ArtistAlbumMetadata{ + ID: qobuzPrefixedID(album.ID), + Name: strings.TrimSpace(album.Title), + ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), + TotalTracks: album.TracksCount, + Images: qobuzAlbumImage(album), + AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount), + Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), + } +} + +func qobuzSplitPathSegments(path string) []string { + rawSegments := strings.Split(strings.TrimSpace(path), "/") + segments := make([]string, 0, len(rawSegments)) + for _, segment := range rawSegments { + trimmed := strings.TrimSpace(segment) + if trimmed == "" { + continue + } + segments = append(segments, trimmed) + } + if len(segments) > 0 && qobuzLocaleSegmentRegex.MatchString(strings.ToLower(segments[0])) { + return segments[1:] + } + return segments +} + +func qobuzResourceTypeFromSegment(segment string) string { + switch strings.ToLower(strings.TrimSpace(segment)) { + case "album": + return "album" + case "interpreter", "artist": + return "artist" + case "playlist", "playlists": + return "playlist" + case "track": + return "track" + default: + return "" + } +} + +func parseQobuzURL(input string) (string, string, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return "", "", fmt.Errorf("empty Qobuz URL") + } + + if strings.HasPrefix(strings.ToLower(raw), "qobuzapp://") { + parsed, err := url.Parse(raw) + if err != nil { + return "", "", err + } + resourceType := qobuzResourceTypeFromSegment(parsed.Host) + resourceID := strings.Trim(strings.TrimSpace(parsed.Path), "/") + if resourceType == "" || resourceID == "" { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + return resourceType, resourceID, nil + } + + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + if !strings.Contains(raw, "://") { + parsed, err = url.Parse("https://" + raw) + } + } + if err != nil || parsed == nil || parsed.Host == "" { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + + host := strings.ToLower(parsed.Host) + if host != "qobuz.com" && host != "www.qobuz.com" && host != "play.qobuz.com" { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + + segments := qobuzSplitPathSegments(parsed.Path) + if len(segments) < 2 { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + + resourceType := qobuzResourceTypeFromSegment(segments[0]) + resourceID := strings.TrimSpace(segments[len(segments)-1]) + if resourceType == "" || resourceID == "" { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + + return resourceType, resourceID, nil +} + func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { - normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) - normFound := strings.ToLower(strings.TrimSpace(foundArtist)) + normExpected := normalizeLooseArtistName(expectedArtist) + normFound := normalizeLooseArtistName(foundArtist) if normExpected == normFound { return true @@ -386,9 +792,239 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { return &track, nil } +func (q *QobuzDownloader) getQobuzJSON(requestURL string, target interface{}) error { + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) + } + + return json.NewDecoder(resp.Body).Decode(target) +} + +func (q *QobuzDownloader) getQobuzBody(requestURL string) ([]byte, error) { + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) + } + + return io.ReadAll(resp.Body) +} + +func extractQobuzAlbumIDsFromArtistHTML(body []byte) []string { + matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1) + if len(matches) == 0 { + return nil + } + + albumIDs := make([]string, 0, len(matches)) + seen := make(map[string]struct{}, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + albumID := strings.TrimSpace(string(match[1])) + if albumID == "" { + continue + } + if _, ok := seen[albumID]; ok { + continue + } + seen[albumID] = struct{}{} + albumIDs = append(albumIDs, albumID) + } + return albumIDs +} + +func (q *QobuzDownloader) getAlbumDetails(albumID string) (*qobuzAlbumDetails, error) { + requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)), q.appID) + var album qobuzAlbumDetails + if err := q.getQobuzJSON(requestURL, &album); err != nil { + return nil, err + } + return &album, nil +} + +func (q *QobuzDownloader) getArtistDetails(artistID string) (*qobuzArtistDetails, error) { + requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)), q.appID) + var artist qobuzArtistDetails + if err := q.getQobuzJSON(requestURL, &artist); err != nil { + return nil, err + } + return &artist, nil +} + +func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) { + requestURL := fmt.Sprintf( + "%s%s&extra=tracks&limit=%d&offset=%d&app_id=%s", + qobuzPlaylistGetBaseURL, + url.QueryEscape(strings.TrimSpace(playlistID)), + limit, + offset, + q.appID, + ) + var playlist qobuzPlaylistDetails + if err := q.getQobuzJSON(requestURL, &playlist); err != nil { + return nil, err + } + return &playlist, nil +} + +func (q *QobuzDownloader) getArtistAlbumIDs(artistID string) ([]string, error) { + artist, err := q.getArtistDetails(artistID) + if err != nil { + return nil, err + } + + slug := strings.TrimSpace(artist.Slug) + if slug == "" { + slug = "artist" + } + requestURL := fmt.Sprintf("%s/interpreter/%s/%d", qobuzStoreBaseURL, url.PathEscape(slug), artist.ID) + body, err := q.getQobuzBody(requestURL) + if err != nil { + return nil, err + } + + albumIDs := extractQobuzAlbumIDsFromArtistHTML(body) + if len(albumIDs) == 0 { + return nil, fmt.Errorf("artist page did not contain album IDs") + } + return albumIDs, nil +} + +func (q *QobuzDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) { + trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64) + if err != nil || trackID <= 0 { + return nil, fmt.Errorf("invalid Qobuz track ID: %s", resourceID) + } + + track, err := q.GetTrackByID(trackID) + if err != nil { + return nil, err + } + + return &TrackResponse{Track: qobuzTrackToTrackMetadata(track)}, nil +} + +func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) { + album, err := q.getAlbumDetails(resourceID) + if err != nil { + return nil, err + } + + tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items)) + for i := range album.Tracks.Items { + tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i])) + } + + return &AlbumResponsePayload{ + AlbumInfo: qobuzAlbumToAlbumInfo(album), + TrackList: tracks, + }, nil +} + +func (q *QobuzDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) { + const pageSize = 50 + + offset := 0 + var playlistInfo PlaylistInfoMetadata + tracks := make([]AlbumTrackMetadata, 0, pageSize) + + for { + page, err := q.getPlaylistDetailsPage(resourceID, pageSize, offset) + if err != nil { + return nil, err + } + + if offset == 0 { + total := page.Tracks.Total + if total == 0 { + total = page.TracksCount + } + playlistInfo.Tracks.Total = total + playlistInfo.Owner.DisplayName = strings.TrimSpace(page.Owner.Name) + playlistInfo.Owner.Name = strings.TrimSpace(page.Name) + playlistInfo.Owner.Images = qobuzFirstNonEmpty(page.ImageRectangle...) + } + + for i := range page.Tracks.Items { + tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&page.Tracks.Items[i])) + } + + if len(page.Tracks.Items) == 0 || + offset+len(page.Tracks.Items) >= playlistInfo.Tracks.Total || + len(page.Tracks.Items) < pageSize { + break + } + offset += len(page.Tracks.Items) + } + + return &PlaylistResponsePayload{ + PlaylistInfo: playlistInfo, + TrackList: tracks, + }, nil +} + +func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) { + artist, err := q.getArtistDetails(resourceID) + if err != nil { + return nil, err + } + + albumIDs, err := q.getArtistAlbumIDs(resourceID) + if err != nil { + return nil, err + } + + albums := make([]ArtistAlbumMetadata, 0, len(albumIDs)) + for _, albumID := range albumIDs { + album, albumErr := q.getAlbumDetails(albumID) + if albumErr != nil { + GoLog("[Qobuz] Skipping artist album %s: %v\n", albumID, albumErr) + continue + } + albums = append(albums, qobuzAlbumToArtistAlbum(album)) + } + + return &ArtistResponsePayload{ + ArtistInfo: ArtistInfoMetadata{ + ID: qobuzPrefixedNumericID(artist.ID), + Name: strings.TrimSpace(artist.Name), + Images: qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail), + }, + Albums: albums, + }, nil +} + func (q *QobuzDownloader) GetAvailableAPIs() []string { return []string{ qobuzDownloadAPIURL, + qobuzDabMusicAPIURL, + qobuzDeebAPIURL, + qobuzAfkarAPIURL, + qobuzSquidAPIURL, } } @@ -409,6 +1045,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, // "deeb" is mapped from the legacy reference fallback endpoint. {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, + // "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=... + {Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard}, {Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard}, } } @@ -648,6 +1286,155 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (* return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) } +func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) { + cleanQuery := strings.TrimSpace(query) + if cleanQuery == "" { + return nil, fmt.Errorf("empty qobuz search query") + } + if limit <= 0 { + limit = 20 + } + + tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, limit) + if err != nil { + return nil, err + } + + results := make([]ExtTrackMetadata, 0, len(tracks)) + for i := range tracks { + results = append(results, normalizeBuiltInMetadataTrack(qobuzTrackToTrackMetadata(&tracks[i]), "qobuz")) + } + return results, nil +} + +// SearchAll searches Qobuz for tracks, artists, and albums matching the query. +// Returns results in the same SearchAllResult format as Deezer's SearchAll. +func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { + GoLog("[Qobuz] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) + + cleanQuery := strings.TrimSpace(query) + if cleanQuery == "" { + return nil, fmt.Errorf("empty qobuz search query") + } + + albumLimit := 5 + + if filter != "" { + switch filter { + case "track": + trackLimit = 50 + artistLimit = 0 + albumLimit = 0 + case "artist": + trackLimit = 0 + artistLimit = 20 + albumLimit = 0 + case "album": + trackLimit = 0 + artistLimit = 0 + albumLimit = 20 + } + } + + result := &SearchAllResult{ + Tracks: make([]TrackMetadata, 0, trackLimit), + Artists: make([]SearchArtistResult, 0, artistLimit), + Albums: make([]SearchAlbumResult, 0, albumLimit), + Playlists: make([]SearchPlaylistResult, 0), + } + + if trackLimit > 0 { + tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, trackLimit) + if err != nil { + GoLog("[Qobuz] Track search failed: %v\n", err) + return nil, fmt.Errorf("qobuz track search failed: %w", err) + } + GoLog("[Qobuz] Got %d tracks from API\n", len(tracks)) + for i := range tracks { + result.Tracks = append(result.Tracks, qobuzTrackToTrackMetadata(&tracks[i])) + } + } + + if artistLimit > 0 { + searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s", + url.QueryEscape(cleanQuery), artistLimit, q.appID) + req, err := http.NewRequest("GET", searchURL, nil) + if err == nil { + resp, reqErr := DoRequestWithUserAgent(q.client, req) + if reqErr == nil { + defer resp.Body.Close() + if resp.StatusCode == 200 { + var artistResp struct { + Artists struct { + Items []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Image qobuzImageSet `json:"image"` + } `json:"items"` + } `json:"artists"` + } + if decErr := json.NewDecoder(resp.Body).Decode(&artistResp); decErr == nil { + GoLog("[Qobuz] Got %d artists from API\n", len(artistResp.Artists.Items)) + for _, artist := range artistResp.Artists.Items { + imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail) + result.Artists = append(result.Artists, SearchArtistResult{ + ID: qobuzPrefixedNumericID(artist.ID), + Name: strings.TrimSpace(artist.Name), + Images: imageURL, + }) + } + } else { + GoLog("[Qobuz] Artist search decode failed: %v\n", decErr) + } + } + } else { + GoLog("[Qobuz] Artist search request failed: %v\n", reqErr) + } + } + } + + if albumLimit > 0 { + searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s", + url.QueryEscape(cleanQuery), albumLimit, q.appID) + req, err := http.NewRequest("GET", searchURL, nil) + if err == nil { + resp, reqErr := DoRequestWithUserAgent(q.client, req) + if reqErr == nil { + defer resp.Body.Close() + if resp.StatusCode == 200 { + var albumResp struct { + Albums struct { + Items []qobuzAlbumDetails `json:"items"` + } `json:"albums"` + } + if decErr := json.NewDecoder(resp.Body).Decode(&albumResp); decErr == nil { + GoLog("[Qobuz] Got %d albums from API\n", len(albumResp.Albums.Items)) + for i := range albumResp.Albums.Items { + album := &albumResp.Albums.Items[i] + result.Albums = append(result.Albums, SearchAlbumResult{ + ID: qobuzPrefixedID(album.ID), + Name: strings.TrimSpace(album.Title), + Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), + Images: qobuzAlbumImage(album), + ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), + TotalTracks: album.TracksCount, + AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount), + }) + } + } else { + GoLog("[Qobuz] Album search decode failed: %v\n", decErr) + } + } + } else { + GoLog("[Qobuz] Album search request failed: %v\n", reqErr) + } + } + } + + GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums)) + return result, nil +} + func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { queries := []string{} @@ -791,6 +1578,39 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) } +func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool { + if track == nil { + return false + } + + if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { + GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n", + logPrefix, source, req.ArtistName, track.Performer.Name) + return false + } + + if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) { + GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n", + logPrefix, source, req.TrackName, track.Title) + return false + } + + expectedDurationSec := req.DurationMS / 1000 + if expectedDurationSec > 0 && track.Duration > 0 { + durationDiff := track.Duration - expectedDurationSec + if durationDiff < 0 { + durationDiff = -durationDiff + } + if durationDiff > 10 { + GoLog("[%s] Duration mismatch from %s: expected %ds, got %ds. Rejecting.\n", + logPrefix, source, expectedDurationSec, track.Duration) + return false + } + } + + return true +} + func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]QobuzTrack, error) { searchURL := fmt.Sprintf("%s%s&limit=%d&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(query), limit, q.appID) req, err := http.NewRequest("GET", searchURL, nil) @@ -940,19 +1760,23 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "") } +func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) { + requestQuality := mapQobuzQualityCodeToAPI(quality) + payload := map[string]any{ + "quality": requestQuality, + "upload_to_r2": false, + "url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID), + } + return json.Marshal(payload) +} + func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) { var lastErr error retryDelay := qobuzRetryDelay var payloadBytes []byte if provider.Kind == qobuzAPIKindMusicDL { - requestQuality := mapQobuzQualityCodeToAPI(quality) - payload := map[string]any{ - "quality": requestQuality, - "upload_to_r2": false, - "url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID), - } var err error - payloadBytes, err = json.Marshal(payload) + payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality) if err != nil { return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err) } @@ -997,7 +1821,6 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit } if provider.Kind == qobuzAPIKindMusicDL { req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Debug-Key", getQobuzDebugKey()) } resp, err := DoRequestWithUserAgent(client, req) @@ -1244,9 +2067,23 @@ type QobuzDownloadResult struct { TrackNumber int DiscNumber int ISRC string + CoverURL string LyricsLRC string } +func parseQobuzRequestTrackID(raw string) int64 { + trimmed := strings.TrimSpace(raw) + trimmed = strings.TrimPrefix(trimmed, "qobuz:") + if trimmed == "" { + return 0 + } + var trackID int64 + if _, err := fmt.Sscanf(trimmed, "%d", &trackID); err != nil || trackID <= 0 { + return 0 + } + return trackID +} + func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) { if downloader == nil { downloader = NewQobuzDownloader() @@ -1260,17 +2097,20 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade var track *QobuzTrack var err error - // Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate) + // Strategy 1: Use Qobuz ID from request payload (fastest, most accurate) if req.QobuzID != "" { - GoLog("[%s] Using Qobuz ID from Odesli enrichment: %s\n", logPrefix, req.QobuzID) - var trackID int64 - if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { - track, err = downloader.GetTrackByID(trackID) + GoLog("[%s] Using Qobuz ID from request payload: %s\n", logPrefix, req.QobuzID) + if trackID := parseQobuzRequestTrackID(req.QobuzID); trackID > 0 { + track, err = qobuzGetTrackByIDFunc(downloader, trackID) if err != nil { - GoLog("[%s] Failed to get track by Odesli ID %d: %v\n", logPrefix, trackID, err) + GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - GoLog("[%s] Successfully found track via Odesli ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) + if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") { + GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) + } else { + track = nil + } } } } @@ -1279,10 +2119,12 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID) - track, err = downloader.GetTrackByID(cached.QobuzTrackID) + track, err = qobuzGetTrackByIDFunc(downloader, cached.QobuzTrackID) if err != nil { GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err) track = nil + } else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") { + track = nil } } } @@ -1291,19 +2133,23 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade if track == nil && req.SpotifyID != "" && req.QobuzID == "" { GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID) songLinkClient := NewSongLinkClient() - availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC) + availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC) if slErr == nil && availability != nil && availability.QobuzID != "" { var trackID int64 if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID) - track, err = downloader.GetTrackByID(trackID) + track, err = qobuzGetTrackByIDFunc(downloader, trackID) if err != nil { GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) - if req.ISRC != "" { - GetTrackIDCache().SetQobuz(req.ISRC, track.ID) + if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") { + GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) + if req.ISRC != "" { + GetTrackIDCache().SetQobuz(req.ISRC, track.ID) + } + } else { + track = nil } } } @@ -1313,27 +2159,17 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade // Strategy 4: ISRC search with duration verification if track == nil && req.ISRC != "" { GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC) - track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) - if track != nil { - if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[%s] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - logPrefix, req.ArtistName, track.Performer.Name) - track = nil - } else if !qobuzTitlesMatch(req.TrackName, track.Title) { - GoLog("[%s] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - logPrefix, req.TrackName, track.Title) - track = nil - } + track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec) + if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") { + track = nil } } // Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds) if track == nil { GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName) - track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) - if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[%s] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", - logPrefix, req.ArtistName, track.Performer.Name) + track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec) + if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") { track = nil } } @@ -1425,7 +2261,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { parallelDone := make(chan struct{}) go func() { defer close(parallelDone) - coverURL := req.CoverURL + coverURL := strings.TrimSpace(req.CoverURL) + if coverURL == "" { + coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track)) + } embedLyrics := req.EmbedLyrics if !req.EmbedMetadata { coverURL = "" @@ -1460,6 +2299,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { if req.AlbumName != "" { albumName = req.AlbumName } + releaseDate := track.Album.ReleaseDate + if req.ReleaseDate != "" { + releaseDate = req.ReleaseDate + } actualTrackNumber := req.TrackNumber if actualTrackNumber == 0 { @@ -1471,7 +2314,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { Artist: track.Performer.Name, Album: albumName, AlbumArtist: req.AlbumArtist, - Date: track.Album.ReleaseDate, + Date: releaseDate, TrackNumber: actualTrackNumber, TotalTracks: req.TotalTracks, DiscNumber: req.DiscNumber, @@ -1535,17 +2378,26 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { lyricsLRC = parallelResult.LyricsLRC } + resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata( + req, + track.Album.Title, + track.Album.ReleaseDate, + actualTrackNumber, + req.DiscNumber, + ) + return QobuzDownloadResult{ FilePath: outputPath, BitDepth: actualBitDepth, SampleRate: actualSampleRate, Title: track.Title, Artist: track.Performer.Name, - Album: track.Album.Title, - ReleaseDate: track.Album.ReleaseDate, - TrackNumber: actualTrackNumber, - DiscNumber: req.DiscNumber, + Album: resultAlbum, + ReleaseDate: resultReleaseDate, + TrackNumber: resultTrackNumber, + DiscNumber: resultDiscNumber, ISRC: track.ISRC, + CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)), LyricsLRC: lyricsLRC, }, nil } diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index 0382b62f..df3b84ac 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -1,6 +1,98 @@ package gobackend -import "testing" +import ( + "encoding/json" + "testing" +) + +func TestParseQobuzURL(t *testing.T) { + tests := []struct { + name string + input string + wantType string + wantID string + expectErr bool + }{ + { + name: "store album url", + input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985", + wantType: "album", + wantID: "0886446451985", + }, + { + name: "store playlist url", + input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430", + wantType: "playlist", + wantID: "2049430", + }, + { + name: "store artist url", + input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886", + wantType: "artist", + wantID: "729886", + }, + { + name: "play track url", + input: "https://play.qobuz.com/track/40681594", + wantType: "track", + wantID: "40681594", + }, + { + name: "custom scheme playlist url", + input: "qobuzapp://playlist/2049430", + wantType: "playlist", + wantID: "2049430", + }, + { + name: "unsupported url", + input: "https://example.com/not-qobuz", + expectErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotType, gotID, err := parseQobuzURL(test.input) + if test.expectErr { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if gotType != test.wantType || gotID != test.wantID { + t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID) + } + }) + } +} + +func TestExtractQobuzArtistAlbumIDs(t *testing.T) { + body := []byte(` +
+ +
+
+ +
+
+ +
+`) + + matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1) + if len(matches) != 3 { + t.Fatalf("expected 3 regex matches, got %d", len(matches)) + } + if string(matches[0][1]) != "yrpbt0lwm3g0y" { + t.Fatalf("unexpected first album id: %q", matches[0][1]) + } + if string(matches[2][1]) != "0886446451985" { + t.Fatalf("unexpected last album id: %q", matches[2][1]) + } +} func TestExtractQobuzDownloadURLFromBody(t *testing.T) { t.Run("reads top-level download_url and quality metadata", func(t *testing.T) { @@ -106,16 +198,56 @@ func TestGetQobuzDebugKey(t *testing.T) { } } +func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) { + payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7") + if err != nil { + t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + + if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" { + t.Fatalf("payload url = %v, want open.qobuz.com track URL", got) + } + if got := payload["quality"]; got != "hi-res" { + t.Fatalf("payload quality = %v, want hi-res", got) + } + if got := payload["upload_to_r2"]; got != false { + t.Fatalf("payload upload_to_r2 = %v, want false", got) + } +} + +func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) { + body := []byte(` + + + + `) + + got := extractQobuzAlbumIDsFromArtistHTML(body) + if len(got) != 2 { + t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got) + } + if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" { + t.Fatalf("unexpected album IDs: %v", got) + } +} + func TestQobuzAvailableProviders(t *testing.T) { providers := NewQobuzDownloader().GetAvailableProviders() - if len(providers) != 3 { - t.Fatalf("expected 3 Qobuz providers, got %d", len(providers)) + if len(providers) != 5 { + t.Fatalf("expected 5 Qobuz providers, got %d", len(providers)) } want := map[string]string{ "musicdl": qobuzAPIKindMusicDL, "dabmusic": qobuzAPIKindStandard, "deeb": qobuzAPIKindStandard, + "qbz": qobuzAPIKindStandard, + "squid": qobuzAPIKindStandard, } for _, provider := range providers { @@ -133,3 +265,174 @@ func TestQobuzAvailableProviders(t *testing.T) { t.Fatalf("missing providers: %v", want) } } + +func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack { + track := &QobuzTrack{ + ID: id, + Title: title, + Duration: duration, + } + track.Performer.Name = artist + return track +} + +func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) { + origGetTrackByID := qobuzGetTrackByIDFunc + origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc + origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc + origSongLinkCheck := songLinkCheckTrackAvailabilityFunc + t.Cleanup(func() { + qobuzGetTrackByIDFunc = origGetTrackByID + qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC + qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata + songLinkCheckTrackAvailabilityFunc = origSongLinkCheck + GetTrackIDCache().Clear() + }) + GetTrackIDCache().Clear() + + qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) { + if trackID != 111 { + t.Fatalf("unexpected track ID lookup: %d", trackID) + } + return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil + } + qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) { + if isrc != "TESTISRC1" { + t.Fatalf("unexpected ISRC lookup: %q", isrc) + } + if expectedDurationSec != 180 { + t.Fatalf("unexpected duration: %d", expectedDurationSec) + } + return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil + } + qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) { + t.Fatal("metadata fallback should not run when ISRC fallback succeeds") + return nil, nil + } + songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) { + if spotifyTrackID != "spotify-track-id" { + t.Fatalf("unexpected spotify ID: %q", spotifyTrackID) + } + if isrc != "TESTISRC1" { + t.Fatalf("unexpected SongLink ISRC: %q", isrc) + } + return &TrackAvailability{QobuzID: "111"}, nil + } + + req := DownloadRequest{ + ISRC: "TESTISRC1", + SpotifyID: "spotify-track-id", + TrackName: "Taste Back", + ArtistName: "Harry Styles", + DurationMS: 180000, + } + + track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if track == nil || track.ID != 222 || track.Title != "Taste Back" { + t.Fatalf("unexpected resolved track: %+v", track) + } + + cached := GetTrackIDCache().Get(req.ISRC) + if cached == nil || cached.QobuzTrackID != 222 { + t.Fatalf("expected validated fallback track to be cached, got %+v", cached) + } +} + +func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) { + origGetTrackByID := qobuzGetTrackByIDFunc + origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc + origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc + origSongLinkCheck := songLinkCheckTrackAvailabilityFunc + t.Cleanup(func() { + qobuzGetTrackByIDFunc = origGetTrackByID + qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC + qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata + songLinkCheckTrackAvailabilityFunc = origSongLinkCheck + }) + + qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) { + if trackID != 333 { + t.Fatalf("unexpected track ID lookup: %d", trackID) + } + return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil + } + qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) { + t.Fatal("ISRC fallback should not run without an ISRC") + return nil, nil + } + qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { + if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 { + t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec) + } + return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil + } + songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) { + t.Fatal("SongLink should not run when Odesli QobuzID is provided") + return nil, nil + } + + req := DownloadRequest{ + QobuzID: "333", + TrackName: "Taste Back", + ArtistName: "Harry Styles", + DurationMS: 181000, + } + + track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if track == nil || track.ID != 444 || track.Title != "Taste Back" { + t.Fatalf("unexpected resolved track: %+v", track) + } +} + +func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) { + origGetTrackByID := qobuzGetTrackByIDFunc + origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc + origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc + origSongLinkCheck := songLinkCheckTrackAvailabilityFunc + t.Cleanup(func() { + qobuzGetTrackByIDFunc = origGetTrackByID + qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC + qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata + songLinkCheckTrackAvailabilityFunc = origSongLinkCheck + }) + + qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) { + if trackID != 40681594 { + t.Fatalf("unexpected track ID lookup: %d", trackID) + } + return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil + } + qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) { + t.Fatal("ISRC fallback should not run when request qobuz id succeeds") + return nil, nil + } + qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) { + t.Fatal("metadata fallback should not run when request qobuz id succeeds") + return nil, nil + } + songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) { + t.Fatal("SongLink should not run when request qobuz id is provided") + return nil, nil + } + + req := DownloadRequest{ + QobuzID: "qobuz:40681594", + TrackName: "Sign of the Times", + ArtistName: "Harry Styles", + DurationMS: 341000, + } + + track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if track == nil || track.ID != 40681594 { + t.Fatalf("unexpected resolved track: %+v", track) + } +} diff --git a/go_backend/security_hardening_test.go b/go_backend/security_hardening_test.go deleted file mode 100644 index 559ccbd8..00000000 --- a/go_backend/security_hardening_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package gobackend - -import ( - "path/filepath" - "strings" - "testing" -) - -func TestSanitizeSensitiveLogText(t *testing.T) { - input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz" - redacted := sanitizeSensitiveLogText(input) - - if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") { - t.Fatalf("expected sensitive values to be redacted, got: %s", redacted) - } - if !strings.Contains(redacted, "[REDACTED]") { - t.Fatalf("expected redaction marker in output, got: %s", redacted) - } -} - -func TestValidateExtensionAuthURL(t *testing.T) { - if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil { - t.Fatalf("expected valid auth URL, got error: %v", err) - } - - blocked := []string{ - "http://accounts.example.com/oauth/authorize", - "https://user:pass@accounts.example.com/oauth/authorize", - "https://localhost/oauth/authorize", - } - - for _, rawURL := range blocked { - if err := validateExtensionAuthURL(rawURL); err == nil { - t.Fatalf("expected URL to be blocked: %s", rawURL) - } - } -} - -func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) { - ext := &LoadedExtension{ - ID: "test-ext", - Manifest: &ExtensionManifest{ - Name: "test-ext", - Permissions: ExtensionPermissions{ - Network: []string{"api.example.com"}, - }, - }, - DataDir: t.TempDir(), - } - - runtime := NewExtensionRuntime(ext) - if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil { - t.Fatal("expected embedded URL credentials to be rejected") - } -} - -func TestBuildStoreExtensionDestPath(t *testing.T) { - baseDir := t.TempDir() - - destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name") - if err != nil { - t.Fatalf("expected sanitized path to be generated, got error: %v", err) - } - - if !isPathWithinBase(baseDir, destPath) { - t.Fatalf("expected destination path to remain under base dir: %s", destPath) - } - - baseName := filepath.Base(destPath) - if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) { - t.Fatalf("expected filename to be sanitized, got: %s", baseName) - } - if !strings.HasSuffix(baseName, ".spotiflac-ext") { - t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName) - } - - if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil { - t.Fatal("expected empty extension id to be rejected") - } -} diff --git a/go_backend/songlink.go b/go_backend/songlink.go index f38a8edb..2a6515b8 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -1,6 +1,7 @@ package gobackend import ( + "bytes" "context" "encoding/json" "fmt" @@ -14,6 +15,10 @@ type SongLinkClient struct { client *http.Client } +type songLinkPlatformLink struct { + URL string `json:"url"` +} + type TrackAvailability struct { SpotifyID string `json:"spotify_id"` Tidal bool `json:"tidal"` @@ -43,6 +48,7 @@ var ( songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) { return s.CheckAvailabilityFromDeezer(deezerTrackID) } + songLinkRetryConfig = DefaultRetryConfig ) func NewSongLinkClient() *SongLinkClient { @@ -130,7 +136,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri } func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { - songLinkRateLimiter.WaitForSlot() + availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID) + if pageErr == nil { + return availability, nil + } + + if !songLinkRateLimiter.TryAcquire() { + return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr) + } spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) apiURL := buildSongLinkURLFromTarget(spotifyURL, "") @@ -140,10 +153,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string return nil, fmt.Errorf("failed to create request: %w", err) } - retryConfig := DefaultRetryConfig() + retryConfig := songLinkRetryConfig() resp, err := DoRequestWithRetry(s.client, req, retryConfig) if err != nil { - return nil, fmt.Errorf("failed to check availability: %w", err) + return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err) } defer resp.Body.Close() @@ -154,10 +167,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string return nil, fmt.Errorf("track not found on any streaming platform") } if resp.StatusCode == 429 { - return nil, fmt.Errorf("SongLink rate limit exceeded") + return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr) } if resp.StatusCode != 200 { - return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode) + return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode) } body, err := ReadResponseBody(resp) @@ -166,59 +179,102 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string } var songLinkResp struct { - LinksByPlatform map[string]struct { - URL string `json:"url"` - } `json:"linksByPlatform"` + LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"` } if err := json.Unmarshal(body, &songLinkResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } - availability := &TrackAvailability{ - SpotifyID: spotifyTrackID, + LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr) + return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil +} + +func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) { + pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID) + req, err := http.NewRequest("GET", pageURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create song.link page request: %w", err) } - if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { - availability.Tidal = true - availability.TidalURL = tidalLink.URL - availability.TidalID = extractTidalIDFromURL(tidalLink.URL) + req.Header.Set("Accept", "text/html,application/xhtml+xml") + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch song.link page: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, fmt.Errorf("track not found on song.link page") + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode) } - if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { - availability.Amazon = true - availability.AmazonURL = amazonLink.URL + body, err := ReadResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read song.link page: %w", err) } - if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { - availability.Deezer = true - availability.DeezerURL = deezerLink.URL - availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) + nextDataJSON, err := extractSongLinkNextDataJSON(body) + if err != nil { + return nil, err } - if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" { - availability.Qobuz = true - availability.QobuzURL = qobuzLink.URL - availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) + var pageData struct { + Props struct { + PageProps struct { + PageData struct { + Sections []struct { + Links []struct { + Platform string `json:"platform"` + URL string `json:"url"` + Show bool `json:"show"` + } `json:"links"` + } `json:"sections"` + } `json:"pageData"` + } `json:"pageProps"` + } `json:"props"` + } + if err := json.Unmarshal(nextDataJSON, &pageData); err != nil { + return nil, fmt.Errorf("failed to decode song.link page data: %w", err) } - // Prefer youtubeMusic URLs — they bypass Cobalt login requirements - if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { - availability.YouTube = true - availability.YouTubeURL = ytMusicLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) - } - - // Fallback to regular youtube if youtubeMusic not available - if !availability.YouTube { - if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { - availability.YouTube = true - availability.YouTubeURL = youtubeLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + linksByPlatform := make(map[string]songLinkPlatformLink) + for _, section := range pageData.Props.PageProps.PageData.Sections { + for _, link := range section.Links { + if !link.Show || strings.TrimSpace(link.URL) == "" { + continue + } + linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL} } } - return availability, nil + if len(linksByPlatform) == 0 { + return nil, fmt.Errorf("song.link page contained no usable platform links") + } + + return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil +} + +func extractSongLinkNextDataJSON(body []byte) ([]byte, error) { + const startMarker = `` + + start := bytes.Index(body, []byte(startMarker)) + if start < 0 { + return nil, fmt.Errorf("song.link page missing __NEXT_DATA__") + } + start += len(startMarker) + + end := bytes.Index(body[start:], []byte(endMarker)) + if end < 0 { + return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__") + } + + return body[start : start+end], nil } func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) { @@ -459,7 +515,7 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv return nil, fmt.Errorf("failed to create request: %w", err) } - retryConfig := DefaultRetryConfig() + retryConfig := songLinkRetryConfig() resp, err := DoRequestWithRetry(s.client, req, retryConfig) if err != nil { return nil, fmt.Errorf("failed to check album availability: %w", err) @@ -542,7 +598,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin return nil, fmt.Errorf("failed to create request: %w", err) } - retryConfig := DefaultRetryConfig() + retryConfig := songLinkRetryConfig() resp, err := DoRequestWithRetry(s.client, req, retryConfig) if err != nil { return nil, fmt.Errorf("failed to check availability: %w", err) @@ -647,7 +703,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit return nil, fmt.Errorf("failed to create request: %w", err) } - retryConfig := DefaultRetryConfig() + retryConfig := songLinkRetryConfig() resp, err := DoRequestWithRetry(s.client, req, retryConfig) if err != nil { return nil, fmt.Errorf("failed to check availability: %w", err) @@ -728,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit return availability, nil } +func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability { + availability := &TrackAvailability{ + SpotifyID: spotifyTrackID, + } + + if availability.SpotifyID == "" { + if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" { + availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL) + } + } + if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" { + availability.Tidal = true + availability.TidalURL = tidalLink.URL + availability.TidalID = extractTidalIDFromURL(tidalLink.URL) + } + if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" { + availability.Amazon = true + availability.AmazonURL = amazonLink.URL + } + if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" { + availability.Qobuz = true + availability.QobuzURL = qobuzLink.URL + availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) + } + if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" { + availability.Deezer = true + availability.DeezerURL = deezerLink.URL + availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) + } + if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + } + if !availability.YouTube { + if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" { + availability.YouTube = true + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + } + } + + return availability +} + func extractSpotifyIDFromURL(spotifyURL string) string { parts := strings.Split(spotifyURL, "/track/") if len(parts) > 1 { @@ -802,7 +903,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila return nil, fmt.Errorf("failed to create request: %w", err) } - retryConfig := DefaultRetryConfig() + retryConfig := songLinkRetryConfig() resp, err := DoRequestWithRetry(s.client, req, retryConfig) if err != nil { return nil, fmt.Errorf("failed to check availability: %w", err) diff --git a/go_backend/songlink_test.go b/go_backend/songlink_test.go new file mode 100644 index 00000000..ac085c1b --- /dev/null +++ b/go_backend/songlink_test.go @@ -0,0 +1,127 @@ +package gobackend + +import ( + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) { + resp := &http.Response{ + Header: make(http.Header), + } + + if got := getRetryAfterDuration(resp); got != 0 { + t.Fatalf("getRetryAfterDuration() = %v, want 0", got) + } +} + +func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) { + client := &SongLinkClient{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case req.URL.Host == "api.song.link": + t.Fatalf("api.song.link should not be called when song.link page succeeds") + return nil, nil + case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid": + body := `` + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + default: + t.Fatalf("unexpected request: %s", req.URL.String()) + return nil, nil + } + }), + }, + } + + availability, err := client.CheckTrackAvailability("testspotifyid", "") + if err != nil { + t.Fatalf("CheckTrackAvailability() error = %v", err) + } + + if availability.SpotifyID != "testspotifyid" { + t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid") + } + if !availability.Deezer || availability.DeezerID != "908604612" { + t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability) + } + if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube { + t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability) + } + if availability.YouTubeID != "testvideoid1" { + t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1") + } +} + +func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) { + origRetryConfig := songLinkRetryConfig + songLinkRetryConfig = func() RetryConfig { + return RetryConfig{ + MaxRetries: 0, + InitialDelay: 0, + MaxDelay: 0, + BackoffFactor: 1, + } + } + defer func() { + songLinkRetryConfig = origRetryConfig + }() + + client := &SongLinkClient{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid": + return &http.Response{ + StatusCode: 500, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("page failure")), + Request: req, + }, nil + case req.URL.Host == "api.song.link": + body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}` + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + default: + t.Fatalf("unexpected request: %s", req.URL.String()) + return nil, nil + } + }), + }, + } + + availability, err := client.CheckTrackAvailability("testspotifyid", "") + if err != nil { + t.Fatalf("CheckTrackAvailability() error = %v", err) + } + + if availability.SpotifyID != "testspotifyid" { + t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid") + } + if !availability.Deezer || availability.DeezerID != "908604612" { + t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability) + } + if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube { + t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability) + } + if availability.YouTubeID != "testvideoid1" { + t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1") + } +} diff --git a/go_backend/spotfetch_api.go b/go_backend/spotfetch_api.go index d6bfab19..e12804a4 100644 --- a/go_backend/spotfetch_api.go +++ b/go_backend/spotfetch_api.go @@ -10,7 +10,7 @@ import ( "time" ) -const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api" +const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api" // GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API. // This is used as a fallback when direct Spotify API access is blocked/limited. diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 9728a72f..0514c5d4 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -9,7 +9,6 @@ import ( "math/rand" "net/http" "net/url" - "os" "strings" "sync" "time" @@ -64,45 +63,20 @@ var ( credentialsMu sync.RWMutex ) -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)") +var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead") func SetSpotifyCredentials(clientID, clientSecret string) { credentialsMu.Lock() defer credentialsMu.Unlock() - customClientID = clientID - customClientSecret = clientSecret + customClientID = "" + customClientSecret = "" } func HasSpotifyCredentials() bool { - credentialsMu.RLock() - defer credentialsMu.RUnlock() - - if customClientID != "" && customClientSecret != "" { - return true - } - - if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" { - return true - } - return false } func getCredentials() (string, string, error) { - credentialsMu.RLock() - defer credentialsMu.RUnlock() - - if customClientID != "" && customClientSecret != "" { - return customClientID, customClientSecret, nil - } - - clientID := os.Getenv("SPOTIFY_CLIENT_ID") - clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") - - if clientID != "" && clientSecret != "" { - return clientID, clientSecret, nil - } - return "", "", ErrNoSpotifyCredentials } @@ -183,6 +157,8 @@ type AlbumResponsePayload struct { } type PlaylistInfoMetadata struct { + Name string `json:"name,omitempty"` + Images string `json:"images,omitempty"` Tracks struct { Total int `json:"total"` } `json:"tracks"` diff --git a/go_backend/tidal.go b/go_backend/tidal.go index ea9a8886..34de563e 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "sync" "time" @@ -25,13 +26,25 @@ type TidalDownloader struct { } var ( - globalTidalDownloader *TidalDownloader - tidalDownloaderOnce sync.Once + globalTidalDownloader *TidalDownloader + tidalDownloaderOnce sync.Once + tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) { + return t.getTrackSearchPage(query, limit) + } + tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) { + return t.getPublicTrack(resourceID) + } ) const ( spotifyTrackBaseURL = "https://open.spotify.com/track/" songLinkLookupBaseURL = "https://api.song.link/v1-alpha.1/links?url=" + tidalPublicAPIBaseURL = "https://tidal.com/v1" + tidalPublicToken = "txNoH4kkV41MfH25" + tidalResourceBaseURL = "https://resources.tidal.com" + tidalCountryCode = "US" + tidalLocale = "en_US" + tidalDeviceType = "BROWSER" ) type TidalTrack struct { @@ -43,19 +56,28 @@ type TidalTrack struct { VolumeNumber int `json:"volumeNumber"` Duration int `json:"duration"` Album struct { + ID int64 `json:"id"` Title string `json:"title"` Cover string `json:"cover"` ReleaseDate string `json:"releaseDate"` + URL string `json:"url"` } `json:"album"` Artists []struct { - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Picture string `json:"picture"` } `json:"artists"` Artist struct { - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Picture string `json:"picture"` } `json:"artist"` MediaMetadata struct { Tags []string `json:"tags"` } `json:"mediaMetadata"` + URL string `json:"url"` } type TidalAPIResponseV2 struct { @@ -100,6 +122,105 @@ type MPD struct { } `xml:"Period"` } +type tidalPublicArtist struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Picture string `json:"picture"` +} + +type tidalPublicAlbum struct { + ID int64 `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Cover string `json:"cover"` + ReleaseDate string `json:"releaseDate"` + URL string `json:"url"` + NumberOfTracks int `json:"numberOfTracks"` + Explicit bool `json:"explicit"` + Artists []tidalPublicArtist `json:"artists"` +} + +type tidalPublicAlbumPage struct { + Rows []struct { + Modules []struct { + Type string `json:"type"` + Album tidalPublicAlbum `json:"album"` + PagedList struct { + DataAPIPath string `json:"dataApiPath"` + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []struct { + Item TidalTrack `json:"item"` + Type string `json:"type"` + } `json:"items"` + } `json:"pagedList"` + } `json:"modules"` + } `json:"rows"` +} + +type tidalPublicArtistPage struct { + Rows []struct { + Modules []struct { + Type string `json:"type"` + Title string `json:"title"` + Artist struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Picture string `json:"picture"` + } `json:"artist"` + PagedList struct { + DataAPIPath string `json:"dataApiPath"` + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []tidalPublicAlbum `json:"items"` + } `json:"pagedList"` + } `json:"modules"` + } `json:"rows"` +} + +type tidalPublicArtistAlbumsPage struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []tidalPublicAlbum `json:"items"` +} + +type tidalPublicPlaylist struct { + UUID string `json:"uuid"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + URL string `json:"url"` + Image string `json:"image"` + SquareImage string `json:"squareImage"` + NumberOfTracks int `json:"numberOfTracks"` + Creator struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"creator"` +} + +type tidalPublicPlaylistItemsPage struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []struct { + Item TidalTrack `json:"item"` + Type string `json:"type"` + } `json:"items"` +} + +type tidalPublicTrackSearchResponse struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalTrack `json:"items"` +} + func NewTidalDownloader() *TidalDownloader { tidalDownloaderOnce.Do(func() { globalTidalDownloader = &TidalDownloader{ @@ -114,6 +235,457 @@ func NewTidalDownloader() *TidalDownloader { return globalTidalDownloader } +func tidalPrefixedID(id string) string { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return "" + } + return "tidal:" + trimmed +} + +func tidalPrefixedNumericID(id int64) string { + if id <= 0 { + return "" + } + return fmt.Sprintf("tidal:%d", id) +} + +func tidalImageURL(imageID, size string) string { + normalizedID := strings.TrimSpace(imageID) + if normalizedID == "" || strings.TrimSpace(size) == "" { + return "" + } + return fmt.Sprintf( + "%s/images/%s/%s.jpg", + tidalResourceBaseURL, + strings.ReplaceAll(normalizedID, "-", "/"), + size, + ) +} + +func tidalFirstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func tidalJoinArtistNames(artists []tidalPublicArtist) string { + if len(artists) == 0 { + return "" + } + + names := make([]string, 0, len(artists)) + for _, artist := range artists { + if trimmed := strings.TrimSpace(artist.Name); trimmed != "" { + names = append(names, trimmed) + } + } + return strings.Join(names, ", ") +} + +func tidalTrackArtistsDisplay(track *TidalTrack) string { + if track == nil { + return "" + } + + if len(track.Artists) > 0 { + names := make([]string, 0, len(track.Artists)) + for _, artist := range track.Artists { + if trimmed := strings.TrimSpace(artist.Name); trimmed != "" { + names = append(names, trimmed) + } + } + if len(names) > 0 { + return strings.Join(names, ", ") + } + } + + return strings.TrimSpace(track.Artist.Name) +} + +func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string { + if album == nil { + return "" + } + return tidalJoinArtistNames(album.Artists) +} + +func tidalTrackExternalURL(track *TidalTrack) string { + if track == nil { + return "" + } + if trimmed := strings.TrimSpace(track.URL); trimmed != "" { + return strings.Replace(trimmed, "http://", "https://", 1) + } + if track.ID > 0 { + return fmt.Sprintf("https://tidal.com/browse/track/%d", track.ID) + } + return "" +} + +func tidalAlbumExternalURL(album *tidalPublicAlbum) string { + if album == nil { + return "" + } + if trimmed := strings.TrimSpace(album.URL); trimmed != "" { + return strings.Replace(trimmed, "http://", "https://", 1) + } + if album.ID > 0 { + return fmt.Sprintf("https://tidal.com/browse/album/%d", album.ID) + } + return "" +} + +func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata { + if track == nil { + return TrackMetadata{} + } + + artistID := tidalPrefixedNumericID(track.Artist.ID) + if artistID == "" && len(track.Artists) > 0 { + artistID = tidalPrefixedNumericID(track.Artists[0].ID) + } + + return TrackMetadata{ + SpotifyID: tidalPrefixedNumericID(track.ID), + Artists: tidalTrackArtistsDisplay(track), + Name: strings.TrimSpace(track.Title), + AlbumName: strings.TrimSpace(track.Album.Title), + AlbumArtist: strings.TrimSpace(track.Artist.Name), + DurationMS: track.Duration * 1000, + Images: tidalImageURL(track.Album.Cover, "1280x1280"), + ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate), + TrackNumber: track.TrackNumber, + DiscNumber: track.VolumeNumber, + ExternalURL: tidalTrackExternalURL(track), + ISRC: strings.TrimSpace(track.ISRC), + AlbumID: tidalPrefixedNumericID(track.Album.ID), + ArtistID: artistID, + } +} + +func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata { + if track == nil { + return AlbumTrackMetadata{} + } + + return AlbumTrackMetadata{ + SpotifyID: tidalPrefixedNumericID(track.ID), + Artists: tidalTrackArtistsDisplay(track), + Name: strings.TrimSpace(track.Title), + AlbumName: strings.TrimSpace(track.Album.Title), + AlbumArtist: strings.TrimSpace(track.Artist.Name), + DurationMS: track.Duration * 1000, + Images: tidalImageURL(track.Album.Cover, "1280x1280"), + ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate), + TrackNumber: track.TrackNumber, + DiscNumber: track.VolumeNumber, + ExternalURL: tidalTrackExternalURL(track), + ISRC: strings.TrimSpace(track.ISRC), + AlbumID: tidalPrefixedNumericID(track.Album.ID), + AlbumURL: strings.Replace(strings.TrimSpace(track.Album.URL), "http://", "https://", 1), + } +} + +func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata { + if album == nil { + return AlbumInfoMetadata{} + } + + artistID := "" + if len(album.Artists) > 0 { + artistID = tidalPrefixedNumericID(album.Artists[0].ID) + } + + return AlbumInfoMetadata{ + TotalTracks: album.NumberOfTracks, + Name: strings.TrimSpace(album.Title), + ReleaseDate: strings.TrimSpace(album.ReleaseDate), + Artists: tidalAlbumArtistsDisplay(album), + ArtistId: artistID, + Images: tidalImageURL(album.Cover, "1280x1280"), + } +} + +func tidalAlbumToArtistAlbum(album *tidalPublicAlbum) ArtistAlbumMetadata { + return tidalAlbumToArtistAlbumWithType(album, "") +} + +func tidalAlbumToArtistAlbumWithType(album *tidalPublicAlbum, fallbackType string) ArtistAlbumMetadata { + if album == nil { + return ArtistAlbumMetadata{} + } + + albumType := strings.ToLower(strings.TrimSpace(album.Type)) + if albumType == "" { + albumType = strings.ToLower(strings.TrimSpace(fallbackType)) + } + if albumType == "" { + albumType = "album" + } + + return ArtistAlbumMetadata{ + ID: tidalPrefixedNumericID(album.ID), + Name: strings.TrimSpace(album.Title), + ReleaseDate: strings.TrimSpace(album.ReleaseDate), + TotalTracks: album.NumberOfTracks, + Images: tidalImageURL(album.Cover, "1280x1280"), + AlbumType: albumType, + Artists: tidalAlbumArtistsDisplay(album), + } +} + +func tidalPlaylistOwnerName(playlist *tidalPublicPlaylist) string { + if playlist == nil { + return "" + } + if trimmed := strings.TrimSpace(playlist.Creator.Name); trimmed != "" { + return trimmed + } + if strings.EqualFold(strings.TrimSpace(playlist.Type), "ARTIST") { + return "Artist" + } + return "TIDAL" +} + +func tidalArtistAlbumTypeFromModuleTitle(title string) string { + normalized := strings.ToLower(strings.TrimSpace(title)) + switch normalized { + case "albums", "compilations", "appears on": + return "album" + case "ep & singles", "eps & singles", "singles", "ep", "eps": + return "single" + default: + return "" + } +} + +func tidalBuildMetadataURL(path string, extraQuery url.Values) string { + trimmedPath := strings.TrimLeft(strings.TrimSpace(path), "/") + if trimmedPath == "" { + return tidalPublicAPIBaseURL + } + + baseURL, err := url.Parse(tidalPublicAPIBaseURL + "/" + trimmedPath) + if err != nil { + return tidalPublicAPIBaseURL + "/" + trimmedPath + } + + query := baseURL.Query() + query.Set("countryCode", tidalCountryCode) + query.Set("locale", tidalLocale) + query.Set("deviceType", tidalDeviceType) + for key, values := range extraQuery { + query.Del(key) + for _, value := range values { + query.Add(key, value) + } + } + baseURL.RawQuery = query.Encode() + return baseURL.String() +} + +func (t *TidalDownloader) getTidalMetadataJSON(requestURL string, target interface{}) error { + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("x-tidal-token", tidalPublicToken) + + resp, err := DoRequestWithUserAgent(t.client, req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("tidal metadata request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) + } + + return json.NewDecoder(resp.Body).Decode(target) +} + +func (t *TidalDownloader) getPublicTrack(resourceID string) (*TidalTrack, error) { + trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64) + if err != nil || trackID <= 0 { + return nil, fmt.Errorf("invalid tidal track ID: %s", resourceID) + } + + requestURL := tidalBuildMetadataURL(fmt.Sprintf("tracks/%d", trackID), nil) + var track TidalTrack + if err := t.getTidalMetadataJSON(requestURL, &track); err != nil { + return nil, err + } + return &track, nil +} + +func (t *TidalDownloader) getAlbumPage(resourceID string) (*tidalPublicAlbumPage, error) { + albumID := strings.TrimSpace(resourceID) + if albumID == "" { + return nil, fmt.Errorf("invalid tidal album ID") + } + + requestURL := tidalBuildMetadataURL("pages/album", url.Values{"albumId": {albumID}}) + var page tidalPublicAlbumPage + if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { + return nil, err + } + return &page, nil +} + +func (t *TidalDownloader) getArtistPage(resourceID string) (*tidalPublicArtistPage, error) { + artistID := strings.TrimSpace(resourceID) + if artistID == "" { + return nil, fmt.Errorf("invalid tidal artist ID") + } + + requestURL := tidalBuildMetadataURL("pages/artist", url.Values{"artistId": {artistID}}) + var page tidalPublicArtistPage + if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { + return nil, err + } + return &page, nil +} + +func (t *TidalDownloader) getArtistAlbumsPage(dataAPIPath string, offset, limit int) (*tidalPublicArtistAlbumsPage, error) { + extraQuery := url.Values{} + if offset >= 0 { + extraQuery.Set("offset", strconv.Itoa(offset)) + } + if limit > 0 { + extraQuery.Set("limit", strconv.Itoa(limit)) + } + + requestURL := tidalBuildMetadataURL(dataAPIPath, extraQuery) + var page tidalPublicArtistAlbumsPage + if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { + return nil, err + } + return &page, nil +} + +func (t *TidalDownloader) getPlaylist(resourceID string) (*tidalPublicPlaylist, error) { + playlistID := strings.TrimSpace(resourceID) + if playlistID == "" { + return nil, fmt.Errorf("invalid tidal playlist ID") + } + + requestURL := tidalBuildMetadataURL("playlists/"+url.PathEscape(playlistID), nil) + var playlist tidalPublicPlaylist + if err := t.getTidalMetadataJSON(requestURL, &playlist); err != nil { + return nil, err + } + return &playlist, nil +} + +func (t *TidalDownloader) getPlaylistItemsPage(resourceID string, offset, limit int) (*tidalPublicPlaylistItemsPage, error) { + playlistID := strings.TrimSpace(resourceID) + if playlistID == "" { + return nil, fmt.Errorf("invalid tidal playlist ID") + } + + requestURL := tidalBuildMetadataURL( + "playlists/"+url.PathEscape(playlistID)+"/items", + url.Values{ + "offset": {strconv.Itoa(offset)}, + "limit": {strconv.Itoa(limit)}, + }, + ) + var page tidalPublicPlaylistItemsPage + if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { + return nil, err + } + return &page, nil +} + +func (t *TidalDownloader) getTrackSearchPage(query string, limit int) (*tidalPublicTrackSearchResponse, error) { + cleanQuery := strings.TrimSpace(query) + if cleanQuery == "" { + return nil, fmt.Errorf("empty tidal search query") + } + if limit <= 0 { + limit = 20 + } + + requestURL := tidalBuildMetadataURL( + "search/tracks", + url.Values{ + "query": {cleanQuery}, + "limit": {strconv.Itoa(limit)}, + "offset": {"0"}, + }, + ) + var page tidalPublicTrackSearchResponse + if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { + return nil, err + } + return &page, nil +} + +func findTidalAlbumPageModule(page *tidalPublicAlbumPage, moduleType string) *struct { + Type string `json:"type"` + Album tidalPublicAlbum `json:"album"` + PagedList struct { + DataAPIPath string `json:"dataApiPath"` + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []struct { + Item TidalTrack `json:"item"` + Type string `json:"type"` + } `json:"items"` + } `json:"pagedList"` +} { + if page == nil { + return nil + } + for rowIndex := range page.Rows { + for moduleIndex := range page.Rows[rowIndex].Modules { + module := &page.Rows[rowIndex].Modules[moduleIndex] + if module.Type == moduleType { + return module + } + } + } + return nil +} + +func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *struct { + Type string `json:"type"` + Title string `json:"title"` + Artist struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Picture string `json:"picture"` + } `json:"artist"` + PagedList struct { + DataAPIPath string `json:"dataApiPath"` + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []tidalPublicAlbum `json:"items"` + } `json:"pagedList"` +} { + if page == nil { + return nil + } + for rowIndex := range page.Rows { + for moduleIndex := range page.Rows[rowIndex].Modules { + module := &page.Rows[rowIndex].Modules[moduleIndex] + if module.Type == moduleType { + return module + } + } + } + return nil +} + func (t *TidalDownloader) GetAvailableAPIs() []string { return []string{ "https://tidal-api.binimum.org", @@ -192,15 +764,393 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { } func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { - return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode") + normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) + if normalizedISRC == "" { + return nil, fmt.Errorf("empty tidal ISRC") + } + + page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20) + if err != nil { + return nil, err + } + + for i := range page.Items { + if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) { + return &page.Items[i], nil + } + } + + return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC) } func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { - return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") + queryParts := make([]string, 0, 3) + if trimmed := strings.TrimSpace(trackName); trimmed != "" { + queryParts = append(queryParts, trimmed) + } + if trimmed := strings.TrimSpace(artistName); trimmed != "" { + queryParts = append(queryParts, trimmed) + } + if len(queryParts) == 0 { + return nil, fmt.Errorf("tidal metadata search requires track or artist name") + } + + queries := []string{strings.Join(queryParts, " ")} + if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" { + queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " ")) + } + + req := DownloadRequest{ + TrackName: strings.TrimSpace(trackName), + ArtistName: strings.TrimSpace(artistName), + AlbumName: strings.TrimSpace(albumName), + ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)), + DurationMS: expectedDuration * 1000, + } + + seenQueries := make(map[string]struct{}, len(queries)) + for _, query := range queries { + if _, seen := seenQueries[query]; seen { + continue + } + seenQueries[query] = struct{}{} + + page, err := tidalGetTrackSearchPageFunc(t, query, 20) + if err != nil { + return nil, err + } + + var candidates []*TidalTrack + for i := range page.Items { + track := &page.Items[i] + if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) { + continue + } + resolved := resolvedTrackInfo{ + Title: strings.TrimSpace(track.Title), + ArtistName: tidalTrackArtistsDisplay(track), + Duration: track.Duration, + } + if trackMatchesRequest(req, resolved, "Tidal search") { + candidates = append(candidates, track) + } + } + + if len(candidates) == 0 { + continue + } + + if req.AlbumName != "" { + for _, candidate := range candidates { + if titlesMatch(req.AlbumName, candidate.Album.Title) { + return candidate, nil + } + } + } + + return candidates[0], nil + } + + if req.ISRC != "" { + return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC) + } + return nil, fmt.Errorf("no tidal metadata match found") } func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { - return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") + return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0) +} + +func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) { + page, err := t.getTrackSearchPage(query, limit) + if err != nil { + return nil, err + } + + results := make([]ExtTrackMetadata, 0, len(page.Items)) + for i := range page.Items { + results = append(results, normalizeBuiltInMetadataTrack(tidalTrackToTrackMetadata(&page.Items[i]), "tidal")) + } + return results, nil +} + +// SearchAll searches Tidal for tracks, artists, and albums matching the query. +// Returns results in the same SearchAllResult format as Deezer's SearchAll. +func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { + GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) + + cleanQuery := strings.TrimSpace(query) + if cleanQuery == "" { + return nil, fmt.Errorf("empty tidal search query") + } + + albumLimit := 5 + + if filter != "" { + switch filter { + case "track": + trackLimit = 50 + artistLimit = 0 + albumLimit = 0 + case "artist": + trackLimit = 0 + artistLimit = 20 + albumLimit = 0 + case "album": + trackLimit = 0 + artistLimit = 0 + albumLimit = 20 + } + } + + result := &SearchAllResult{ + Tracks: make([]TrackMetadata, 0, trackLimit), + Artists: make([]SearchArtistResult, 0, artistLimit), + Albums: make([]SearchAlbumResult, 0, albumLimit), + Playlists: make([]SearchPlaylistResult, 0), + } + + if trackLimit > 0 { + page, err := t.getTrackSearchPage(cleanQuery, trackLimit) + if err != nil { + GoLog("[Tidal] Track search failed: %v\n", err) + return nil, fmt.Errorf("tidal track search failed: %w", err) + } + GoLog("[Tidal] Got %d tracks from API\n", len(page.Items)) + for i := range page.Items { + result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i])) + } + } + + if artistLimit > 0 { + requestURL := tidalBuildMetadataURL("search/artists", url.Values{ + "query": {cleanQuery}, + "limit": {strconv.Itoa(artistLimit)}, + "offset": {"0"}, + }) + var artistResp struct { + Items []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Picture string `json:"picture"` + Popularity int `json:"popularity"` + URL string `json:"url"` + } `json:"items"` + } + if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil { + GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items)) + for _, artist := range artistResp.Items { + result.Artists = append(result.Artists, SearchArtistResult{ + ID: tidalPrefixedNumericID(artist.ID), + Name: strings.TrimSpace(artist.Name), + Images: tidalImageURL(artist.Picture, "750x750"), + Followers: 0, + Popularity: artist.Popularity, + }) + } + } else { + GoLog("[Tidal] Artist search failed: %v\n", err) + } + } + + if albumLimit > 0 { + requestURL := tidalBuildMetadataURL("search/albums", url.Values{ + "query": {cleanQuery}, + "limit": {strconv.Itoa(albumLimit)}, + "offset": {"0"}, + }) + var albumResp struct { + Items []tidalPublicAlbum `json:"items"` + } + if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil { + GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items)) + for i := range albumResp.Items { + album := &albumResp.Items[i] + albumType := strings.ToLower(strings.TrimSpace(album.Type)) + if albumType == "" { + albumType = "album" + } + result.Albums = append(result.Albums, SearchAlbumResult{ + ID: tidalPrefixedNumericID(album.ID), + Name: strings.TrimSpace(album.Title), + Artists: tidalAlbumArtistsDisplay(album), + Images: tidalImageURL(album.Cover, "1280x1280"), + ReleaseDate: strings.TrimSpace(album.ReleaseDate), + TotalTracks: album.NumberOfTracks, + AlbumType: albumType, + }) + } + } else { + GoLog("[Tidal] Album search failed: %v\n", err) + } + } + + GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums)) + return result, nil +} + +func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) { + track, err := t.getPublicTrack(resourceID) + if err != nil { + return nil, err + } + return &TrackResponse{Track: tidalTrackToTrackMetadata(track)}, nil +} + +func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) { + page, err := t.getAlbumPage(resourceID) + if err != nil { + return nil, err + } + + headerModule := findTidalAlbumPageModule(page, "ALBUM_HEADER") + itemsModule := findTidalAlbumPageModule(page, "ALBUM_ITEMS") + if headerModule == nil { + return nil, fmt.Errorf("tidal album page missing album header") + } + if itemsModule == nil { + return nil, fmt.Errorf("tidal album page missing track list") + } + + tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items)) + for _, item := range itemsModule.PagedList.Items { + track := item.Item + if track.Album.ID == 0 { + track.Album.ID = headerModule.Album.ID + track.Album.Title = headerModule.Album.Title + track.Album.Cover = headerModule.Album.Cover + track.Album.ReleaseDate = headerModule.Album.ReleaseDate + track.Album.URL = headerModule.Album.URL + } + tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track)) + } + + return &AlbumResponsePayload{ + AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album), + TrackList: tracks, + }, nil +} + +func (t *TidalDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) { + playlist, err := t.getPlaylist(resourceID) + if err != nil { + return nil, err + } + + const pageSize = 50 + offset := 0 + totalTracks := playlist.NumberOfTracks + tracks := make([]AlbumTrackMetadata, 0, totalTracks) + + for { + page, pageErr := t.getPlaylistItemsPage(resourceID, offset, pageSize) + if pageErr != nil { + return nil, pageErr + } + if totalTracks == 0 && page.TotalNumberOfItems > 0 { + totalTracks = page.TotalNumberOfItems + } + + for _, item := range page.Items { + if item.Type != "track" { + continue + } + tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&item.Item)) + } + + if len(page.Items) == 0 || offset+len(page.Items) >= totalTracks || len(page.Items) < pageSize { + break + } + offset += len(page.Items) + } + + var info PlaylistInfoMetadata + info.Tracks.Total = totalTracks + info.Name = strings.TrimSpace(playlist.Title) + info.Images = tidalImageURL(tidalFirstNonEmpty(playlist.SquareImage, playlist.Image), "origin") + info.Owner.DisplayName = tidalPlaylistOwnerName(playlist) + info.Owner.Name = strings.TrimSpace(playlist.Title) + info.Owner.Images = info.Images + + return &PlaylistResponsePayload{ + PlaylistInfo: info, + TrackList: tracks, + }, nil +} + +func (t *TidalDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) { + page, err := t.getArtistPage(resourceID) + if err != nil { + return nil, err + } + + headerModule := findTidalArtistPageModule(page, "ARTIST_HEADER") + albumsModule := findTidalArtistPageModule(page, "ALBUM_LIST") + if headerModule == nil { + return nil, fmt.Errorf("tidal artist page missing artist header") + } + if albumsModule == nil { + return nil, fmt.Errorf("tidal artist page missing albums list") + } + + albums := make([]ArtistAlbumMetadata, 0, albumsModule.PagedList.TotalNumberOfItems) + seenAlbumIDs := make(map[string]struct{}) + + appendArtistAlbum := func(album tidalPublicAlbum, fallbackType string) { + mapped := tidalAlbumToArtistAlbumWithType(&album, fallbackType) + if mapped.ID == "" { + return + } + if _, exists := seenAlbumIDs[mapped.ID]; exists { + return + } + seenAlbumIDs[mapped.ID] = struct{}{} + albums = append(albums, mapped) + } + + for rowIndex := range page.Rows { + for moduleIndex := range page.Rows[rowIndex].Modules { + module := &page.Rows[rowIndex].Modules[moduleIndex] + if module.Type != "ALBUM_LIST" { + continue + } + + fallbackType := tidalArtistAlbumTypeFromModuleTitle(module.Title) + for _, album := range module.PagedList.Items { + appendArtistAlbum(album, fallbackType) + } + + pageSize := module.PagedList.Limit + if pageSize <= 0 { + pageSize = 50 + } + offset := len(module.PagedList.Items) + for offset < module.PagedList.TotalNumberOfItems && strings.TrimSpace(module.PagedList.DataAPIPath) != "" { + albumsPage, pageErr := t.getArtistAlbumsPage(module.PagedList.DataAPIPath, offset, pageSize) + if pageErr != nil { + return nil, pageErr + } + + for _, album := range albumsPage.Items { + appendArtistAlbum(album, fallbackType) + } + + if len(albumsPage.Items) == 0 || offset+len(albumsPage.Items) >= albumsPage.TotalNumberOfItems { + break + } + offset += len(albumsPage.Items) + } + } + } + + return &ArtistResponsePayload{ + ArtistInfo: ArtistInfoMetadata{ + ID: tidalPrefixedNumericID(headerModule.Artist.ID), + Name: strings.TrimSpace(headerModule.Artist.Name), + Images: tidalImageURL(headerModule.Artist.Picture, "750x750"), + }, + Albums: albums, + }, nil } type TidalDownloadInfo struct { @@ -583,7 +1533,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n", directURL != "", initURL != "", len(mediaURLs)) - client := NewHTTPClientWithTimeout(120 * time.Second) + client := NewHTTPClientWithTimeout(DownloadTimeout) if directURL != "" { GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) @@ -794,8 +1744,8 @@ type TidalDownloadResult struct { } func artistsMatch(spotifyArtist, tidalArtist string) bool { - normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist)) - normTidal := strings.ToLower(strings.TrimSpace(tidalArtist)) + normSpotify := normalizeLooseArtistName(spotifyArtist) + normTidal := normalizeLooseArtistName(tidalArtist) if normSpotify == normTidal { return true @@ -1062,20 +2012,18 @@ func isLatinScript(s string) bool { return true } -func tidalTrackArtistsDisplay(track *TidalTrack) string { - if track == nil { - return "" +func parseTidalRequestTrackID(raw string) (int64, bool) { + trimmed := strings.TrimSpace(raw) + trimmed = strings.TrimPrefix(trimmed, "tidal:") + if trimmed == "" { + return 0, false } - tidalArtist := track.Artist.Name - if len(track.Artists) > 0 { - var artistNames []string - for _, a := range track.Artists { - artistNames = append(artistNames, a.Name) - } - tidalArtist = strings.Join(artistNames, ", ") + trackID, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil || trackID <= 0 { + return 0, false } - return tidalArtist + return trackID, true } func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) { @@ -1091,8 +2039,9 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade var gotTidalID bool if req.TidalID != "" { - GoLog("[%s] Using Tidal ID from Odesli enrichment: %s\n", logPrefix, req.TidalID) - if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID) + if parsedTrackID, ok := parseTidalRequestTrackID(req.TidalID); ok { + trackID = parsedTrackID gotTidalID = true } } @@ -1105,6 +2054,36 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade } } + if !gotTidalID && req.ISRC != "" { + GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC) + directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC) + if directErr == nil && directTrack != nil && directTrack.ID > 0 { + trackID = directTrack.ID + gotTidalID = true + GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID) + } else if directErr != nil { + GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr) + } + } + + if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" { + GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix) + searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC( + req.TrackName, + req.ArtistName, + req.AlbumName, + req.ISRC, + expectedDurationSec, + ) + if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 { + trackID = searchTrack.ID + gotTidalID = true + GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID) + } else if searchErr != nil { + GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr) + } + } + if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") { GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix) @@ -1113,7 +2092,8 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade return } if availability.TidalID != "" { - if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + if parsedTrackID, ok := parseTidalRequestTrackID(availability.TidalID); ok { + trackID = parsedTrackID GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID) gotTidalID = true return @@ -1168,6 +2148,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink") } + // Verify the resolved track matches the request. + actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10)) + if fetchErr != nil { + GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr) + // Continue without verification — better than failing entirely. + } else { + providerArtist := actualTrack.Artist.Name + if providerArtist == "" && len(actualTrack.Artists) > 0 { + providerArtist = actualTrack.Artists[0].Name + } + resolved := resolvedTrackInfo{ + Title: actualTrack.Title, + ArtistName: providerArtist, + Duration: actualTrack.Duration, + } + if !trackMatchesRequest(req, resolved, logPrefix) { + // Invalidate the cached ID so future requests don't reuse it. + if req.ISRC != "" { + GetTrackIDCache().SetTidal(req.ISRC, 0) + } + return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'", + trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title) + } + GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title) + } + track := &TidalTrack{ ID: trackID, Title: strings.TrimSpace(req.TrackName), @@ -1437,25 +2443,33 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { bitDepth := downloadInfo.BitDepth sampleRate := downloadInfo.SampleRate - lyricsLRC := "" if quality == "HIGH" { bitDepth = 0 sampleRate = 44100 } + lyricsLRC := "" if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC } + resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata( + req, + track.Album.Title, + track.Album.ReleaseDate, + actualTrackNumber, + actualDiscNumber, + ) + return TidalDownloadResult{ FilePath: actualOutputPath, BitDepth: bitDepth, SampleRate: sampleRate, Title: track.Title, Artist: track.Artist.Name, - Album: track.Album.Title, - ReleaseDate: track.Album.ReleaseDate, - TrackNumber: actualTrackNumber, - DiscNumber: actualDiscNumber, + Album: resultAlbum, + ReleaseDate: resultReleaseDate, + TrackNumber: resultTrackNumber, + DiscNumber: resultDiscNumber, ISRC: track.ISRC, LyricsLRC: lyricsLRC, }, nil diff --git a/go_backend/tidal_test.go b/go_backend/tidal_test.go new file mode 100644 index 00000000..bc8a56dc --- /dev/null +++ b/go_backend/tidal_test.go @@ -0,0 +1,222 @@ +package gobackend + +import "testing" + +func TestParseTidalURL(t *testing.T) { + tests := []struct { + name string + input string + wantType string + wantID string + expectErr bool + }{ + { + name: "track url", + input: "https://tidal.com/track/77616174", + wantType: "track", + wantID: "77616174", + }, + { + name: "browse album url", + input: "https://listen.tidal.com/browse/album/77616169", + wantType: "album", + wantID: "77616169", + }, + { + name: "artist url", + input: "https://www.tidal.com/artist/3852143", + wantType: "artist", + wantID: "3852143", + }, + { + name: "playlist url", + input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b", + wantType: "playlist", + wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b", + }, + { + name: "unsupported host", + input: "https://example.com/track/123", + expectErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotType, gotID, err := parseTidalURL(test.input) + if test.expectErr { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if gotType != test.wantType || gotID != test.wantID { + t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID) + } + }) + } +} + +func TestParseTidalRequestTrackID(t *testing.T) { + tests := []struct { + input string + want int64 + ok bool + }{ + {input: "40681594", want: 40681594, ok: true}, + {input: "tidal:40681594", want: 40681594, ok: true}, + {input: " tidal:40681594 ", want: 40681594, ok: true}, + {input: "", want: 0, ok: false}, + {input: "tidal:not-a-number", want: 0, ok: false}, + } + + for _, test := range tests { + got, ok := parseTidalRequestTrackID(test.input) + if got != test.want || ok != test.ok { + t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok) + } + } +} + +func TestTidalImageURL(t *testing.T) { + got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280") + want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg" + if got != want { + t.Fatalf("tidalImageURL() = %q, want %q", got, want) + } +} + +func TestTidalTrackToTrackMetadata(t *testing.T) { + track := &TidalTrack{ + ID: 77616174, + Title: "Bruckner: Symphony No. 5", + ISRC: "GBUM71507433", + Duration: 1172, + TrackNumber: 5, + VolumeNumber: 1, + URL: "http://www.tidal.com/track/77616174", + } + track.Artist.ID = 3852143 + track.Artist.Name = "Staatskapelle Berlin" + track.Artists = []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Picture string `json:"picture"` + }{ + {ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"}, + {ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"}, + } + track.Album.ID = 77616169 + track.Album.Title = "Bruckner: Symphonies 4-9" + track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3" + track.Album.ReleaseDate = "2016-02-26" + + got := tidalTrackToTrackMetadata(track) + if got.SpotifyID != "tidal:77616174" { + t.Fatalf("unexpected track ID: %q", got.SpotifyID) + } + if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" { + t.Fatalf("unexpected artists: %q", got.Artists) + } + if got.AlbumID != "tidal:77616169" { + t.Fatalf("unexpected album ID: %q", got.AlbumID) + } + if got.ArtistID != "tidal:3852143" { + t.Fatalf("unexpected artist ID: %q", got.ArtistID) + } + if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" { + t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL) + } +} + +func TestTidalAlbumToArtistAlbum(t *testing.T) { + album := &tidalPublicAlbum{ + ID: 77616169, + Title: "Bruckner: Symphonies 4-9", + Type: "ALBUM", + Cover: "fc18a64b-d76b-4582-962a-224cb05193f3", + ReleaseDate: "2016-02-26", + NumberOfTracks: 23, + Artists: []tidalPublicArtist{ + {ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"}, + {ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"}, + }, + } + + got := tidalAlbumToArtistAlbum(album) + if got.ID != "tidal:77616169" { + t.Fatalf("unexpected album ID: %q", got.ID) + } + if got.AlbumType != "album" { + t.Fatalf("unexpected album type: %q", got.AlbumType) + } + if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" { + t.Fatalf("unexpected artists: %q", got.Artists) + } + if got.Images == "" { + t.Fatalf("expected image URL, got empty string") + } +} + +func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) { + album := &tidalPublicAlbum{ + ID: 490623904, + Title: "LET 'EM KNOW", + Cover: "fc18a64b-d76b-4582-962a-224cb05193f3", + NumberOfTracks: 1, + } + + got := tidalAlbumToArtistAlbumWithType(album, "single") + if got.AlbumType != "single" { + t.Fatalf("unexpected fallback album type: %q", got.AlbumType) + } +} + +func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) { + tests := []struct { + title string + want string + }{ + {title: "Albums", want: "album"}, + {title: "EP & Singles", want: "single"}, + {title: "Compilations", want: "album"}, + {title: "Appears On", want: "album"}, + {title: "Unknown", want: ""}, + } + + for _, test := range tests { + if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want { + t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want) + } + } +} + +func TestTidalPlaylistImageUsesOrigin(t *testing.T) { + got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin") + want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg" + if got != want { + t.Fatalf("unexpected origin playlist image URL: %q", got) + } +} + +func TestTidalPlaylistOwnerName(t *testing.T) { + editorial := &tidalPublicPlaylist{Type: "EDITORIAL"} + if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" { + t.Fatalf("unexpected editorial owner: %q", got) + } + + artist := &tidalPublicPlaylist{Type: "ARTIST"} + if got := tidalPlaylistOwnerName(artist); got != "Artist" { + t.Fatalf("unexpected artist owner: %q", got) + } + + user := &tidalPublicPlaylist{} + user.Creator.Name = "djtest" + if got := tidalPlaylistOwnerName(user); got != "djtest" { + t.Fatalf("unexpected creator owner: %q", got) + } +} diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index 039ff434..cecf462d 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -3,6 +3,8 @@ package gobackend import ( "strings" "unicode" + + "golang.org/x/text/unicode/norm" ) // normalizeLooseTitle collapses separators/punctuation so titles like @@ -33,6 +35,37 @@ func normalizeLooseTitle(title string) string { return strings.Join(strings.Fields(b.String()), " ") } +// normalizeLooseArtistName folds diacritics and common separators so artist +// verification is resilient to variants like "Özkent" vs "Ozkent". +func normalizeLooseArtistName(name string) string { + trimmed := strings.TrimSpace(strings.ToLower(name)) + if trimmed == "" { + return "" + } + + decomposed := norm.NFD.String(trimmed) + + var b strings.Builder + b.Grow(len(decomposed)) + + for _, r := range decomposed { + switch { + case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r): + continue + case unicode.IsLetter(r), unicode.IsNumber(r): + b.WriteRune(r) + case unicode.IsSpace(r): + b.WriteByte(' ') + case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': + b.WriteByte(' ') + default: + // Drop remaining punctuation/symbols for loose artist matching. + } + } + + return strings.Join(strings.Fields(b.String()), " ") +} + func hasAlphaNumericRunes(value string) bool { for _, r := range value { if unicode.IsLetter(r) || unicode.IsNumber(r) { @@ -68,3 +101,45 @@ func normalizeSymbolOnlyTitle(title string) string { return b.String() } + +// ==================== Shared Track Verification ==================== + +// resolvedTrackInfo holds the metadata fetched from a provider for verification. +type resolvedTrackInfo struct { + Title string + ArtistName string + Duration int // seconds +} + +// trackMatchesRequest checks whether a resolved track from a provider matches +// the original download request. Returns true if the track is a plausible match. +func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool { + if req.ArtistName != "" && resolved.ArtistName != "" && + !artistsMatch(req.ArtistName, resolved.ArtistName) { + GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n", + logPrefix, req.ArtistName, resolved.ArtistName) + return false + } + + if req.TrackName != "" && resolved.Title != "" && + !titlesMatch(req.TrackName, resolved.Title) { + GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n", + logPrefix, req.TrackName, resolved.Title) + return false + } + + expectedDurationSec := req.DurationMS / 1000 + if expectedDurationSec > 0 && resolved.Duration > 0 { + diff := expectedDurationSec - resolved.Duration + if diff < 0 { + diff = -diff + } + if diff > 10 { + GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n", + logPrefix, expectedDurationSec, resolved.Duration) + return false + } + } + + return true +} diff --git a/go_backend/youtube.go b/go_backend/youtube.go index e43d0e39..3bb65f5a 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -11,7 +11,6 @@ import ( "strconv" "strings" "sync" - "time" ) type YouTubeDownloader struct { @@ -30,6 +29,7 @@ var ( type YouTubeQuality string const ( + YouTubeQualityOpus320 YouTubeQuality = "opus_320" YouTubeQualityOpus256 YouTubeQuality = "opus_256" YouTubeQualityOpus128 YouTubeQuality = "opus_128" YouTubeQualityMP3128 YouTubeQuality = "mp3_128" @@ -38,7 +38,7 @@ const ( ) var ( - youtubeOpusSupportedBitrates = []int{128, 256} + youtubeOpusSupportedBitrates = []int{128, 256, 320} youtubeMp3SupportedBitrates = []int{128, 256, 320} ) @@ -82,7 +82,7 @@ type YouTubeDownloadResult struct { func NewYouTubeDownloader() *YouTubeDownloader { youtubeDownloaderOnce.Do(func() { globalYouTubeDownloader = &YouTubeDownloader{ - client: NewHTTPClientWithTimeout(120 * time.Second), + client: NewHTTPClientWithTimeout(DownloadTimeout), apiURL: "https://api.qwkuns.me", } }) @@ -147,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize switch normalizedRaw { case "opus_256", "opus256", "opus": return "opus", 256, YouTubeQualityOpus256 + case "opus_320", "opus320": + return "opus", 320, YouTubeQualityOpus320 case "opus_128", "opus128": return "opus", 128, YouTubeQualityOpus128 case "mp3_320", "mp3320", "mp3", "": @@ -511,12 +513,10 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) { return "", fmt.Errorf("invalid URL: %w", err) } - // /watch?v= if v := parsed.Query().Get("v"); v != "" { return v, nil } - // /embed/ if strings.Contains(parsed.Path, "/embed/") { parts := strings.Split(parsed.Path, "/embed/") if len(parts) >= 2 { @@ -524,7 +524,6 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) { } } - // /v/ if strings.Contains(parsed.Path, "/v/") { parts := strings.Split(parsed.Path, "/v/") if len(parts) >= 2 { diff --git a/go_backend/youtube_quality_test.go b/go_backend/youtube_quality_test.go index cb77e5bb..e0f2ebbf 100644 --- a/go_backend/youtube_quality_test.go +++ b/go_backend/youtube_quality_test.go @@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) { _, opusBitrate, _ := parseYouTubeQualityInput("opus_999") - if opusBitrate != 256 { - t.Fatalf("expected opus normalization to 256, got %d", opusBitrate) + if opusBitrate != 320 { + t.Fatalf("expected opus normalization to 320, got %d", opusBitrate) } _, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1") @@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) { t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate) } } + +func TestParseYouTubeQualityInput_Opus320(t *testing.T) { + format, bitrate, normalized := parseYouTubeQualityInput("opus_320") + if format != "opus" { + t.Fatalf("expected opus format, got %s", format) + } + if bitrate != 320 { + t.Fatalf("expected 320 bitrate, got %d", bitrate) + } + if normalized != YouTubeQualityOpus320 { + t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized) + } +} diff --git a/image.png b/image.png deleted file mode 100644 index 565fab55..00000000 Binary files a/image.png and /dev/null differ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4cb53238..740a9fd3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -160,38 +160,6 @@ import Gobackend // Import Go framework if let error = error { throw error } return response - case "getSpotifyMetadata": - let args = call.arguments as! [String: Any] - let url = args["url"] as! String - let response = GobackendGetSpotifyMetadata(url, &error) - if let error = error { throw error } - return response - - case "searchSpotify": - let args = call.arguments as! [String: Any] - let query = args["query"] as! String - let limit = args["limit"] as? Int ?? 10 - let response = GobackendSearchSpotify(query, Int(limit), &error) - if let error = error { throw error } - return response - - case "searchSpotifyAll": - let args = call.arguments as! [String: Any] - let query = args["query"] as! String - let trackLimit = args["track_limit"] as? Int ?? 15 - let artistLimit = args["artist_limit"] as? Int ?? 3 - let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error) - if let error = error { throw error } - return response - - case "getSpotifyRelatedArtists": - let args = call.arguments as! [String: Any] - let artistId = args["artist_id"] as! String - let limit = args["limit"] as? Int ?? 12 - let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error) - if let error = error { throw error } - return response - case "checkAvailability": let args = call.arguments as! [String: Any] let spotifyId = args["spotify_id"] as! String @@ -399,6 +367,26 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "searchTidalAll": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let trackLimit = args["track_limit"] as? Int ?? 15 + let artistLimit = args["artist_limit"] as? Int ?? 3 + let filter = args["filter"] as? String ?? "" + let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error) + if let error = error { throw error } + return response + + case "searchQobuzAll": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let trackLimit = args["track_limit"] as? Int ?? 15 + let artistLimit = args["artist_limit"] as? Int ?? 3 + let filter = args["filter"] as? String ?? "" + let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error) + if let error = error { throw error } + return response + case "getDeezerRelatedArtists": let args = call.arguments as! [String: Any] let artistId = args["artist_id"] as! String @@ -415,6 +403,22 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "getQobuzMetadata": + let args = call.arguments as! [String: Any] + let resourceType = args["resource_type"] as! String + let resourceId = args["resource_id"] as! String + let response = GobackendGetQobuzMetadata(resourceType, resourceId, &error) + if let error = error { throw error } + return response + + case "getTidalMetadata": + let args = call.arguments as! [String: Any] + let resourceType = args["resource_type"] as! String + let resourceId = args["resource_id"] as! String + let response = GobackendGetTidalMetadata(resourceType, resourceId, &error) + if let error = error { throw error } + return response + case "parseDeezerUrl": let args = call.arguments as! [String: Any] let url = args["url"] as! String @@ -422,6 +426,13 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "parseQobuzUrl": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendParseQobuzURLExport(url, &error) + if let error = error { throw error } + return response + case "parseTidalUrl": let args = call.arguments as! [String: Any] let url = args["url"] as! String @@ -510,17 +521,6 @@ import Gobackend // Import Go framework GobackendClearTrackCache() return nil - case "setSpotifyCredentials": - let args = call.arguments as! [String: Any] - let clientId = args["client_id"] as! String - let clientSecret = args["client_secret"] as! String - GobackendSetSpotifyAPICredentials(clientId, clientSecret) - return nil - - case "hasSpotifyCredentials": - let hasCredentials = GobackendCheckSpotifyCredentials() - return hasCredentials - // Log methods case "getLogs": let response = GobackendGetLogs() @@ -643,6 +643,20 @@ import Gobackend // Import Go framework let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error) if let error = error { throw error } return response + + case "searchTracksWithMetadataProviders": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let limit = args["limit"] as? Int ?? 20 + let includeExtensions = args["include_extensions"] as? Bool ?? true + let response = GobackendSearchTracksWithMetadataProvidersJSON( + query, + Int(limit), + includeExtensions, + &error + ) + if let error = error { throw error } + return response case "enrichTrackWithExtension": let args = call.arguments as! [String: Any] @@ -834,6 +848,23 @@ import Gobackend // Import Go framework if let error = error { throw error } return nil + case "setStoreRegistryUrl": + let args = call.arguments as! [String: Any] + let registryUrl = args["registry_url"] as? String ?? "" + GobackendSetStoreRegistryURLJSON(registryUrl, &error) + if let error = error { throw error } + return nil + + case "getStoreRegistryUrl": + let response = GobackendGetStoreRegistryURLJSON(&error) + if let error = error { throw error } + return response + + case "clearStoreRegistryUrl": + GobackendClearStoreRegistryURLJSON(&error) + if let error = error { throw error } + return nil + case "getStoreExtensions": let args = call.arguments as! [String: Any] let forceRefresh = args["force_refresh"] as? Bool ?? false @@ -969,6 +1000,15 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + // CUE Sheet Parsing + case "parseCueSheet": + let args = call.arguments as! [String: Any] + let cuePath = args["cue_path"] as! String + let audioDir = args["audio_dir"] as? String ?? "" + let response = GobackendParseCueSheet(cuePath, audioDir, &error) + if let error = error { throw error } + return response + default: throw NSError( domain: "SpotiFLAC", diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index d423fac6..94e71f92 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,21 +1,26 @@ +import 'package:flutter/foundation.dart'; + /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.7.2'; - static const String buildNumber = '105'; + static const String version = '3.9.0'; + static const String buildNumber = '115'; static const String fullVersion = '$version+$buildNumber'; - - + + /// Shows "Internal" in debug builds, actual version in release. + static String get displayVersion => kDebugMode ? 'Internal' : version; + static const String appName = 'SpotiFLAC'; static const String copyright = '© 2026 SpotiFLAC'; - + static const String mobileAuthor = 'zarzet'; static const String originalAuthor = 'afkarxyz'; - + static const String githubRepo = 'zarzet/SpotiFLAC-Mobile'; static const String githubUrl = 'https://github.com/$githubRepo'; - static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC'; - + static const String originalGithubUrl = + 'https://github.com/afkarxyz/SpotiFLAC'; + static const String kofiUrl = 'https://ko-fi.com/zarzet'; static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/'; } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 822982eb..2fd44b83 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -256,7 +256,7 @@ abstract class AppLocalizations { /// **'Filename Format'** String get downloadFilenameFormat; - /// Setting for folder structure + /// Title of the folder organization picker bottom sheet /// /// In en, this message translates to: /// **'Folder Organization'** @@ -1066,6 +1066,12 @@ abstract class AppLocalizations { /// **'Import'** String get dialogImport; + /// Confirm button in Download All dialog + /// + /// In en, this message translates to: + /// **'Download'** + String get dialogDownload; + /// Dialog button - discard changes /// /// In en, this message translates to: @@ -1306,6 +1312,24 @@ abstract class AppLocalizations { /// **'No tracks found'** String get errorNoTracksFound; + /// Error title - URL not handled by any extension or service + /// + /// In en, this message translates to: + /// **'Link not recognized'** + String get errorUrlNotRecognized; + + /// Error message - URL not recognized explanation + /// + /// In en, this message translates to: + /// **'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'** + String get errorUrlNotRecognizedMessage; + + /// Error message - generic URL fetch failure + /// + /// In en, this message translates to: + /// **'Failed to load content from this link. Please try again.'** + String get errorUrlFetchFailed; + /// Error - extension source not available /// /// In en, this message translates to: @@ -1438,6 +1462,18 @@ abstract class AppLocalizations { /// **'No organization'** String get folderOrganizationNone; + /// Folder option - playlist folders + /// + /// In en, this message translates to: + /// **'By Playlist'** + String get folderOrganizationByPlaylist; + + /// Subtitle for playlist folder option + /// + /// In en, this message translates to: + /// **'Separate folder for each playlist'** + String get folderOrganizationByPlaylistSubtitle; + /// Folder option - artist folders /// /// In en, this message translates to: @@ -2206,10 +2242,88 @@ abstract class AppLocalizations { /// **'Clear filters'** String get storeClearFilters; + /// Store setup screen - heading when no repo is configured + /// + /// In en, this message translates to: + /// **'Add Extension Repository'** + String get storeAddRepoTitle; + + /// Store setup screen - explanatory text + /// + /// In en, this message translates to: + /// **'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'** + String get storeAddRepoDescription; + + /// Label for the repository URL input field + /// + /// In en, this message translates to: + /// **'Repository URL'** + String get storeRepoUrlLabel; + + /// Hint/placeholder for the repository URL input field + /// + /// In en, this message translates to: + /// **'https://github.com/user/repo'** + String get storeRepoUrlHint; + + /// Helper text below the repository URL input field + /// + /// In en, this message translates to: + /// **'e.g. https://github.com/user/extensions-repo'** + String get storeRepoUrlHelper; + + /// Button to submit a new repository URL + /// + /// In en, this message translates to: + /// **'Add Repository'** + String get storeAddRepoButton; + + /// Tooltip for the change-repository icon button in the app bar + /// + /// In en, this message translates to: + /// **'Change repository'** + String get storeChangeRepoTooltip; + + /// Title of the change/remove repository dialog + /// + /// In en, this message translates to: + /// **'Extension Repository'** + String get storeRepoDialogTitle; + + /// Label shown above the current repository URL in the dialog + /// + /// In en, this message translates to: + /// **'Current repository:'** + String get storeRepoDialogCurrent; + + /// Label for the new repository URL field inside the dialog + /// + /// In en, this message translates to: + /// **'New Repository URL'** + String get storeNewRepoUrlLabel; + + /// Error heading when the store cannot be loaded + /// + /// In en, this message translates to: + /// **'Failed to load store'** + String get storeLoadError; + + /// Message when store has no extensions + /// + /// In en, this message translates to: + /// **'No extensions available'** + String get storeEmptyNoExtensions; + + /// Message when search/filter returns no results + /// + /// In en, this message translates to: + /// **'No extensions found'** + String get storeEmptyNoResults; + /// Default search provider option /// /// In en, this message translates to: - /// **'Default (Deezer/Spotify)'** + /// **'Default (Deezer)'** String get extensionDefaultProvider; /// Subtitle for default provider @@ -2482,6 +2596,66 @@ abstract class AppLocalizations { /// **'24-bit / up to 192kHz'** String get qualityHiResFlacMaxSubtitle; + /// Quality option label for Tidal lossy 320kbps + /// + /// In en, this message translates to: + /// **'Lossy 320kbps'** + String get downloadLossy320; + + /// Setting title to pick output format for Tidal lossy downloads + /// + /// In en, this message translates to: + /// **'Lossy Format'** + String get downloadLossyFormat; + + /// Title of the Tidal lossy format picker bottom sheet + /// + /// In en, this message translates to: + /// **'Lossy 320kbps Format'** + String get downloadLossy320Format; + + /// Description in the Tidal lossy format picker + /// + /// In en, this message translates to: + /// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'** + String get downloadLossy320FormatDesc; + + /// Tidal lossy format option - MP3 320kbps + /// + /// In en, this message translates to: + /// **'MP3 320kbps'** + String get downloadLossyMp3; + + /// Subtitle for MP3 320kbps Tidal lossy option + /// + /// In en, this message translates to: + /// **'Best compatibility, ~10MB per track'** + String get downloadLossyMp3Subtitle; + + /// Tidal lossy format option - Opus 256kbps + /// + /// In en, this message translates to: + /// **'Opus 256kbps'** + String get downloadLossyOpus256; + + /// Subtitle for Opus 256kbps Tidal lossy option + /// + /// In en, this message translates to: + /// **'Best quality Opus, ~8MB per track'** + String get downloadLossyOpus256Subtitle; + + /// Tidal lossy format option - Opus 128kbps + /// + /// In en, this message translates to: + /// **'Opus 128kbps'** + String get downloadLossyOpus128; + + /// Subtitle for Opus 128kbps Tidal lossy option + /// + /// In en, this message translates to: + /// **'Smallest size, ~4MB per track'** + String get downloadLossyOpus128Subtitle; + /// Note about quality availability /// /// In en, this message translates to: @@ -2992,6 +3166,42 @@ abstract class AppLocalizations { /// **'Show when searching for existing tracks'** String get libraryShowDuplicateIndicatorSubtitle; + /// Setting for automatic library scanning + /// + /// In en, this message translates to: + /// **'Auto Scan'** + String get libraryAutoScan; + + /// Subtitle for auto scan setting + /// + /// In en, this message translates to: + /// **'Automatically scan your library for new files'** + String get libraryAutoScanSubtitle; + + /// Auto scan disabled + /// + /// In en, this message translates to: + /// **'Off'** + String get libraryAutoScanOff; + + /// Auto scan when app opens + /// + /// In en, this message translates to: + /// **'Every app open'** + String get libraryAutoScanOnOpen; + + /// Auto scan once per day + /// + /// In en, this message translates to: + /// **'Daily'** + String get libraryAutoScanDaily; + + /// Auto scan once per week + /// + /// In en, this message translates to: + /// **'Weekly'** + String get libraryAutoScanWeekly; + /// Section header for library actions /// /// In en, this message translates to: @@ -3724,6 +3934,36 @@ abstract class AppLocalizations { /// **'FFmpeg metadata embed failed'** String get trackReEnrichFfmpegFailed; + /// Action/button label for queueing FLAC redownloads for local tracks + /// + /// In en, this message translates to: + /// **'Queue FLAC'** + String get queueFlacAction; + + /// Confirmation dialog body before queueing FLAC redownloads for local tracks + /// + /// In en, this message translates to: + /// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'** + String queueFlacConfirmMessage(int count); + + /// Snackbar while resolving remote matches for local FLAC redownloads + /// + /// In en, this message translates to: + /// **'Finding FLAC matches... ({current}/{total})'** + String queueFlacFindingProgress(int current, int total); + + /// Snackbar when no safe FLAC redownload matches were found + /// + /// In en, this message translates to: + /// **'No reliable online matches found for the selection'** + String get queueFlacNoReliableMatches; + + /// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped + /// + /// In en, this message translates to: + /// **'Added {addedCount} tracks to queue, skipped {skippedCount}'** + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount); + /// Snackbar when save operation fails /// /// In en, this message translates to: @@ -3739,7 +3979,7 @@ abstract class AppLocalizations { /// Subtitle for convert format menu item /// /// In en, this message translates to: - /// **'Convert to MP3 or Opus'** + /// **'Convert to MP3, Opus, ALAC, or FLAC'** String get trackConvertFormatSubtitle; /// Title of convert bottom sheet @@ -3776,6 +4016,21 @@ abstract class AppLocalizations { String bitrate, ); + /// Confirmation dialog message for lossless-to-lossless conversion + /// + /// In en, this message translates to: + /// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'** + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ); + + /// Hint shown when converting between lossless formats + /// + /// In en, this message translates to: + /// **'Lossless conversion — no quality loss'** + String get trackConvertLosslessHint; + /// Snackbar while converting /// /// In en, this message translates to: @@ -3794,6 +4049,78 @@ abstract class AppLocalizations { /// **'Conversion failed'** String get trackConvertFailed; + /// Title for CUE split bottom sheet + /// + /// In en, this message translates to: + /// **'Split CUE Sheet'** + String get cueSplitTitle; + + /// Subtitle for CUE split menu item + /// + /// In en, this message translates to: + /// **'Split CUE+FLAC into individual tracks'** + String get cueSplitSubtitle; + + /// Album name in CUE split sheet + /// + /// In en, this message translates to: + /// **'Album: {album}'** + String cueSplitAlbum(String album); + + /// Artist name in CUE split sheet + /// + /// In en, this message translates to: + /// **'Artist: {artist}'** + String cueSplitArtist(String artist); + + /// Number of tracks in CUE sheet + /// + /// In en, this message translates to: + /// **'{count} tracks'** + String cueSplitTrackCount(int count); + + /// CUE split confirmation dialog title + /// + /// In en, this message translates to: + /// **'Split CUE Album'** + String get cueSplitConfirmTitle; + + /// CUE split confirmation dialog message + /// + /// In en, this message translates to: + /// **'Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.'** + String cueSplitConfirmMessage(String album, int count); + + /// Snackbar while splitting CUE + /// + /// In en, this message translates to: + /// **'Splitting CUE sheet... ({current}/{total})'** + String cueSplitSplitting(int current, int total); + + /// Snackbar after successful CUE split + /// + /// In en, this message translates to: + /// **'Split into {count} tracks successfully'** + String cueSplitSuccess(int count); + + /// Snackbar when CUE split fails + /// + /// In en, this message translates to: + /// **'CUE split failed'** + String get cueSplitFailed; + + /// Error when CUE audio file is missing + /// + /// In en, this message translates to: + /// **'Audio file not found for this CUE sheet'** + String get cueSplitNoAudioFile; + + /// Button text to start CUE splitting + /// + /// In en, this message translates to: + /// **'Split into Tracks'** + String get cueSplitButton; + /// Generic action button - create /// /// In en, this message translates to: @@ -4074,6 +4401,12 @@ abstract class AppLocalizations { String bitrate, ); + /// Confirmation dialog message for lossless batch conversion + /// + /// In en, this message translates to: + /// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'** + String selectionBatchConvertConfirmMessageLossless(int count, String format); + /// Snackbar during batch conversion progress /// /// In en, this message translates to: @@ -4103,6 +4436,654 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Artist folders use Track Artist only'** String get downloadUseAlbumArtistForFoldersTrackSubtitle; + + /// Title for the lyrics provider priority page + /// + /// In en, this message translates to: + /// **'Lyrics Providers'** + String get lyricsProvidersTitle; + + /// Description on the lyrics provider priority page + /// + /// In en, this message translates to: + /// **'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'** + String get lyricsProvidersDescription; + + /// Info tip on lyrics provider priority page + /// + /// In en, this message translates to: + /// **'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'** + String get lyricsProvidersInfoText; + + /// Section header for enabled providers + /// + /// In en, this message translates to: + /// **'Enabled ({count})'** + String lyricsProvidersEnabledSection(int count); + + /// Section header for disabled providers + /// + /// In en, this message translates to: + /// **'Disabled ({count})'** + String lyricsProvidersDisabledSection(int count); + + /// Snackbar when user tries to disable the last enabled provider + /// + /// In en, this message translates to: + /// **'At least one provider must remain enabled'** + String get lyricsProvidersAtLeastOne; + + /// Snackbar after saving lyrics provider priority + /// + /// In en, this message translates to: + /// **'Lyrics provider priority saved'** + String get lyricsProvidersSaved; + + /// Body text of the discard-changes dialog on lyrics provider page + /// + /// In en, this message translates to: + /// **'You have unsaved changes that will be lost.'** + String get lyricsProvidersDiscardContent; + + /// Description for Spotify Lyrics API provider + /// + /// In en, this message translates to: + /// **'Spotify-sourced synced lyrics via community API'** + String get lyricsProviderSpotifyApiDesc; + + /// Description for LRCLIB provider + /// + /// In en, this message translates to: + /// **'Open-source synced lyrics database'** + String get lyricsProviderLrclibDesc; + + /// Description for Netease provider + /// + /// In en, this message translates to: + /// **'NetEase Cloud Music (good for Asian songs)'** + String get lyricsProviderNeteaseDesc; + + /// Description for Musixmatch provider + /// + /// In en, this message translates to: + /// **'Largest lyrics database (multi-language)'** + String get lyricsProviderMusixmatchDesc; + + /// Description for Apple Music provider + /// + /// In en, this message translates to: + /// **'Word-by-word synced lyrics (via proxy)'** + String get lyricsProviderAppleMusicDesc; + + /// Description for QQ Music provider + /// + /// In en, this message translates to: + /// **'QQ Music (good for Chinese songs, via proxy)'** + String get lyricsProviderQqMusicDesc; + + /// Generic description for extension-based lyrics providers + /// + /// In en, this message translates to: + /// **'Extension provider'** + String get lyricsProviderExtensionDesc; + + /// Title of SAF migration dialog + /// + /// In en, this message translates to: + /// **'Storage Update Required'** + String get safMigrationTitle; + + /// First paragraph of SAF migration dialog + /// + /// In en, this message translates to: + /// **'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'** + String get safMigrationMessage1; + + /// Second paragraph of SAF migration dialog + /// + /// In en, this message translates to: + /// **'Please select your download folder again to switch to the new storage system.'** + String get safMigrationMessage2; + + /// Snackbar after successfully migrating to SAF + /// + /// In en, this message translates to: + /// **'Download folder updated to SAF mode'** + String get safMigrationSuccess; + + /// Settings menu item - donate + /// + /// In en, this message translates to: + /// **'Donate'** + String get settingsDonate; + + /// Subtitle for donate menu item + /// + /// In en, this message translates to: + /// **'Support SpotiFLAC-Mobile development'** + String get settingsDonateSubtitle; + + /// Tooltip for the Love All button on album/playlist screens + /// + /// In en, this message translates to: + /// **'Love All'** + String get tooltipLoveAll; + + /// Tooltip for the Add to Playlist button + /// + /// In en, this message translates to: + /// **'Add to Playlist'** + String get tooltipAddToPlaylist; + + /// Snackbar after removing multiple tracks from Loved folder + /// + /// In en, this message translates to: + /// **'Removed {count} tracks from Loved'** + String snackbarRemovedTracksFromLoved(int count); + + /// Snackbar after adding multiple tracks to Loved folder + /// + /// In en, this message translates to: + /// **'Added {count} tracks to Loved'** + String snackbarAddedTracksToLoved(int count); + + /// Dialog title for bulk download confirmation + /// + /// In en, this message translates to: + /// **'Download All'** + String get dialogDownloadAllTitle; + + /// Body of the Download All confirmation dialog + /// + /// In en, this message translates to: + /// **'Download {count} tracks?'** + String dialogDownloadAllMessage(int count); + + /// Checkbox label in import dialog to skip already-downloaded songs + /// + /// In en, this message translates to: + /// **'Skip already downloaded songs'** + String get homeSkipAlreadyDownloaded; + + /// Context menu item to navigate to the album page + /// + /// In en, this message translates to: + /// **'Go to Album'** + String get homeGoToAlbum; + + /// Snackbar when album info cannot be loaded + /// + /// In en, this message translates to: + /// **'Album info not available'** + String get homeAlbumInfoUnavailable; + + /// Snackbar while loading a CUE sheet file + /// + /// In en, this message translates to: + /// **'Loading CUE sheet...'** + String get snackbarLoadingCueSheet; + + /// Snackbar after successfully saving track metadata + /// + /// In en, this message translates to: + /// **'Metadata saved successfully'** + String get snackbarMetadataSaved; + + /// Snackbar when lyrics embedding fails + /// + /// In en, this message translates to: + /// **'Failed to embed lyrics'** + String get snackbarFailedToEmbedLyrics; + + /// Snackbar when writing metadata back to file fails + /// + /// In en, this message translates to: + /// **'Failed to write back to storage'** + String get snackbarFailedToWriteStorage; + + /// Generic error snackbar with error detail + /// + /// In en, this message translates to: + /// **'Error: {error}'** + String snackbarError(String error); + + /// Snackbar when an extension button has no action configured + /// + /// In en, this message translates to: + /// **'No action defined for this button'** + String get snackbarNoActionDefined; + + /// Empty state message when an album has no tracks + /// + /// In en, this message translates to: + /// **'No tracks found for this album'** + String get noTracksFoundForAlbum; + + /// Subtitle text in Android download location bottom sheet + /// + /// In en, this message translates to: + /// **'Choose storage mode for downloaded files.'** + String get downloadLocationSubtitle; + + /// Storage mode option - use legacy app folder + /// + /// In en, this message translates to: + /// **'App folder (non-SAF)'** + String get storageModeAppFolder; + + /// Subtitle for app folder storage mode + /// + /// In en, this message translates to: + /// **'Use default Music/SpotiFLAC path'** + String get storageModeAppFolderSubtitle; + + /// Storage mode option - use Android SAF picker + /// + /// In en, this message translates to: + /// **'SAF folder'** + String get storageModeSaf; + + /// Subtitle for SAF storage mode + /// + /// In en, this message translates to: + /// **'Pick folder via Android Storage Access Framework'** + String get storageModeSafSubtitle; + + /// Description text in filename format bottom sheet + /// + /// In en, this message translates to: + /// **'Customize how your files are named.'** + String get downloadFilenameDescription; + + /// Label above filename tag chips + /// + /// In en, this message translates to: + /// **'Tap to insert tag:'** + String get downloadFilenameInsertTag; + + /// Subtitle when separate singles folder is enabled + /// + /// In en, this message translates to: + /// **'Albums/ and Singles/ folders'** + String get downloadSeparateSinglesEnabled; + + /// Subtitle when separate singles folder is disabled + /// + /// In en, this message translates to: + /// **'All files in same structure'** + String get downloadSeparateSinglesDisabled; + + /// Setting title for artist folder filter options + /// + /// In en, this message translates to: + /// **'Artist Name Filters'** + String get downloadArtistNameFilters; + + /// Setting title for adding a playlist folder prefix before the normal organization structure + /// + /// In en, this message translates to: + /// **'Create playlist source folder'** + String get downloadCreatePlaylistSourceFolder; + + /// Subtitle when playlist source folder prefix is enabled + /// + /// In en, this message translates to: + /// **'Playlist downloads use Playlist/ plus your normal folder structure.'** + String get downloadCreatePlaylistSourceFolderEnabled; + + /// Subtitle when playlist source folder prefix is disabled + /// + /// In en, this message translates to: + /// **'Playlist downloads use the normal folder structure only.'** + String get downloadCreatePlaylistSourceFolderDisabled; + + /// Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist + /// + /// In en, this message translates to: + /// **'By Playlist already places downloads inside a playlist folder.'** + String get downloadCreatePlaylistSourceFolderRedundant; + + /// Setting title for SongLink country region + /// + /// In en, this message translates to: + /// **'SongLink Region'** + String get downloadSongLinkRegion; + + /// Setting title for network compatibility toggle + /// + /// In en, this message translates to: + /// **'Network compatibility mode'** + String get downloadNetworkCompatibilityMode; + + /// Subtitle when network compatibility mode is enabled + /// + /// In en, this message translates to: + /// **'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'** + String get downloadNetworkCompatibilityModeEnabled; + + /// Subtitle when network compatibility mode is disabled + /// + /// In en, this message translates to: + /// **'Off: strict HTTPS certificate validation (recommended)'** + String get downloadNetworkCompatibilityModeDisabled; + + /// Hint shown instead of Ask-quality subtitle when no built-in service selected + /// + /// In en, this message translates to: + /// **'Select a built-in service to enable'** + String get downloadSelectServiceToEnable; + + /// Info hint when non-Tidal/Qobuz service is selected + /// + /// In en, this message translates to: + /// **'Select Tidal or Qobuz above to configure quality'** + String get downloadSelectTidalQobuz; + + /// Subtitle for Embed Lyrics when Embed Metadata is disabled + /// + /// In en, this message translates to: + /// **'Disabled while Embed Metadata is turned off'** + String get downloadEmbedLyricsDisabled; + + /// Toggle title for including Netease translated lyrics + /// + /// In en, this message translates to: + /// **'Netease: Include Translation'** + String get downloadNeteaseIncludeTranslation; + + /// Subtitle when Netease translation is enabled + /// + /// In en, this message translates to: + /// **'Append translated lyrics when available'** + String get downloadNeteaseIncludeTranslationEnabled; + + /// Subtitle when Netease translation is disabled + /// + /// In en, this message translates to: + /// **'Use original lyrics only'** + String get downloadNeteaseIncludeTranslationDisabled; + + /// Toggle title for including Netease romanized lyrics + /// + /// In en, this message translates to: + /// **'Netease: Include Romanization'** + String get downloadNeteaseIncludeRomanization; + + /// Subtitle when Netease romanization is enabled + /// + /// In en, this message translates to: + /// **'Append romanized lyrics when available'** + String get downloadNeteaseIncludeRomanizationEnabled; + + /// Subtitle when Netease romanization is disabled + /// + /// In en, this message translates to: + /// **'Disabled'** + String get downloadNeteaseIncludeRomanizationDisabled; + + /// Toggle title for Apple/QQ multi-person word-by-word lyrics + /// + /// In en, this message translates to: + /// **'Apple/QQ Multi-Person Word-by-Word'** + String get downloadAppleQqMultiPerson; + + /// Subtitle when multi-person word-by-word is enabled + /// + /// In en, this message translates to: + /// **'Enable v1/v2 speaker and [bg:] tags'** + String get downloadAppleQqMultiPersonEnabled; + + /// Subtitle when multi-person word-by-word is disabled + /// + /// In en, this message translates to: + /// **'Simplified word-by-word formatting'** + String get downloadAppleQqMultiPersonDisabled; + + /// Setting title for Musixmatch language preference + /// + /// In en, this message translates to: + /// **'Musixmatch Language'** + String get downloadMusixmatchLanguage; + + /// Option label when Musixmatch uses original language + /// + /// In en, this message translates to: + /// **'Auto (original)'** + String get downloadMusixmatchLanguageAuto; + + /// Toggle title for filtering contributing artists in Album Artist metadata + /// + /// In en, this message translates to: + /// **'Filter contributing artists in Album Artist'** + String get downloadFilterContributing; + + /// Subtitle when contributing artist filter is enabled + /// + /// In en, this message translates to: + /// **'Album Artist metadata uses primary artist only'** + String get downloadFilterContributingEnabled; + + /// Subtitle when contributing artist filter is disabled + /// + /// In en, this message translates to: + /// **'Keep full Album Artist metadata value'** + String get downloadFilterContributingDisabled; + + /// Subtitle for lyrics providers setting when no providers are enabled + /// + /// In en, this message translates to: + /// **'None enabled'** + String get downloadProvidersNoneEnabled; + + /// Label for the Musixmatch language code text field + /// + /// In en, this message translates to: + /// **'Language code'** + String get downloadMusixmatchLanguageCode; + + /// Hint text for the Musixmatch language code field + /// + /// In en, this message translates to: + /// **'auto / en / es / ja'** + String get downloadMusixmatchLanguageHint; + + /// Description in the Musixmatch language picker + /// + /// In en, this message translates to: + /// **'Set preferred language code (example: en, es, ja). Leave empty for auto.'** + String get downloadMusixmatchLanguageDesc; + + /// Button to reset Musixmatch language to automatic + /// + /// In en, this message translates to: + /// **'Auto'** + String get downloadMusixmatchAuto; + + /// Subtitle for 'Any' network mode option + /// + /// In en, this message translates to: + /// **'WiFi + Mobile Data'** + String get downloadNetworkAnySubtitle; + + /// Subtitle for 'WiFi only' network mode option + /// + /// In en, this message translates to: + /// **'Pause downloads on mobile data'** + String get downloadNetworkWifiOnlySubtitle; + + /// Description in the SongLink region picker + /// + /// In en, this message translates to: + /// **'Used as userCountry for SongLink API lookup.'** + String get downloadSongLinkRegionDesc; + + /// Snackbar when the audio format is not supported for the requested operation + /// + /// In en, this message translates to: + /// **'Unsupported audio format'** + String get snackbarUnsupportedAudioFormat; + + /// Tooltip for refresh button on cache management page + /// + /// In en, this message translates to: + /// **'Refresh'** + String get cacheRefresh; + + /// Dialog message for bulk playlist download confirmation + /// + /// In en, this message translates to: + /// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'** + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount); + + /// Button label for bulk downloading selected playlists + /// + /// In en, this message translates to: + /// **'Download {count} {count, plural, =1{playlist} other{playlists}}'** + String bulkDownloadPlaylistsButton(int count); + + /// Button label when no playlists are selected for download + /// + /// In en, this message translates to: + /// **'Select playlists to download'** + String get bulkDownloadSelectPlaylists; + + /// Snackbar when selected playlists contain no tracks + /// + /// In en, this message translates to: + /// **'Selected playlists have no tracks'** + String get snackbarSelectedPlaylistsEmpty; + + /// Playlist count display + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 playlist} other{{count} playlists}}'** + String playlistsCount(int count); + + /// Section title for selective online metadata auto-fill in the edit metadata sheet + /// + /// In en, this message translates to: + /// **'Auto-fill from online'** + String get editMetadataAutoFill; + + /// Description for the auto-fill section + /// + /// In en, this message translates to: + /// **'Select fields to fill automatically from online metadata'** + String get editMetadataAutoFillDesc; + + /// Button label to fetch online metadata and fill selected fields + /// + /// In en, this message translates to: + /// **'Fetch & Fill'** + String get editMetadataAutoFillFetch; + + /// Snackbar shown while searching for online metadata + /// + /// In en, this message translates to: + /// **'Searching online...'** + String get editMetadataAutoFillSearching; + + /// Snackbar when online metadata search returns no results + /// + /// In en, this message translates to: + /// **'No matching metadata found online'** + String get editMetadataAutoFillNoResults; + + /// Snackbar confirming how many fields were auto-filled + /// + /// In en, this message translates to: + /// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'** + String editMetadataAutoFillDone(int count); + + /// Snackbar when user taps Fetch without selecting any fields + /// + /// In en, this message translates to: + /// **'Select at least one field to auto-fill'** + String get editMetadataAutoFillNoneSelected; + + /// Chip label for title field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Title'** + String get editMetadataFieldTitle; + + /// Chip label for artist field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Artist'** + String get editMetadataFieldArtist; + + /// Chip label for album field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Album'** + String get editMetadataFieldAlbum; + + /// Chip label for album artist field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Album Artist'** + String get editMetadataFieldAlbumArtist; + + /// Chip label for date field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Date'** + String get editMetadataFieldDate; + + /// Chip label for track number field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Track #'** + String get editMetadataFieldTrackNum; + + /// Chip label for disc number field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Disc #'** + String get editMetadataFieldDiscNum; + + /// Chip label for genre field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Genre'** + String get editMetadataFieldGenre; + + /// Chip label for ISRC field in auto-fill selector + /// + /// In en, this message translates to: + /// **'ISRC'** + String get editMetadataFieldIsrc; + + /// Chip label for label field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Label'** + String get editMetadataFieldLabel; + + /// Chip label for copyright field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Copyright'** + String get editMetadataFieldCopyright; + + /// Chip label for cover art field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Cover Art'** + String get editMetadataFieldCover; + + /// Button to select all fields for auto-fill + /// + /// In en, this message translates to: + /// **'All'** + String get editMetadataSelectAll; + + /// Button to select only fields that are currently empty + /// + /// In en, this message translates to: + /// **'Empty only'** + String get editMetadataSelectEmpty; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index af8f7b73..7d2e6008 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -15,7 +15,7 @@ class AppLocalizationsDe extends AppLocalizations { String get navHome => 'Startseite'; @override - String get navLibrary => 'Archiv'; + String get navLibrary => 'Bibliothek'; @override String get navSettings => 'Einstellungen'; @@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aboutAppDescription => - 'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.'; + 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; @override String get artistAlbums => 'Alben'; @@ -500,7 +500,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupNotificationBackgroundDescription => - 'Werde benachrichtigt über Download-Fortschritt und -Fertigstellung. Dies hilft Ihnen, Downloads zu verfolgen, wenn die App im Hintergrund ist.'; + 'Erhalte Benachrichtigungen über den Fortschritt und die Fertigstellung deiner Downloads, selbst wenn die App im Hintergrund läuft.'; @override String get setupSkipForNow => 'Vorerst überspringen'; @@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dialogImport => 'Importieren'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Verwerfen'; @@ -592,11 +595,11 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get dialogImportPlaylistTitle => 'Wiedergabeliste importieren'; + String get dialogImportPlaylistTitle => 'Playlist importieren'; @override String dialogImportPlaylistMessage(int count) { - return '$count Titel in CSV gefunden. Zur Warteschlange hinzufügen?'; + return '$count Titel gefunden hinzufügen?'; } @override @@ -606,12 +609,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String snackbarAddedToQueue(String trackName) { - return '\"$trackName\" zur Warteschlange hinzugefügt'; + return '\"$trackName\" hinzugefügt'; } @override String snackbarAddedTracksToQueue(int count) { - return '$count Titel zur Warteschlange hinzugefügt'; + return '$count Titel hinzugefügt'; } @override @@ -701,6 +704,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String get errorNoTracksFound => 'Keine Titel gefunden'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle'; @@ -765,15 +779,22 @@ class AppLocalizationsDe extends AppLocalizations { String get filenameFormat => 'Dateinamenformat'; @override - String get filenameShowAdvancedTags => 'Show advanced tags'; + String get filenameShowAdvancedTags => 'Erweiterte Tags anzeigen'; @override String get filenameShowAdvancedTagsDescription => - 'Enable formatted tags for track padding and date patterns'; + 'Formatierte Tags für Track-Padding und Datumsmuster aktivieren'; @override String get folderOrganizationNone => 'Keine Organisation'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'Nach Künstler'; @@ -918,11 +939,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String logEntries(int count) { - return 'Entries ($count)'; + return '$count Einträge'; } @override - String get credentialsTitle => 'Spotify Credentials'; + String get credentialsTitle => 'Spotify-Anmeldedaten'; @override String get credentialsDescription => @@ -984,7 +1005,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Wähle wie Songtexte mit deinen Downloads gespeichert werden'; @override - String get lyricsModeEmbed => 'In Datei einbinden'; + String get lyricsModeEmbed => 'In Datei einbetten'; @override String get lyricsModeEmbedSubtitle => 'Lyrics in FLAC Metadaten gespeichert'; @@ -1001,7 +1022,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get lyricsModeBothSubtitle => - 'Lyrics einbinden und als .lrc speichern'; + 'Lyrics einbetten und als .lrc speichern'; @override String get sectionColor => 'Farbe'; @@ -1019,29 +1040,30 @@ class AppLocalizationsDe extends AppLocalizations { String get appearanceLanguage => 'App Sprache'; @override - String get settingsAppearanceSubtitle => 'Theme, colors, display'; + String get settingsAppearanceSubtitle => 'Design, Farben, Anzeige'; @override - String get settingsDownloadSubtitle => 'Service, quality, filename format'; + String get settingsDownloadSubtitle => 'Dienst, Qualität, Dateinamen-Format'; @override - String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; + String get settingsOptionsSubtitle => 'Fallback, Lyrics, Covers, Updates'; @override - String get settingsExtensionsSubtitle => 'Manage download providers'; + String get settingsExtensionsSubtitle => 'Download-Anbieter verwalten'; @override - String get settingsLogsSubtitle => 'View app logs for debugging'; + String get settingsLogsSubtitle => 'App-Logs zum Debuggen anzeigen'; @override - String get loadingSharedLink => 'Loading shared link...'; + String get loadingSharedLink => 'Link wird geladen...'; @override - String get pressBackAgainToExit => 'Press back again to exit'; + String get pressBackAgainToExit => + 'Drücke wieder \"zurück\" um die App zu beenden'; @override String downloadAllCount(int count) { - return 'Download All ($count)'; + return 'Alle $count Titel herunterladen'; } @override @@ -1049,65 +1071,65 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count Titel', + one: '1 Titel', ); return '$_temp0'; } @override - String get trackCopyFilePath => 'Copy file path'; + String get trackCopyFilePath => 'Dateipfad kopieren'; @override - String get trackRemoveFromDevice => 'Remove from device'; + String get trackRemoveFromDevice => 'Vom Gerät entfernen'; @override - String get trackLoadLyrics => 'Load Lyrics'; + String get trackLoadLyrics => 'Lade Lyrics'; @override - String get trackMetadata => 'Metadata'; + String get trackMetadata => 'Metadaten'; @override - String get trackFileInfo => 'File Info'; + String get trackFileInfo => 'Datei-Info'; @override String get trackLyrics => 'Lyrics'; @override - String get trackFileNotFound => 'File not found'; + String get trackFileNotFound => 'Datei nicht gefunden'; @override - String get trackOpenInDeezer => 'Open in Deezer'; + String get trackOpenInDeezer => 'In Deezer öffnen'; @override - String get trackOpenInSpotify => 'Open in Spotify'; + String get trackOpenInSpotify => 'In Spotify öffnen'; @override - String get trackTrackName => 'Track name'; + String get trackTrackName => 'Name des Titels'; @override - String get trackArtist => 'Artist'; + String get trackArtist => 'Künstler'; @override - String get trackAlbumArtist => 'Album artist'; + String get trackAlbumArtist => 'Album Künstler'; @override String get trackAlbum => 'Album'; @override - String get trackTrackNumber => 'Track number'; + String get trackTrackNumber => 'Titelnummer'; @override - String get trackDiscNumber => 'Disc number'; + String get trackDiscNumber => 'CD-Nummer'; @override - String get trackDuration => 'Duration'; + String get trackDuration => 'Länge'; @override - String get trackAudioQuality => 'Audio quality'; + String get trackAudioQuality => 'Audioqualität'; @override - String get trackReleaseDate => 'Release date'; + String get trackReleaseDate => 'Erscheinungsdatum'; @override String get trackGenre => 'Genre'; @@ -1116,71 +1138,73 @@ class AppLocalizationsDe extends AppLocalizations { String get trackLabel => 'Label'; @override - String get trackCopyright => 'Copyright'; + String get trackCopyright => 'Urheberrecht'; @override - String get trackDownloaded => 'Downloaded'; + String get trackDownloaded => 'Heruntergeladen'; @override - String get trackCopyLyrics => 'Copy lyrics'; + String get trackCopyLyrics => 'Lyrics kopieren'; @override - String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + String get trackLyricsNotAvailable => + 'Lyrics sind für diesen Titel nicht verfügbar'; @override - String get trackLyricsTimeout => 'Request timed out. Try again later.'; + String get trackLyricsTimeout => + 'Anfrage Timeout. Versuche es später erneut.'; @override - String get trackLyricsLoadFailed => 'Failed to load lyrics'; + String get trackLyricsLoadFailed => 'Fehler beim Laden der Lyrics'; @override - String get trackEmbedLyrics => 'Embed Lyrics'; + String get trackEmbedLyrics => 'Lyrics einbetten'; @override - String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + String get trackLyricsEmbedded => 'Lyrics erfolgreich eingebettet'; @override - String get trackInstrumental => 'Instrumental track'; + String get trackInstrumental => 'Instrumentalspur'; @override - String get trackCopiedToClipboard => 'Copied to clipboard'; + String get trackCopiedToClipboard => 'In Zwischenablage kopiert'; @override - String get trackDeleteConfirmTitle => 'Remove from device?'; + String get trackDeleteConfirmTitle => 'Vom Gerät entfernen?'; @override String get trackDeleteConfirmMessage => - 'This will permanently delete the downloaded file and remove it from your history.'; + 'Dies wird die heruntergeladene Datei dauerhaft löschen und sie aus deinem Verlauf entfernen.'; @override - String get dateToday => 'Today'; + String get dateToday => 'Heute'; @override - String get dateYesterday => 'Yesterday'; + String get dateYesterday => 'Gestern'; @override String dateDaysAgo(int count) { - return '$count days ago'; + return 'Vor $count Tagen'; } @override String dateWeeksAgo(int count) { - return '$count weeks ago'; + return 'Vor $count Wochen'; } @override String dateMonthsAgo(int count) { - return '$count months ago'; + return 'Vor $count Monaten'; } @override - String get storeFilterAll => 'All'; + String get storeFilterAll => 'Alle'; @override - String get storeFilterMetadata => 'Metadata'; + String get storeFilterMetadata => 'Metadaten'; @override - String get storeFilterDownload => 'Download'; + String get storeFilterDownload => 'Herunterladen'; @override String get storeFilterUtility => 'Utility'; @@ -1192,142 +1216,187 @@ class AppLocalizationsDe extends AppLocalizations { String get storeFilterIntegration => 'Integration'; @override - String get storeClearFilters => 'Clear filters'; + String get storeClearFilters => 'Filter entfernen'; @override - String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + String get storeAddRepoTitle => 'Add Extension Repository'; @override - String get extensionDefaultProviderSubtitle => 'Use built-in search'; + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; @override - String get extensionAuthor => 'Author'; + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + + @override + String get extensionDefaultProvider => 'Standard (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Eingebaute Suche verwenden'; + + @override + String get extensionAuthor => 'Entwickler'; @override String get extensionId => 'ID'; @override - String get extensionError => 'Error'; + String get extensionError => 'Fehler'; @override - String get extensionCapabilities => 'Capabilities'; + String get extensionCapabilities => 'Eigenschaften'; @override - String get extensionMetadataProvider => 'Metadata Provider'; + String get extensionMetadataProvider => 'Metadaten-Anbieter'; @override - String get extensionDownloadProvider => 'Download Provider'; + String get extensionDownloadProvider => 'Download-Anbieter'; @override - String get extensionLyricsProvider => 'Lyrics Provider'; + String get extensionLyricsProvider => 'Lyrics-Anbieter'; @override String get extensionUrlHandler => 'URL Handler'; @override - String get extensionQualityOptions => 'Quality Options'; + String get extensionQualityOptions => 'Qualitätsoptionen'; @override String get extensionPostProcessingHooks => 'Post-Processing Hooks'; @override - String get extensionPermissions => 'Permissions'; + String get extensionPermissions => 'Berechtigungen'; @override - String get extensionSettings => 'Settings'; + String get extensionSettings => 'Einstellungen'; @override - String get extensionRemoveButton => 'Remove Extension'; + String get extensionRemoveButton => 'Erweiterung entfernen'; @override - String get extensionUpdated => 'Updated'; + String get extensionUpdated => 'Aktualisiert'; @override - String get extensionMinAppVersion => 'Min App Version'; + String get extensionMinAppVersion => 'Min App-Version'; @override - String get extensionCustomTrackMatching => 'Custom Track Matching'; + String get extensionCustomTrackMatching => + 'Benutzerdefiniertes Track-Matching'; @override - String get extensionPostProcessing => 'Post-Processing'; + String get extensionPostProcessing => 'Post-processing'; @override String extensionHooksAvailable(int count) { - return '$count hook(s) available'; + return '$count Hook(s) verfügbar'; } @override String extensionPatternsCount(int count) { - return '$count pattern(s)'; + return '$count Muster'; } @override String extensionStrategy(String strategy) { - return 'Strategy: $strategy'; + return 'Strategie: $strategy'; } @override - String get extensionsProviderPrioritySection => 'Provider Priority'; + String get extensionsProviderPrioritySection => 'Provider-Priorität'; @override - String get extensionsInstalledSection => 'Installed Extensions'; + String get extensionsInstalledSection => 'Installierte Erweiterungen'; @override - String get extensionsNoExtensions => 'No extensions installed'; + String get extensionsNoExtensions => 'Keine Erweiterungen installiert'; @override String get extensionsNoExtensionsSubtitle => - 'Install .spotiflac-ext files to add new providers'; + 'Installiere .spotiflac-ext Dateien um neue Anbieter hinzuzufügen'; @override - String get extensionsInstallButton => 'Install Extension'; + String get extensionsInstallButton => 'Erweiterung installieren'; @override String get extensionsInfoTip => - 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; + 'Erweiterungen können neue Metadaten und Download-Anbieter hinzufügen. Installiere nur Erweiterungen von vertrauenswürdigen Quellen.'; @override - String get extensionsInstalledSuccess => 'Extension installed successfully'; + String get extensionsInstalledSuccess => + 'Erweiterung erfolgreich installiert'; @override - String get extensionsDownloadPriority => 'Download Priority'; + String get extensionsDownloadPriority => 'Download-Priorität'; @override - String get extensionsDownloadPrioritySubtitle => 'Set download service order'; + String get extensionsDownloadPrioritySubtitle => + 'Download-Service-Reihenfolge festlegen'; @override String get extensionsNoDownloadProvider => - 'No extensions with download provider'; + 'Keine Erweiterungen mit Download-Provider'; @override - String get extensionsMetadataPriority => 'Metadata Priority'; + String get extensionsMetadataPriority => 'Metadaten Priorität'; @override String get extensionsMetadataPrioritySubtitle => - 'Set search & metadata source order'; + 'Reihenfolge der Such- und Metadaten quellen festlegen'; @override String get extensionsNoMetadataProvider => - 'No extensions with metadata provider'; + 'Keine Erweiterungen mit Metadaten-Anbieter'; @override - String get extensionsSearchProvider => 'Search Provider'; + String get extensionsSearchProvider => 'Such-Provider'; @override - String get extensionsNoCustomSearch => 'No extensions with custom search'; + String get extensionsNoCustomSearch => + 'Keine Erweiterungen mit benutzerdefinierter Suche'; @override String get extensionsSearchProviderDescription => - 'Choose which service to use for searching tracks'; + 'Wähle den Dienst für die Suche von Titel'; @override - String get extensionsCustomSearch => 'Custom search'; + String get extensionsCustomSearch => 'Benutzerdefinierte Suche'; @override - String get extensionsErrorLoading => 'Error loading extension'; + String get extensionsErrorLoading => 'Fehler beim Laden der Erweiterung'; @override - String get qualityFlacLossless => 'FLAC Lossless'; + String get qualityFlacLossless => 'FLAC Verlustfrei'; @override String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; @@ -1336,21 +1405,53 @@ class AppLocalizationsDe extends AppLocalizations { String get qualityHiResFlac => 'Hi-Res FLAC'; @override - String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + String get qualityHiResFlacSubtitle => '24-Bit / bis 96kHz'; @override String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; @override - String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz'; + + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; @override String get qualityNote => - 'Actual quality depends on track availability from the service'; + 'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab'; @override String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; + 'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.'; @override String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; @@ -1359,13 +1460,13 @@ class AppLocalizationsDe extends AppLocalizations { String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; @override - String get downloadAskBeforeDownload => 'Ask Before Download'; + String get downloadAskBeforeDownload => 'Qualität vor Download fragen'; @override - String get downloadDirectory => 'Download Directory'; + String get downloadDirectory => 'Downloadverzeichnis'; @override - String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + String get downloadSeparateSinglesFolder => 'Singles Ordner trennen'; @override String get downloadAlbumFolderStructure => 'Album Folder Structure'; @@ -1378,53 +1479,53 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadUsePrimaryArtistOnlyEnabled => - 'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'; + 'Vorgestellte Künstler aus dem Ordnernamen entfernt (z.B. Justin Bieber, Quavo → Justin Bieber)'; @override String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; @override - String get downloadSelectQuality => 'Select Quality'; + String get downloadSelectQuality => 'Qualität wählen'; @override - String get downloadFrom => 'Download From'; + String get downloadFrom => 'Herunterladen von'; @override - String get appearanceAmoledDark => 'AMOLED Dark'; + String get appearanceAmoledDark => 'AMOLED Schwarz'; @override - String get appearanceAmoledDarkSubtitle => 'Pure black background'; + String get appearanceAmoledDarkSubtitle => 'AMOLED Hintergrund'; @override - String get queueClearAll => 'Clear All'; + String get queueClearAll => 'Alles löschen'; @override String get queueClearAllMessage => - 'Are you sure you want to clear all downloads?'; + 'Bist du dir sicher, dass du alle Downloads löschen möchten?'; @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @override String get settingsAutoExportFailedSubtitle => - 'Save failed downloads to TXT file automatically'; + 'Fehlgeschlagene Downloads automatisch in eine TXT-Datei speichern'; @override - String get settingsDownloadNetwork => 'Download Network'; + String get settingsDownloadNetwork => 'Download Netzwerk'; @override - String get settingsDownloadNetworkAny => 'WiFi + Mobile Data'; + String get settingsDownloadNetworkAny => 'WLAN + Mobile Daten'; @override - String get settingsDownloadNetworkWifiOnly => 'WiFi Only'; + String get settingsDownloadNetworkWifiOnly => 'Nur WLAN'; @override String get settingsDownloadNetworkSubtitle => - 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; + 'Wähle aus, welches Netzwerk für Downloads verwendet werden soll. Wenn nur WLAN aktiviert wird, werden Downloads auf mobilen Daten angehalten.'; @override - String get albumFolderArtistAlbum => 'Artist / Album'; + String get albumFolderArtistAlbum => 'Künstler/Album'; @override String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; @@ -1434,13 +1535,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get albumFolderArtistYearAlbumSubtitle => - 'Albums/Artist Name/[2005] Album Name/'; + 'Albums/Künster Name/[2005] Album Name/'; @override - String get albumFolderAlbumOnly => 'Album Only'; + String get albumFolderAlbumOnly => 'Nur Alben'; @override - String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + String get albumFolderAlbumOnlySubtitle => 'Alben/Album Name/'; @override String get albumFolderYearAlbum => '[Year] Album'; @@ -1456,39 +1557,39 @@ class AppLocalizationsDe extends AppLocalizations { 'Artist/Album/ and Artist/Singles/'; @override - String get downloadedAlbumDeleteSelected => 'Delete Selected'; + String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen'; @override String downloadedAlbumDeleteMessage(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'Titel', + one: 'Titel', ); - return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + return '$count $_temp0 aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.'; } @override String downloadedAlbumSelectedCount(int count) { - return '$count selected'; + return '$count ausgewählt'; } @override - String get downloadedAlbumAllSelected => 'All tracks selected'; + String get downloadedAlbumAllSelected => 'Alle Titel sind ausgewählt'; @override - String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + String get downloadedAlbumTapToSelect => 'Tippe auf Titel zum Auswählen'; @override String downloadedAlbumDeleteCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'Titel', + one: 'Titel', ); - return 'Delete $count $_temp0'; + return 'Lösche $count $_temp0'; } @override @@ -1500,22 +1601,22 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get recentTypeArtist => 'Artist'; + String get recentTypeArtist => 'Künstler'; @override String get recentTypeAlbum => 'Album'; @override - String get recentTypeSong => 'Song'; + String get recentTypeSong => 'Titel'; @override String get recentTypePlaylist => 'Playlist'; @override - String get recentEmpty => 'No recent items yet'; + String get recentEmpty => 'Noch keine aktuellen Einträge'; @override - String get recentShowAllDownloads => 'Show All Downloads'; + String get recentShowAllDownloads => 'Alle Downloads anzeigen'; @override String recentPlaylistInfo(String name) { @@ -1523,41 +1624,41 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get discographyDownload => 'Download Discography'; + String get discographyDownload => 'Diskographie herunterladen'; @override - String get discographyDownloadAll => 'Download All'; + String get discographyDownloadAll => 'Alle Herunterladen'; @override String discographyDownloadAllSubtitle(int count, int albumCount) { - return '$count tracks from $albumCount releases'; + return '$count Titel von $albumCount Releases'; } @override - String get discographyAlbumsOnly => 'Albums Only'; + String get discographyAlbumsOnly => 'Nur Alben'; @override String discographyAlbumsOnlySubtitle(int count, int albumCount) { - return '$count tracks from $albumCount albums'; + return '$count Titel von $albumCount Albums'; } @override - String get discographySinglesOnly => 'Singles & EPs Only'; + String get discographySinglesOnly => 'Nur Singles & EPs'; @override String discographySinglesOnlySubtitle(int count, int albumCount) { - return '$count tracks from $albumCount singles'; + return '$count Titel von $albumCount Singles'; } @override - String get discographySelectAlbums => 'Select Albums...'; + String get discographySelectAlbums => 'Alben auswählen...'; @override String get discographySelectAlbumsSubtitle => 'Choose specific albums or singles'; @override - String get discographyFetchingTracks => 'Fetching tracks...'; + String get discographyFetchingTracks => 'Lade Titel...'; @override String discographyFetchingAlbum(int current, int total) { @@ -1566,11 +1667,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String discographySelectedCount(int count) { - return '$count selected'; + return '$count ausgewählt'; } @override - String get discographyDownloadSelected => 'Download Selected'; + String get discographyDownloadSelected => 'Auswahl herunterladen'; @override String discographyAddedToQueue(int count) { @@ -1579,20 +1680,20 @@ class AppLocalizationsDe extends AppLocalizations { @override String discographySkippedDownloaded(int added, int skipped) { - return '$added added, $skipped already downloaded'; + return '$added hinzugefügt, $skipped bereits heruntergeladen'; } @override - String get discographyNoAlbums => 'No albums available'; + String get discographyNoAlbums => 'Es sind keine Alben verfügbar'; @override String get discographyFailedToFetch => 'Failed to fetch some albums'; @override - String get sectionStorageAccess => 'Storage Access'; + String get sectionStorageAccess => 'Speicherzugriff'; @override - String get allFilesAccess => 'All Files Access'; + String get allFilesAccess => 'Zugriff auf alle Dateien'; @override String get allFilesAccessEnabledSubtitle => 'Can write to any folder'; @@ -1602,170 +1703,189 @@ class AppLocalizationsDe extends AppLocalizations { @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.'; + 'Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.'; @override String get allFilesAccessDeniedMessage => - 'Permission was denied. Please enable \'All files access\' manually in system settings.'; + 'Zugriff verweigert. Bitte aktiviere \"Zugriff auf alle Dateien\" manuell in den Systemeinstellungen.'; @override String get allFilesAccessDisabledMessage => - 'All Files Access disabled. The app will use limited storage access.'; + 'Zugriff auf alle Dateien ist deaktiviert. Die App verwendet nur begrenzten Zugriff auf den Speicher.'; @override - String get settingsLocalLibrary => 'Local Library'; + String get settingsLocalLibrary => 'Lokale Bibliothek'; @override String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; @override - String get settingsCache => 'Storage & Cache'; + String get settingsCache => 'Speicher & Cache'; @override String get settingsCacheSubtitle => 'View size and clear cached data'; @override - String get libraryTitle => 'Local Library'; + String get libraryTitle => 'Lokale Bibliothek'; @override - String get libraryScanSettings => 'Scan Settings'; + String get libraryScanSettings => 'Scan Einstellungen'; @override - String get libraryEnableLocalLibrary => 'Enable Local Library'; + String get libraryEnableLocalLibrary => 'Lokale Bibliothek aktivieren'; @override String get libraryEnableLocalLibrarySubtitle => 'Scan and track your existing music'; @override - String get libraryFolder => 'Library Folder'; + String get libraryFolder => 'Bibliotheksordner'; @override - String get libraryFolderHint => 'Tap to select folder'; + String get libraryFolderHint => 'Tippe um Ordner auszuwählen'; @override String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; @override String get libraryShowDuplicateIndicatorSubtitle => - 'Show when searching for existing tracks'; + 'Bei der Suche nach vorhandenen Titeln anzeigen'; @override - String get libraryActions => 'Actions'; + String get libraryAutoScan => 'Auto Scan'; @override - String get libraryScan => 'Scan Library'; + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; @override - String get libraryScanSubtitle => 'Scan for audio files'; + String get libraryAutoScanOff => 'Off'; @override - String get libraryScanSelectFolderFirst => 'Select a folder first'; + String get libraryAutoScanOnOpen => 'Every app open'; @override - String get libraryCleanupMissingFiles => 'Cleanup Missing Files'; + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + + @override + String get libraryActions => 'Aktionen'; + + @override + String get libraryScan => 'Bibliothek scannen'; + + @override + String get libraryScanSubtitle => 'Suche nach Audiodateien'; + + @override + String get libraryScanSelectFolderFirst => 'Wähle zuerst einen Ordner'; + + @override + String get libraryCleanupMissingFiles => 'Fehlende Dateien bereinigen'; @override String get libraryCleanupMissingFilesSubtitle => - 'Remove entries for files that no longer exist'; + 'Verlaufseinträge für Dateien löschen, die nicht mehr existieren'; @override - String get libraryClear => 'Clear Library'; + String get libraryClear => 'Bibliothek löschen'; @override - String get libraryClearSubtitle => 'Remove all scanned tracks'; + String get libraryClearSubtitle => 'Alle gescannten Titel entfernen'; @override - String get libraryClearConfirmTitle => 'Clear Library'; + String get libraryClearConfirmTitle => 'Bibliothek löschen'; @override String get libraryClearConfirmMessage => - 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'; + 'Dadurch werden alle gescannten Titel aus Ihrer Bibliothek entfernt. Ihre eigentlichen Musikdateien werden nicht gelöscht.'; @override - String get libraryAbout => 'About Local Library'; + String get libraryAbout => 'Über die lokale Bibliothek'; @override String get libraryAboutDescription => - 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; + 'Durchsucht deine bestehende Musiksammlung, um Duplikate beim Herunterladen zu erkennen. Unterstützt die Formate FLAC, M4A, MP3, Opus und OGG. Metadaten werden, sofern verfügbar, aus den Dateitags gelesen.'; @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: '$count Titel', + one: '1 Titel', ); return '$_temp0'; } @override String libraryLastScanned(String time) { - return 'Last scanned: $time'; + return 'Zuletzt gescannt: $time'; } @override - String get libraryLastScannedNever => 'Never'; + String get libraryLastScannedNever => 'Nie'; @override - String get libraryScanning => 'Scanning...'; + String get libraryScanning => 'Scannen...'; @override String libraryScanProgress(String progress, int total) { - return '$progress% of $total files'; + return '$progress% von $total Dateien'; } @override - String get libraryInLibrary => 'In Library'; + String get libraryInLibrary => 'In Bibliothek'; @override String libraryRemovedMissingFiles(int count) { - return 'Removed $count missing files from library'; + return 'Entfernte $count fehlende Dateien aus der Bibliothek'; } @override - String get libraryCleared => 'Library cleared'; + String get libraryCleared => 'Bibliothek geleert'; @override - String get libraryStorageAccessRequired => 'Storage Access Required'; + String get libraryStorageAccessRequired => 'Speicherzugriff erforderlich'; @override String get libraryStorageAccessMessage => - 'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.'; + 'SpotiFLAC benötigt Speicherzugriff, um deine Musikbibliothek zu scannen. Bitte erteile die Berechtigung in den Einstellungen.'; @override - String get libraryFolderNotExist => 'Selected folder does not exist'; + String get libraryFolderNotExist => 'Der ausgewählte Ordner existiert nicht'; @override - String get librarySourceDownloaded => 'Downloaded'; + String get librarySourceDownloaded => 'Heruntergeladen'; @override - String get librarySourceLocal => 'Local'; + String get librarySourceLocal => 'Lokal'; @override - String get libraryFilterAll => 'All'; + String get libraryFilterAll => 'Alle'; @override - String get libraryFilterDownloaded => 'Downloaded'; + String get libraryFilterDownloaded => 'Heruntergeladen'; @override - String get libraryFilterLocal => 'Local'; + String get libraryFilterLocal => 'Lokal'; @override - String get libraryFilterTitle => 'Filters'; + String get libraryFilterTitle => 'Filter'; @override - String get libraryFilterReset => 'Reset'; + String get libraryFilterReset => 'Zurücksetzen'; @override - String get libraryFilterApply => 'Apply'; + String get libraryFilterApply => 'Anwenden'; @override - String get libraryFilterSource => 'Source'; + String get libraryFilterSource => 'Quelle'; @override - String get libraryFilterQuality => 'Quality'; + String get libraryFilterQuality => 'Qualität'; @override String get libraryFilterQualityHiRes => 'Hi-Res (24bit)'; @@ -1774,30 +1894,30 @@ class AppLocalizationsDe extends AppLocalizations { String get libraryFilterQualityCD => 'CD (16bit)'; @override - String get libraryFilterQualityLossy => 'Lossy'; + String get libraryFilterQualityLossy => 'Verlustbehaftet'; @override String get libraryFilterFormat => 'Format'; @override - String get libraryFilterSort => 'Sort'; + String get libraryFilterSort => 'Sortieren'; @override - String get libraryFilterSortLatest => 'Latest'; + String get libraryFilterSortLatest => 'Neuste'; @override - String get libraryFilterSortOldest => 'Oldest'; + String get libraryFilterSortOldest => 'Älteste'; @override - String get timeJustNow => 'Just now'; + String get timeJustNow => 'Gerade eben'; @override String timeMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count minutes ago', - one: '1 minute ago', + other: 'vor $count Minuten', + one: 'vor $count Minute', ); return '$_temp0'; } @@ -1807,199 +1927,201 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count hours ago', - one: '1 hour ago', + other: 'vor $count Stunden', + one: 'vor $count Stunde', ); return '$_temp0'; } @override - String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + String get tutorialWelcomeTitle => 'Willkommen bei SpotiFLAC!'; @override String get tutorialWelcomeDesc => - 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + 'Lass uns lernen, wie du deine Lieblingsmusik in verlustfreier Qualität herunterlädst. Dieses schnelle Tutorial zeigt dir die Grundlagen.'; @override String get tutorialWelcomeTip1 => - 'Download music from Spotify, Deezer, or paste any supported URL'; + 'Lade Musik von Spotify, Deezer herunter oder jeden unterstützten Link einfügen'; @override String get tutorialWelcomeTip2 => - 'FLAC-Qualität von Tidal, Qobuz oder Deezer'; + 'Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik'; @override String get tutorialWelcomeTip3 => - 'Automatic metadata, cover art, and lyrics embedding'; + 'Automatische Metadaten, Cover und Lyrics einbetten'; @override - String get tutorialSearchTitle => 'Finding Music'; + String get tutorialSearchTitle => 'Suche Musik'; @override String get tutorialSearchDesc => - 'There are two easy ways to find music you want to download.'; + 'Es gibt zwei einfache Möglichkeiten, Musik zu finden, die du herunterladen möchtest.'; @override - String get tutorialDownloadTitle => 'Downloading Music'; + String get tutorialDownloadTitle => 'Musik wird heruntergeladen'; @override String get tutorialDownloadDesc => - 'Downloading music is simple and fast. Here\'s how it works.'; + 'Das Herunterladen von Musik ist einfach und schnell. So funktioniert es.'; @override - String get tutorialLibraryTitle => 'Your Library'; + String get tutorialLibraryTitle => 'Deine Bibliothek'; @override String get tutorialLibraryDesc => - 'All your downloaded music is organized in the Library tab.'; + 'Die gesamte heruntergeladene Musik ist in der Bibliothek organisiert.'; @override String get tutorialLibraryTip1 => - 'View download progress and queue in the Library tab'; + 'Fortschritt und Warteschlange im Bibliothek‑Tab anzeigen'; @override String get tutorialLibraryTip2 => - 'Tap any track to play it with your music player'; + 'Tippe auf einen Titel, um ihn mit deinem Musikplayer abzuspielen'; @override String get tutorialLibraryTip3 => - 'Switch between list and grid view for better browsing'; + 'Wechsle zwischen Listen- und Gitteransicht für ein besseres Surfen'; @override - String get tutorialExtensionsTitle => 'Extensions'; + String get tutorialExtensionsTitle => 'Erweiterungen'; @override String get tutorialExtensionsDesc => - 'Extend the app\'s capabilities with community extensions.'; + 'Erweitere die Fähigkeiten der App mit Community-Erweiterungen.'; @override String get tutorialExtensionsTip1 => - 'Browse the Store tab to discover useful extensions'; + 'Im Store Tab findest du nützliche Erweiterungen'; @override String get tutorialExtensionsTip2 => - 'Add new download providers or search sources'; + 'Neue Download- oder Suchanbieter hinzufügen'; @override String get tutorialExtensionsTip3 => - 'Get lyrics, enhanced metadata, and more features'; + 'Lyrics, erweiterte Metadaten und mehr Funktionen erhalten'; @override - String get tutorialSettingsTitle => 'Customize Your Experience'; + String get tutorialSettingsTitle => 'Passe deine Benutzererfahrung an'; @override String get tutorialSettingsDesc => - 'Personalize the app in Settings to match your preferences.'; + 'Personalisiere die App in den Einstellungen nach deiner Präferenz.'; @override String get tutorialSettingsTip1 => - 'Change download location and folder organization'; + 'Downloadverzeichnis und Ordnerorganisation ändern'; @override String get tutorialSettingsTip2 => - 'Set default audio quality and format preferences'; + 'Standard Audioqualität und Formateinstellungen festlegen'; @override - String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + String get tutorialSettingsTip3 => 'App-Design und Aussehen anpassen'; @override String get tutorialReadyMessage => - 'You\'re all set! Start downloading your favorite music now.'; + 'Das ist alles! Lade jetzt deine Lieblingsmusik herunter.'; @override - String get libraryForceFullScan => 'Force Full Scan'; + String get libraryForceFullScan => 'Vollen Neu-Scan erzwingen'; @override - String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache'; + String get libraryForceFullScanSubtitle => + 'Alle Dateien erneut scannen und Cache ignorieren'; @override - String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads'; + String get cleanupOrphanedDownloads => 'Verwaiste Downloads bereinigen'; @override String get cleanupOrphanedDownloadsSubtitle => - 'Remove history entries for files that no longer exist'; + 'Verlaufseinträge für Dateien löschen, die nicht mehr existieren'; @override String cleanupOrphanedDownloadsResult(int count) { - return 'Removed $count orphaned entries from history'; + return 'Entfernte $count verwaiste Einträge aus dem Verlauf'; } @override - String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; + String get cleanupOrphanedDownloadsNone => + 'Keine verwaisten Einträge gefunden'; @override - String get cacheTitle => 'Storage & Cache'; + String get cacheTitle => 'Speicher & Cache'; @override - String get cacheSummaryTitle => 'Cache overview'; + String get cacheSummaryTitle => 'Cache-Übersicht'; @override String get cacheSummarySubtitle => - 'Clearing cache will not remove downloaded music files.'; + 'Das Leeren des Caches entfernt nicht heruntergeladene Musikdateien.'; @override String cacheEstimatedTotal(String size) { - return 'Estimated cache usage: $size'; + return 'Geschätzte Cache-Größe: $size'; } @override - String get cacheSectionStorage => 'Cached Data'; + String get cacheSectionStorage => 'Zwischengespeicherte Daten'; @override - String get cacheSectionMaintenance => 'Maintenance'; + String get cacheSectionMaintenance => 'Wartung'; @override - String get cacheAppDirectory => 'App cache directory'; + String get cacheAppDirectory => 'App-Cache Verzeichnis'; @override String get cacheAppDirectoryDesc => - 'HTTP responses, WebView data, and other temporary app data.'; + 'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.'; @override - String get cacheTempDirectory => 'Temporary directory'; + String get cacheTempDirectory => 'Temporäres Verzeichnis'; @override String get cacheTempDirectoryDesc => - 'Temporary files from downloads and audio conversion.'; + 'Temporäre Dateien von Downloads und Audio-Konvertierung.'; @override - String get cacheCoverImage => 'Cover image cache'; + String get cacheCoverImage => 'Cover-Cache'; @override String get cacheCoverImageDesc => - 'Downloaded album and track cover art. Will re-download when viewed.'; + 'Album- und Titelcover heruntergeladen. Werden erneut heruntergeladen.'; @override - String get cacheLibraryCover => 'Library cover cache'; + String get cacheLibraryCover => 'Bibliotheks-Cover-Cache'; @override String get cacheLibraryCoverDesc => - 'Cover art extracted from local music files. Will re-extract on next scan.'; + 'Cover aus lokalen Musikdateien extrahiert. Wird beim nächsten Scannen neu extrahiert.'; @override - String get cacheExploreFeed => 'Explore feed cache'; + String get cacheExploreFeed => 'Feed-Cache entdecken'; @override String get cacheExploreFeedDesc => - 'Explore tab content (new releases, trending). Will refresh on next visit.'; + 'Startseiten-Inhalt (neue Releases, Trends). Wird bei einem Neustart aktualisiert.'; @override - String get cacheTrackLookup => 'Track lookup cache'; + String get cacheTrackLookup => 'Titel Such-Cache'; @override String get cacheTrackLookupDesc => - 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'; + 'Spotify/Deezer Track-ID-Lookups. Das Löschen kann die nächsten Suchergebnisse verlangsamen.'; @override String get cacheCleanupUnusedDesc => - 'Remove orphaned download history and library entries for missing files.'; + 'Verwaisten Downloadverlauf und Bibliothekseinträge für fehlende Dateien entfernen.'; @override - String get cacheNoData => 'No cached data'; + String get cacheNoData => 'Keine gecachten Daten'; @override String cacheSizeWithFiles(String size, int count) { - return '$size in $count files'; + return '$size in $count Dateien'; } @override @@ -2009,71 +2131,71 @@ class AppLocalizationsDe extends AppLocalizations { @override String cacheEntries(int count) { - return '$count entries'; + return '$count Einträge'; } @override String cacheClearSuccess(String target) { - return 'Cleared: $target'; + return 'Entfernt: $target'; } @override - String get cacheClearConfirmTitle => 'Clear cache?'; + String get cacheClearConfirmTitle => 'Cache leeren?'; @override String cacheClearConfirmMessage(String target) { - return 'This will clear cached data for $target. Downloaded music files will not be deleted.'; + return 'Dies löscht zwischengespeicherte Daten in $target. Die Musikdateien werden nicht gelöscht.'; } @override - String get cacheClearAllConfirmTitle => 'Clear all cache?'; + String get cacheClearAllConfirmTitle => 'Gesamten Cache leeren?'; @override String get cacheClearAllConfirmMessage => - 'This will clear all cache categories on this page. Downloaded music files will not be deleted.'; + 'Dadurch werden alle Cache-Kategorien auf dieser Seite gelöscht. Heruntergeladene Musikdateien werden nicht gelöscht.'; @override - String get cacheClearAll => 'Clear all cache'; + String get cacheClearAll => 'Gesamten Cache leeren'; @override - String get cacheCleanupUnused => 'Cleanup unused data'; + String get cacheCleanupUnused => 'Unbenutzte Daten bereinigen'; @override String get cacheCleanupUnusedSubtitle => - 'Remove orphaned download history and missing library entries'; + 'Verwaisten Downloadverlauf und fehlende Bibliothekseinträge löschen'; @override String cacheCleanupResult(int downloadCount, int libraryCount) { - return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries'; + return 'Bereinigung: $downloadCount verwaiste Downloads, $libraryCount fehlende Bibliothekseinträge'; } @override - String get cacheRefreshStats => 'Refresh stats'; + String get cacheRefreshStats => 'Statistik aktualisieren'; @override - String get trackSaveCoverArt => 'Save Cover Art'; + String get trackSaveCoverArt => 'Cover speichern'; @override - String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + String get trackSaveCoverArtSubtitle => 'Albumcover als .jpg Datei speichern'; @override - String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + String get trackSaveLyrics => 'Lyrics als .lrc speichern'; @override - String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + String get trackSaveLyricsSubtitle => 'Lade Lyrics als .lrc Datei'; @override - String get trackSaveLyricsProgress => 'Saving lyrics...'; + String get trackSaveLyricsProgress => 'Speichere Lyrics...'; @override - String get trackReEnrich => 'Re-enrich'; + String get trackReEnrich => 'Neu-anreichern'; @override String get trackReEnrichOnlineSubtitle => - 'Search metadata online and embed into file'; + 'Metadaten online suchen und in Datei einbinden'; @override - String get trackEditMetadata => 'Edit Metadata'; + String get trackEditMetadata => 'Metadaten bearbeiten'; @override String trackCoverSaved(String fileName) { @@ -2085,43 +2207,66 @@ class AppLocalizationsDe extends AppLocalizations { @override String trackLyricsSaved(String fileName) { - return 'Lyrics saved to $fileName'; + return 'Lyrics in $fileName gespeichert'; } @override - String get trackReEnrichProgress => 'Re-enriching metadata...'; + String get trackReEnrichProgress => 'Metadaten neu anreichern...'; @override - String get trackReEnrichSearching => 'Searching metadata online...'; + String get trackReEnrichSearching => 'Suche Metadaten online...'; @override - String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + String get trackReEnrichSuccess => 'Metadaten erfolgreich neu angereichert'; @override - String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + String get trackReEnrichFfmpegFailed => + 'FFmpeg Metadaten-Einbettung fehlgeschlagen'; + + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } @override String trackSaveFailed(String error) { - return 'Failed: $error'; + return 'Fehler: $error'; } @override - String get trackConvertFormat => 'Convert Format'; + String get trackConvertFormat => 'Format konvertieren'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => 'In MP3 oder Opus konvertieren'; @override - String get trackConvertTitle => 'Convert Audio'; + String get trackConvertTitle => 'Audio konvertieren'; @override - String get trackConvertTargetFormat => 'Target Format'; + String get trackConvertTargetFormat => 'Zielformat'; @override String get trackConvertBitrate => 'Bitrate'; @override - String get trackConvertConfirmTitle => 'Confirm Conversion'; + String get trackConvertConfirmTitle => 'Konvertierung bestätigen'; @override String trackConvertConfirmMessage( @@ -2129,198 +2274,259 @@ class AppLocalizationsDe extends AppLocalizations { String targetFormat, String bitrate, ) { - return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; + return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.'; } @override - String get trackConvertConverting => 'Converting audio...'; + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + + @override + String get trackConvertConverting => 'Konvertiere Audio...'; @override String trackConvertSuccess(String format) { - return 'Converted to $format successfully'; + return 'Konvertiert in $format erfolgreich'; } @override - String get trackConvertFailed => 'Conversion failed'; + String get trackConvertFailed => 'Konvertierung fehlgeschlagen'; @override - String get actionCreate => 'Create'; + String get cueSplitTitle => 'Split CUE Sheet'; @override - String get collectionFoldersTitle => 'My folders'; + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; @override - String get collectionWishlist => 'Wishlist'; + String cueSplitAlbum(String album) { + return 'Album: $album'; + } @override - String get collectionLoved => 'Loved'; + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } @override - String get collectionPlaylists => 'Playlists'; + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + + @override + String get actionCreate => 'Erstellen'; + + @override + String get collectionFoldersTitle => 'Meine Ordner'; + + @override + String get collectionWishlist => 'Wunschliste'; + + @override + String get collectionLoved => 'Lieblingssongs'; + + @override + String get collectionPlaylists => 'Playlisten'; @override String get collectionPlaylist => 'Playlist'; @override - String get collectionAddToPlaylist => 'Add to playlist'; + String get collectionAddToPlaylist => 'Zur Playlist hinzufügen'; @override - String get collectionCreatePlaylist => 'Create playlist'; + String get collectionCreatePlaylist => 'Playlist erstellen'; @override - String get collectionNoPlaylistsYet => 'No playlists yet'; + String get collectionNoPlaylistsYet => 'Noch keine Playlists'; @override String get collectionNoPlaylistsSubtitle => - 'Create a playlist to start categorizing tracks'; + 'Playlist erstellen, um Titel zu kategorisieren'; @override String collectionPlaylistTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count Titel', + one: '1 Titel', ); return '$_temp0'; } @override String collectionAddedToPlaylist(String playlistName) { - return 'Added to \"$playlistName\"'; + return 'Zu \"$playlistName \" hinzugefügt'; } @override String collectionAlreadyInPlaylist(String playlistName) { - return 'Already in \"$playlistName\"'; + return 'Bereits in \"$playlistName\"'; } @override - String get collectionPlaylistCreated => 'Playlist created'; + String get collectionPlaylistCreated => 'Playlist erstellt'; @override - String get collectionPlaylistNameHint => 'Playlist name'; + String get collectionPlaylistNameHint => 'Playlist-Name'; @override - String get collectionPlaylistNameRequired => 'Playlist name is required'; + String get collectionPlaylistNameRequired => 'Playlist-Name ist erforderlich'; @override - String get collectionRenamePlaylist => 'Rename playlist'; + String get collectionRenamePlaylist => 'Playlist umbenennen'; @override - String get collectionDeletePlaylist => 'Delete playlist'; + String get collectionDeletePlaylist => 'Playlist löschen'; @override String collectionDeletePlaylistMessage(String playlistName) { - return 'Delete \"$playlistName\" and all tracks inside it?'; + return 'Willst du \"$playlistName\" und alle darin enthaltenen Titel löschen?'; } @override - String get collectionPlaylistDeleted => 'Playlist deleted'; + String get collectionPlaylistDeleted => 'Playlist gelöscht'; @override - String get collectionPlaylistRenamed => 'Playlist renamed'; + String get collectionPlaylistRenamed => 'Playlist umbenannt'; @override - String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + String get collectionWishlistEmptyTitle => 'Wunschliste ist leer'; @override String get collectionWishlistEmptySubtitle => - 'Tap + on tracks to save what you want to download later'; + 'Tippe auf das + bei den Titeln, um sie zum späteren Herunterladen zu speichern'; @override - String get collectionLovedEmptyTitle => 'Loved folder is empty'; + String get collectionLovedEmptyTitle => 'Lieblingssongs sind leer'; @override String get collectionLovedEmptySubtitle => - 'Tap love on tracks to keep your favorites'; + 'Tippe auf das Herz, um deine Favoriten zu behalten'; @override - String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + String get collectionPlaylistEmptyTitle => 'Die Playlist ist leer'; @override String get collectionPlaylistEmptySubtitle => - 'Long-press + on any track to add it here'; + 'Drücke lange + auf einem beliebigen Titel, um ihn hier hinzuzufügen'; @override - String get collectionRemoveFromPlaylist => 'Remove from playlist'; + String get collectionRemoveFromPlaylist => 'Von Playlist entfernen'; @override - String get collectionRemoveFromFolder => 'Remove from folder'; + String get collectionRemoveFromFolder => 'Aus Ordner entfernen'; @override String collectionRemoved(String trackName) { - return '\"$trackName\" removed'; + return '\"$trackName\" entfernt'; } @override String collectionAddedToLoved(String trackName) { - return '\"$trackName\" added to Loved'; + return '\"$trackName\" zu Lieblingssongs hinzugefügt'; } @override String collectionRemovedFromLoved(String trackName) { - return '\"$trackName\" removed from Loved'; + return '\"$trackName\" aus Lieblingssongs entfernt'; } @override String collectionAddedToWishlist(String trackName) { - return '\"$trackName\" added to Wishlist'; + return '\"$trackName\" zur Wunschliste hinzugefügt'; } @override String collectionRemovedFromWishlist(String trackName) { - return '\"$trackName\" removed from Wishlist'; + return '\"$trackName\" aus der Wunschliste entfernt'; } @override - String get trackOptionAddToLoved => 'Add to Loved'; + String get trackOptionAddToLoved => 'Zu Lieblingssongs hinzufügen'; @override - String get trackOptionRemoveFromLoved => 'Remove from Loved'; + String get trackOptionRemoveFromLoved => 'Aus Lieblingssongs entfernt'; @override - String get trackOptionAddToWishlist => 'Add to Wishlist'; + String get trackOptionAddToWishlist => 'Zur Wunschliste hinzufügen'; @override - String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + String get trackOptionRemoveFromWishlist => 'Von der Wunschliste entfernen'; @override - String get collectionPlaylistChangeCover => 'Change cover image'; + String get collectionPlaylistChangeCover => 'Coverbild ändern'; @override - String get collectionPlaylistRemoveCover => 'Remove cover image'; + String get collectionPlaylistRemoveCover => 'Cover entfernen'; @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'Titel', + one: 'Titel', ); - return 'Share $count $_temp0'; + return 'Teile $count $_temp0'; } @override - String get selectionShareNoFiles => 'No shareable files found'; + String get selectionShareNoFiles => 'Keine teilbare Dateien gefunden'; @override String selectionConvertCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'Titel', + one: 'Titel', ); - return 'Convert $count $_temp0'; + return 'Konvertiere $count $_temp0'; } @override - String get selectionConvertNoConvertible => 'No convertible tracks selected'; + String get selectionConvertNoConvertible => + 'Keine konvertierbare Titel ausgewählt'; @override - String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + String get selectionBatchConvertConfirmTitle => 'Batch-Konvertierung'; @override String selectionBatchConvertConfirmMessage( @@ -2328,35 +2534,460 @@ class AppLocalizationsDe extends AppLocalizations { String format, String bitrate, ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Titel', + one: 'Titel', + ); + return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.'; + } + + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, other: 'tracks', one: 'track', ); - return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } @override String selectionBatchConvertProgress(int current, int total) { - return 'Converting $current of $total...'; + return 'Konvertiere $current von $total...'; } @override String selectionBatchConvertSuccess(int success, int total, String format) { - return 'Converted $success of $total tracks to $format'; + return '$success von $total Titeln in $format konvertiert'; } @override String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; + return '$count heruntergeladen'; } @override String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; + 'Künstlerordner verwenden den Album-Interpreten, wenn verfügbar'; @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c98cf0ee..594df94e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -688,6 +691,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +775,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -1178,7 +1199,48 @@ class AppLocalizationsEn extends AppLocalizations { String get storeClearFilters => 'Clear filters'; @override - String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + + @override + String get extensionDefaultProvider => 'Default (Deezer)'; @override String get extensionDefaultProviderSubtitle => 'Use built-in search'; @@ -1327,6 +1389,38 @@ class AppLocalizationsEn extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1633,6 +1727,25 @@ class AppLocalizationsEn extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; @@ -2083,6 +2196,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2092,7 +2227,8 @@ class AppLocalizationsEn extends AppLocalizations { String get trackConvertFormat => 'Convert Format'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => + 'Convert to MP3, Opus, ALAC, or FLAC'; @override String get trackConvertTitle => 'Convert Audio'; @@ -2115,6 +2251,18 @@ class AppLocalizationsEn extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2126,6 +2274,54 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2320,6 +2516,17 @@ class AppLocalizationsEn extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; @@ -2342,4 +2549,418 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index efc7a016..d99eec4d 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -525,6 +525,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -688,6 +691,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +775,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -1177,6 +1198,47 @@ class AppLocalizationsEs extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1327,6 +1389,38 @@ class AppLocalizationsEs extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1633,6 +1727,25 @@ class AppLocalizationsEs extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; @@ -2083,6 +2196,28 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2092,7 +2227,8 @@ class AppLocalizationsEs extends AppLocalizations { String get trackConvertFormat => 'Convert Format'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => + 'Convert to MP3, Opus, ALAC, or FLAC'; @override String get trackConvertTitle => 'Convert Audio'; @@ -2115,6 +2251,18 @@ class AppLocalizationsEs extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2126,6 +2274,54 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2320,6 +2516,17 @@ class AppLocalizationsEs extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; @@ -2342,6 +2549,420 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 89d94355..5b1c8d81 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; @override String get artistAlbums => 'Albums'; @@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -690,6 +693,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -763,6 +777,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -1179,6 +1200,47 @@ class AppLocalizationsFr extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1329,6 +1391,38 @@ class AppLocalizationsFr extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1635,6 +1729,25 @@ class AppLocalizationsFr extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; @@ -1811,7 +1924,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Audio en qualité FLAC depuis Tidal, Qobuz ou Deezer'; + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; @override String get tutorialWelcomeTip3 => @@ -2085,6 +2198,28 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2117,6 +2252,18 @@ class AppLocalizationsFr extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2128,6 +2275,54 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2322,6 +2517,17 @@ class AppLocalizationsFr extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; @@ -2344,4 +2550,418 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 5b6ff05f..7c92f95c 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; @override String get artistAlbums => 'Albums'; @@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -688,6 +691,17 @@ class AppLocalizationsHi extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +775,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -1177,6 +1198,47 @@ class AppLocalizationsHi extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1327,6 +1389,38 @@ class AppLocalizationsHi extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1633,6 +1727,25 @@ class AppLocalizationsHi extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; @@ -1809,7 +1922,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Tidal, Qobuz, या Deezer से FLAC गुणवत्ता ऑडियो प्राप्त करें'; + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; @override String get tutorialWelcomeTip3 => @@ -2083,6 +2196,28 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2115,6 +2250,18 @@ class AppLocalizationsHi extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2126,6 +2273,54 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2320,6 +2515,17 @@ class AppLocalizationsHi extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; @@ -2342,4 +2548,418 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 4312a41a..8b088f35 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -15,7 +15,7 @@ class AppLocalizationsId extends AppLocalizations { String get navHome => 'Beranda'; @override - String get navLibrary => 'Library'; + String get navLibrary => 'Pustaka'; @override String get navSettings => 'Pengaturan'; @@ -45,7 +45,7 @@ class AppLocalizationsId extends AppLocalizations { String get historyFilterSingles => 'Single'; @override - String get historySearchHint => 'Search history...'; + String get historySearchHint => 'Cari riwayat...'; @override String get settingsTitle => 'Pengaturan'; @@ -104,7 +104,7 @@ class AppLocalizationsId extends AppLocalizations { String get appearanceHistoryViewList => 'Daftar'; @override - String get appearanceHistoryViewGrid => 'Grid'; + String get appearanceHistoryViewGrid => 'Kisi'; @override String get optionsTitle => 'Opsi'; @@ -126,7 +126,7 @@ class AppLocalizationsId extends AppLocalizations { 'Ketuk Deezer atau Spotify untuk beralih dari ekstensi'; @override - String get optionsAutoFallback => 'Auto Fallback'; + String get optionsAutoFallback => 'Cadangan Otomatis'; @override String get optionsAutoFallbackSubtitle => @@ -217,7 +217,7 @@ class AppLocalizationsId extends AppLocalizations { @override String optionsSpotifyCredentialsConfigured(String clientId) { - return 'Client ID: $clientId...'; + return 'ID Klien: $clientId...'; } @override @@ -230,7 +230,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get optionsSpotifyDeprecationWarning => - 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'; + 'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.'; @override String get extensionsTitle => 'Ekstensi'; @@ -283,7 +283,7 @@ class AppLocalizationsId extends AppLocalizations { 'Seniman berbakat yang membuat logo aplikasi kita yang indah!'; @override - String get aboutTranslators => 'Translators'; + String get aboutTranslators => 'Penerjemah'; @override String get aboutSpecialThanks => 'Terima Kasih Khusus'; @@ -311,19 +311,19 @@ class AppLocalizationsId extends AppLocalizations { 'Sarankan fitur baru untuk aplikasi'; @override - String get aboutTelegramChannel => 'Telegram Channel'; + String get aboutTelegramChannel => 'Saluran Telegram'; @override - String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + String get aboutTelegramChannelSubtitle => 'Pengumuman dan pembaruan'; @override - String get aboutTelegramChat => 'Telegram Community'; + String get aboutTelegramChat => 'Komunitas Telegram'; @override - String get aboutTelegramChatSubtitle => 'Chat with other users'; + String get aboutTelegramChatSubtitle => 'Berbincang dengan pengguna lain'; @override - String get aboutSocial => 'Social'; + String get aboutSocial => 'Sosial'; @override String get aboutApp => 'Aplikasi'; @@ -341,7 +341,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get aboutSjdonadoDesc => - 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; + 'Pencipta I Don\'t Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!'; @override String get aboutDabMusic => 'DAB Music'; @@ -355,11 +355,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get aboutSpotiSaverDesc => - 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'; + 'Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!'; @override String get aboutAppDescription => - 'Unduh lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.'; + 'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.'; @override String get artistAlbums => 'Album'; @@ -456,7 +456,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupIcloudNotSupported => - 'iCloud Drive is not supported. Please use the app Documents folder.'; + 'iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.'; @override String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC'; @@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get dialogImport => 'Impor'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Buang'; @@ -593,7 +596,7 @@ class AppLocalizationsId extends AppLocalizations { @override String csvImportTracks(int count) { - return '$count tracks from CSV'; + return '$count trek dari CSV'; } @override @@ -613,7 +616,7 @@ class AppLocalizationsId extends AppLocalizations { @override String snackbarAlreadyInLibrary(String trackName) { - return '\"$trackName\" already exists in your library'; + return '\"$trackName\" sudah ada di perpustakaan Anda'; } @override @@ -691,6 +694,17 @@ class AppLocalizationsId extends AppLocalizations { @override String get errorNoTracksFound => 'Tidak ada lagu ditemukan'; + @override + String get errorUrlNotRecognized => 'Link tidak dikenali'; + + @override + String get errorUrlNotRecognizedMessage => + 'Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.'; + + @override + String get errorUrlFetchFailed => + 'Gagal memuat konten dari link ini. Silakan coba lagi.'; + @override String errorMissingExtensionSource(String item) { return 'Tidak dapat memuat $item: sumber ekstensi tidak ada'; @@ -755,15 +769,22 @@ class AppLocalizationsId extends AppLocalizations { String get filenameFormat => 'Format Nama File'; @override - String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan'; + String get filenameShowAdvancedTags => 'Show advanced tags'; @override String get filenameShowAdvancedTagsDescription => - 'Aktifkan tag format untuk padding nomor lagu dan pola tanggal'; + 'Enable formatted tags for track padding and date patterns'; @override String get folderOrganizationNone => 'Tidak ada'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'Berdasarkan Artis'; @@ -1182,6 +1203,47 @@ class AppLocalizationsId extends AppLocalizations { @override String get storeClearFilters => 'Hapus filter'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1334,6 +1396,38 @@ class AppLocalizationsId extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; @@ -1343,10 +1437,10 @@ class AppLocalizationsId extends AppLocalizations { 'YouTube provides lossy audio only. Not part of lossless fallback.'; @override - String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube'; + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; @override - String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube'; + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; @override String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; @@ -1640,6 +1734,25 @@ class AppLocalizationsId extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; @@ -1684,8 +1797,8 @@ class AppLocalizationsId extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'trek', - one: 'trek', + other: 'tracks', + one: 'track', ); return '$_temp0'; } @@ -1816,7 +1929,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Deezer'; + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; @override String get tutorialWelcomeTip3 => @@ -2090,6 +2203,28 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Antrekan FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Mencari kecocokan FLAC... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2099,7 +2234,8 @@ class AppLocalizationsId extends AppLocalizations { String get trackConvertFormat => 'Convert Format'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => + 'Konversi ke MP3, Opus, ALAC, atau FLAC'; @override String get trackConvertTitle => 'Convert Audio'; @@ -2122,6 +2258,18 @@ class AppLocalizationsId extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.'; + } + + @override + String get trackConvertLosslessHint => + 'Konversi lossless — tanpa kehilangan kualitas'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2134,10 +2282,58 @@ class AppLocalizationsId extends AppLocalizations { String get trackConvertFailed => 'Conversion failed'; @override - String get actionCreate => 'Buat'; + String get cueSplitTitle => 'Split CUE Sheet'; @override - String get collectionFoldersTitle => 'Folder saya'; + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; @override String get collectionWishlist => 'Wishlist'; @@ -2146,172 +2342,171 @@ class AppLocalizationsId extends AppLocalizations { String get collectionLoved => 'Loved'; @override - String get collectionPlaylists => 'Playlist'; + String get collectionPlaylists => 'Playlists'; @override String get collectionPlaylist => 'Playlist'; @override - String get collectionAddToPlaylist => 'Tambahkan ke playlist'; + String get collectionAddToPlaylist => 'Add to playlist'; @override - String get collectionCreatePlaylist => 'Buat playlist'; + String get collectionCreatePlaylist => 'Create playlist'; @override - String get collectionNoPlaylistsYet => 'Belum ada playlist'; + String get collectionNoPlaylistsYet => 'No playlists yet'; @override String get collectionNoPlaylistsSubtitle => - 'Buat playlist untuk mulai mengategorikan lagu'; + 'Create a playlist to start categorizing tracks'; @override String collectionPlaylistTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count lagu', - one: '1 lagu', + other: '$count tracks', + one: '1 track', ); return '$_temp0'; } @override String collectionAddedToPlaylist(String playlistName) { - return 'Ditambahkan ke \"$playlistName\"'; + return 'Added to \"$playlistName\"'; } @override String collectionAlreadyInPlaylist(String playlistName) { - return 'Sudah ada di \"$playlistName\"'; + return 'Already in \"$playlistName\"'; } @override - String get collectionPlaylistCreated => 'Playlist berhasil dibuat'; + String get collectionPlaylistCreated => 'Playlist created'; @override - String get collectionPlaylistNameHint => 'Nama playlist'; + String get collectionPlaylistNameHint => 'Playlist name'; @override - String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi'; + String get collectionPlaylistNameRequired => 'Playlist name is required'; @override - String get collectionRenamePlaylist => 'Ubah nama playlist'; + String get collectionRenamePlaylist => 'Rename playlist'; @override - String get collectionDeletePlaylist => 'Hapus playlist'; + String get collectionDeletePlaylist => 'Delete playlist'; @override String collectionDeletePlaylistMessage(String playlistName) { - return 'Hapus \"$playlistName\" beserta semua lagunya?'; + return 'Delete \"$playlistName\" and all tracks inside it?'; } @override - String get collectionPlaylistDeleted => 'Playlist dihapus'; + String get collectionPlaylistDeleted => 'Playlist deleted'; @override - String get collectionPlaylistRenamed => 'Nama playlist diperbarui'; + String get collectionPlaylistRenamed => 'Playlist renamed'; @override - String get collectionWishlistEmptyTitle => 'Wishlist masih kosong'; + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; @override String get collectionWishlistEmptySubtitle => - 'Tap + di lagu untuk menyimpan yang ingin diunduh nanti'; + 'Tap + on tracks to save what you want to download later'; @override - String get collectionLovedEmptyTitle => 'Folder Loved masih kosong'; + String get collectionLovedEmptyTitle => 'Loved folder is empty'; @override String get collectionLovedEmptySubtitle => - 'Tap love di lagu untuk menyimpan favoritmu'; + 'Tap love on tracks to keep your favorites'; @override - String get collectionPlaylistEmptyTitle => 'Playlist masih kosong'; + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @override String get collectionPlaylistEmptySubtitle => - 'Tekan lama tombol + pada lagu untuk menambahkannya ke sini'; + 'Long-press + on any track to add it here'; @override - String get collectionRemoveFromPlaylist => 'Hapus dari playlist'; + String get collectionRemoveFromPlaylist => 'Remove from playlist'; @override - String get collectionRemoveFromFolder => 'Hapus dari folder'; + String get collectionRemoveFromFolder => 'Remove from folder'; @override String collectionRemoved(String trackName) { - return '\"$trackName\" dihapus'; + return '\"$trackName\" removed'; } @override String collectionAddedToLoved(String trackName) { - return '\"$trackName\" ditambahkan ke Loved'; + return '\"$trackName\" added to Loved'; } @override String collectionRemovedFromLoved(String trackName) { - return '\"$trackName\" dihapus dari Loved'; + return '\"$trackName\" removed from Loved'; } @override String collectionAddedToWishlist(String trackName) { - return '\"$trackName\" ditambahkan ke Wishlist'; + return '\"$trackName\" added to Wishlist'; } @override String collectionRemovedFromWishlist(String trackName) { - return '\"$trackName\" dihapus dari Wishlist'; + return '\"$trackName\" removed from Wishlist'; } @override - String get trackOptionAddToLoved => 'Tambahkan ke Loved'; + String get trackOptionAddToLoved => 'Add to Loved'; @override - String get trackOptionRemoveFromLoved => 'Hapus dari Loved'; + String get trackOptionRemoveFromLoved => 'Remove from Loved'; @override - String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist'; + String get trackOptionAddToWishlist => 'Add to Wishlist'; @override - String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist'; + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; @override - String get collectionPlaylistChangeCover => 'Ubah gambar sampul'; + String get collectionPlaylistChangeCover => 'Change cover image'; @override - String get collectionPlaylistRemoveCover => 'Hapus gambar sampul'; + String get collectionPlaylistRemoveCover => 'Remove cover image'; @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'trek', - one: 'trek', + other: 'tracks', + one: 'track', ); - return 'Bagikan $count $_temp0'; + return 'Share $count $_temp0'; } @override - String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan'; + String get selectionShareNoFiles => 'No shareable files found'; @override String selectionConvertCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'trek', - one: 'trek', + other: 'tracks', + one: 'track', ); - return 'Konversi $count $_temp0'; + return 'Convert $count $_temp0'; } @override - String get selectionConvertNoConvertible => - 'Tidak ada trek yang dapat dikonversi dipilih'; + String get selectionConvertNoConvertible => 'No convertible tracks selected'; @override - String get selectionBatchConvertConfirmTitle => 'Konversi Massal'; + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; @override String selectionBatchConvertConfirmMessage( @@ -2322,20 +2517,31 @@ class AppLocalizationsId extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'trek', - one: 'trek', + other: 'tracks', + one: 'track', ); - return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.'; + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } @override String selectionBatchConvertProgress(int current, int total) { - return 'Mengonversi $current dari $total...'; + return 'Converting $current of $total...'; } @override String selectionBatchConvertSuccess(int success, int total, String format) { - return 'Berhasil mengonversi $success dari $total trek ke $format'; + return 'Converted $success of $total tracks to $format'; } @override @@ -2350,4 +2556,418 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Buat folder sumber playlist'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Unduhan dari playlist hanya memakai struktur folder normal.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 9aa5a2fb..5c454b45 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -15,7 +15,7 @@ class AppLocalizationsJa extends AppLocalizations { String get navHome => 'ホーム'; @override - String get navLibrary => 'Library'; + String get navLibrary => 'ライブラリ'; @override String get navSettings => '設定'; @@ -160,7 +160,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; + return '$count 件の分割ダウンロード'; } @override @@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get aboutAppDescription => - 'Tidal、Qobuz から Spotify のトラックをロスレス品質でダウンロードします。'; + 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; @override String get artistAlbums => 'アルバム'; @@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get dialogImport => 'インポート'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => '破棄'; @@ -683,6 +686,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get errorNoTracksFound => 'トラックがありません'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return '$item を読み込めません: 拡張ソースがありません'; @@ -756,6 +770,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get folderOrganizationNone => '構成がありません'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'アーティスト別'; @@ -1111,7 +1132,7 @@ class AppLocalizationsJa extends AppLocalizations { String get trackLyricsLoadFailed => '歌詞の読み込みに失敗しました'; @override - String get trackEmbedLyrics => 'Embed Lyrics'; + String get trackEmbedLyrics => '歌詞を埋め込む'; @override String get trackLyricsEmbedded => 'Lyrics embedded successfully'; @@ -1171,6 +1192,47 @@ class AppLocalizationsJa extends AppLocalizations { @override String get storeClearFilters => 'フィルターを消去'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)'; @@ -1317,6 +1379,38 @@ class AppLocalizationsJa extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; @@ -1325,10 +1419,10 @@ class AppLocalizationsJa extends AppLocalizations { 'YouTube provides lossy audio only. Not part of lossless fallback.'; @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート'; @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート'; @override String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; @@ -1375,20 +1469,20 @@ class AppLocalizationsJa extends AppLocalizations { String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?'; @override - String get settingsAutoExportFailed => 'Auto-export failed downloads'; + String get settingsAutoExportFailed => 'ダウンロードの自動エクスポートに失敗しました'; @override String get settingsAutoExportFailedSubtitle => 'Save failed downloads to TXT file automatically'; @override - String get settingsDownloadNetwork => 'Download Network'; + String get settingsDownloadNetwork => 'ダウンロードネットワーク'; @override - String get settingsDownloadNetworkAny => 'WiFi + Mobile Data'; + String get settingsDownloadNetworkAny => 'Wi-Fi + モバイルデータ'; @override - String get settingsDownloadNetworkWifiOnly => 'WiFi Only'; + String get settingsDownloadNetworkWifiOnly => 'Wi-Fi のみ'; @override String get settingsDownloadNetworkSubtitle => @@ -1419,7 +1513,7 @@ class AppLocalizationsJa extends AppLocalizations { String get albumFolderYearAlbumSubtitle => 'アルバム/[2005] アルバム名/'; @override - String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + String get albumFolderArtistAlbumSingles => 'アーティスト / アルバム + シングル'; @override String get albumFolderArtistAlbumSinglesSubtitle => @@ -1485,7 +1579,7 @@ class AppLocalizationsJa extends AppLocalizations { String get recentEmpty => 'No recent items yet'; @override - String get recentShowAllDownloads => 'Show All Downloads'; + String get recentShowAllDownloads => 'すべてのダウンロードを表示'; @override String recentPlaylistInfo(String name) { @@ -1559,10 +1653,10 @@ class AppLocalizationsJa extends AppLocalizations { String get discographyFailedToFetch => '一部のアルバムの取得に失敗しました'; @override - String get sectionStorageAccess => 'Storage Access'; + String get sectionStorageAccess => 'ストレージアクセス'; @override - String get allFilesAccess => 'All Files Access'; + String get allFilesAccess => 'すべてのファイルへのアクセス'; @override String get allFilesAccessEnabledSubtitle => 'Can write to any folder'; @@ -1583,35 +1677,35 @@ class AppLocalizationsJa extends AppLocalizations { 'All Files Access disabled. The app will use limited storage access.'; @override - String get settingsLocalLibrary => 'Local Library'; + String get settingsLocalLibrary => 'ローカルライブラリ'; @override String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; @override - String get settingsCache => 'Storage & Cache'; + String get settingsCache => 'ストレージとキャッシュ'; @override String get settingsCacheSubtitle => 'View size and clear cached data'; @override - String get libraryTitle => 'Local Library'; + String get libraryTitle => 'ローカルライブラリ'; @override - String get libraryScanSettings => 'Scan Settings'; + String get libraryScanSettings => 'スキャン設定'; @override - String get libraryEnableLocalLibrary => 'Enable Local Library'; + String get libraryEnableLocalLibrary => 'ローカルライブラリを有効'; @override String get libraryEnableLocalLibrarySubtitle => 'Scan and track your existing music'; @override - String get libraryFolder => 'Library Folder'; + String get libraryFolder => 'ライブラリのフォルダ'; @override - String get libraryFolderHint => 'Tap to select folder'; + String get libraryFolderHint => 'タップでフォルダを選択'; @override String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; @@ -1621,13 +1715,32 @@ class AppLocalizationsJa extends AppLocalizations { 'Show when searching for existing tracks'; @override - String get libraryActions => 'Actions'; + String get libraryAutoScan => 'Auto Scan'; @override - String get libraryScan => 'Scan Library'; + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; @override - String get libraryScanSubtitle => 'Scan for audio files'; + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + + @override + String get libraryActions => 'アクション'; + + @override + String get libraryScan => 'ライブラリをスキャン'; + + @override + String get libraryScanSubtitle => 'オーディオファイルをスキャン'; @override String get libraryScanSelectFolderFirst => 'Select a folder first'; @@ -1640,20 +1753,20 @@ class AppLocalizationsJa extends AppLocalizations { 'Remove entries for files that no longer exist'; @override - String get libraryClear => 'Clear Library'; + String get libraryClear => 'ライブラリを消去'; @override String get libraryClearSubtitle => 'Remove all scanned tracks'; @override - String get libraryClearConfirmTitle => 'Clear Library'; + String get libraryClearConfirmTitle => 'ライブラリを消去'; @override String get libraryClearConfirmMessage => 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'; @override - String get libraryAbout => 'About Local Library'; + String get libraryAbout => 'ローカルライブラリについて'; @override String get libraryAboutDescription => @@ -1672,14 +1785,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String libraryLastScanned(String time) { - return 'Last scanned: $time'; + return '最終スキャン: $time'; } @override String get libraryLastScannedNever => 'Never'; @override - String get libraryScanning => 'Scanning...'; + String get libraryScanning => 'スキャン中...'; @override String libraryScanProgress(String progress, int total) { @@ -1687,7 +1800,7 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get libraryInLibrary => 'In Library'; + String get libraryInLibrary => 'ライブラリ内'; @override String libraryRemovedMissingFiles(int count) { @@ -1698,7 +1811,7 @@ class AppLocalizationsJa extends AppLocalizations { String get libraryCleared => 'Library cleared'; @override - String get libraryStorageAccessRequired => 'Storage Access Required'; + String get libraryStorageAccessRequired => 'ストレージアクセスが必要です'; @override String get libraryStorageAccessMessage => @@ -1708,37 +1821,37 @@ class AppLocalizationsJa extends AppLocalizations { String get libraryFolderNotExist => 'Selected folder does not exist'; @override - String get librarySourceDownloaded => 'Downloaded'; + String get librarySourceDownloaded => 'ダウンロード済み'; @override - String get librarySourceLocal => 'Local'; + String get librarySourceLocal => 'ローカル'; @override - String get libraryFilterAll => 'All'; + String get libraryFilterAll => 'すべて'; @override - String get libraryFilterDownloaded => 'Downloaded'; + String get libraryFilterDownloaded => 'ダウンロード済み'; @override - String get libraryFilterLocal => 'Local'; + String get libraryFilterLocal => 'ローカル'; @override - String get libraryFilterTitle => 'Filters'; + String get libraryFilterTitle => 'フィルター'; @override - String get libraryFilterReset => 'Reset'; + String get libraryFilterReset => 'リセット'; @override - String get libraryFilterApply => 'Apply'; + String get libraryFilterApply => '適用'; @override - String get libraryFilterSource => 'Source'; + String get libraryFilterSource => 'ソース'; @override - String get libraryFilterQuality => 'Quality'; + String get libraryFilterQuality => '品質'; @override - String get libraryFilterQualityHiRes => 'Hi-Res (24bit)'; + String get libraryFilterQualityHiRes => 'ハイレゾ (24bit)'; @override String get libraryFilterQualityCD => 'CD (16bit)'; @@ -1747,7 +1860,7 @@ class AppLocalizationsJa extends AppLocalizations { String get libraryFilterQualityLossy => 'Lossy'; @override - String get libraryFilterFormat => 'Format'; + String get libraryFilterFormat => '形式'; @override String get libraryFilterSort => 'Sort'; @@ -1766,8 +1879,8 @@ class AppLocalizationsJa extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count minutes ago', - one: '1 minute ago', + other: '$count 分前', + one: '1 分前', ); return '$_temp0'; } @@ -1777,14 +1890,14 @@ class AppLocalizationsJa extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count hours ago', - one: '1 hour ago', + other: '$count 時間前', + one: '1 時間前', ); return '$_temp0'; } @override - String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + String get tutorialWelcomeTitle => 'SpotiFLAC へようこそ!'; @override String get tutorialWelcomeDesc => @@ -1795,7 +1908,8 @@ class AppLocalizationsJa extends AppLocalizations { 'Download music from Spotify, Deezer, or paste any supported URL'; @override - String get tutorialWelcomeTip2 => 'Tidal、Qobuz、Deezer から FLAC 品質のオーディオを取得'; + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; @override String get tutorialWelcomeTip3 => @@ -1809,14 +1923,14 @@ class AppLocalizationsJa extends AppLocalizations { 'There are two easy ways to find music you want to download.'; @override - String get tutorialDownloadTitle => 'Downloading Music'; + String get tutorialDownloadTitle => '音楽をダウンロード中'; @override String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; @override - String get tutorialLibraryTitle => 'Your Library'; + String get tutorialLibraryTitle => 'あなたのライブラリ'; @override String get tutorialLibraryDesc => @@ -1835,7 +1949,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Switch between list and grid view for better browsing'; @override - String get tutorialExtensionsTitle => 'Extensions'; + String get tutorialExtensionsTitle => '拡張'; @override String get tutorialExtensionsDesc => @@ -1876,7 +1990,7 @@ class AppLocalizationsJa extends AppLocalizations { 'You\'re all set! Start downloading your favorite music now.'; @override - String get libraryForceFullScan => 'Force Full Scan'; + String get libraryForceFullScan => '強制フルスキャン'; @override String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache'; @@ -1897,10 +2011,10 @@ class AppLocalizationsJa extends AppLocalizations { String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; @override - String get cacheTitle => 'Storage & Cache'; + String get cacheTitle => 'ストレージとキャッシュ'; @override - String get cacheSummaryTitle => 'Cache overview'; + String get cacheSummaryTitle => 'キャッシュの概要'; @override String get cacheSummarySubtitle => @@ -1912,34 +2026,34 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get cacheSectionStorage => 'Cached Data'; + String get cacheSectionStorage => 'キャッシュ済みデータ'; @override - String get cacheSectionMaintenance => 'Maintenance'; + String get cacheSectionMaintenance => 'メンテナンス'; @override - String get cacheAppDirectory => 'App cache directory'; + String get cacheAppDirectory => 'アプリキャッシュのディレクトリ'; @override String get cacheAppDirectoryDesc => 'HTTP responses, WebView data, and other temporary app data.'; @override - String get cacheTempDirectory => 'Temporary directory'; + String get cacheTempDirectory => '一時ディレクトリ'; @override String get cacheTempDirectoryDesc => 'Temporary files from downloads and audio conversion.'; @override - String get cacheCoverImage => 'Cover image cache'; + String get cacheCoverImage => 'カバー画像のキャッシュ'; @override String get cacheCoverImageDesc => 'Downloaded album and track cover art. Will re-download when viewed.'; @override - String get cacheLibraryCover => 'Library cover cache'; + String get cacheLibraryCover => 'ライブラリのカバーキャッシュ'; @override String get cacheLibraryCoverDesc => @@ -1964,7 +2078,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Remove orphaned download history and library entries for missing files.'; @override - String get cacheNoData => 'No cached data'; + String get cacheNoData => 'キャッシュデータはありません'; @override String cacheSizeWithFiles(String size, int count) { @@ -1978,16 +2092,16 @@ class AppLocalizationsJa extends AppLocalizations { @override String cacheEntries(int count) { - return '$count entries'; + return '$count 個のエントリ'; } @override String cacheClearSuccess(String target) { - return 'Cleared: $target'; + return '消去済み: $target'; } @override - String get cacheClearConfirmTitle => 'Clear cache?'; + String get cacheClearConfirmTitle => 'キャッシュを消去しますか?'; @override String cacheClearConfirmMessage(String target) { @@ -1995,17 +2109,17 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get cacheClearAllConfirmTitle => 'Clear all cache?'; + String get cacheClearAllConfirmTitle => 'すべてのキャッシュを消去しますか?'; @override String get cacheClearAllConfirmMessage => 'This will clear all cache categories on this page. Downloaded music files will not be deleted.'; @override - String get cacheClearAll => 'Clear all cache'; + String get cacheClearAll => 'すべてのキャッシュを消去'; @override - String get cacheCleanupUnused => 'Cleanup unused data'; + String get cacheCleanupUnused => '未使用のデータを削除'; @override String get cacheCleanupUnusedSubtitle => @@ -2017,16 +2131,16 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get cacheRefreshStats => 'Refresh stats'; + String get cacheRefreshStats => '状態を更新'; @override - String get trackSaveCoverArt => 'Save Cover Art'; + String get trackSaveCoverArt => 'カバー画像を保存'; @override String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; @override - String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + String get trackSaveLyrics => '歌詞を保存 (.lrc)'; @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; @@ -2042,7 +2156,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Search metadata online and embed into file'; @override - String get trackEditMetadata => 'Edit Metadata'; + String get trackEditMetadata => 'メタデータを編集'; @override String trackCoverSaved(String fileName) { @@ -2070,27 +2184,49 @@ class AppLocalizationsJa extends AppLocalizations { String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; @override - String trackSaveFailed(String error) { - return 'Failed: $error'; + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; } @override - String get trackConvertFormat => 'Convert Format'; + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; @override - String get trackConvertTitle => 'Convert Audio'; + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } @override - String get trackConvertTargetFormat => 'Target Format'; + String trackSaveFailed(String error) { + return '失敗: $error'; + } @override - String get trackConvertBitrate => 'Bitrate'; + String get trackConvertFormat => '変換の形式'; @override - String get trackConvertConfirmTitle => 'Confirm Conversion'; + String get trackConvertFormatSubtitle => 'MP3 または Opus に変換'; + + @override + String get trackConvertTitle => 'オーディオを変換'; + + @override + String get trackConvertTargetFormat => 'ターゲットの形式'; + + @override + String get trackConvertBitrate => 'ビットレート'; + + @override + String get trackConvertConfirmTitle => '変換を確認'; @override String trackConvertConfirmMessage( @@ -2102,7 +2238,19 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get trackConvertConverting => 'Converting audio...'; + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + + @override + String get trackConvertConverting => 'オーディオを変換中...'; @override String trackConvertSuccess(String format) { @@ -2110,7 +2258,55 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get trackConvertFailed => 'Conversion failed'; + String get trackConvertFailed => '変換に失敗しました'; + + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; @override String get actionCreate => 'Create'; @@ -2306,6 +2502,17 @@ class AppLocalizationsJa extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; @@ -2328,4 +2535,418 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 3796e499..3dc6f4f9 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -45,41 +45,40 @@ class AppLocalizationsKo extends AppLocalizations { String get historyFilterSingles => 'Singles'; @override - String get historySearchHint => 'Search history...'; + String get historySearchHint => '검색 기록...'; @override String get settingsTitle => 'Settings'; @override - String get settingsDownload => 'Download'; + String get settingsDownload => '다운로드'; @override - String get settingsAppearance => 'Appearance'; + String get settingsAppearance => '외관'; @override - String get settingsOptions => 'Options'; + String get settingsOptions => '옵션'; @override - String get settingsExtensions => 'Extensions'; + String get settingsExtensions => '확장 기능'; @override - String get settingsAbout => 'About'; + String get settingsAbout => '정보'; @override - String get downloadTitle => 'Download'; + String get downloadTitle => '다운로드'; @override - String get downloadAskQualitySubtitle => - 'Show quality picker for each download'; + String get downloadAskQualitySubtitle => '다운로드를 할 때마다 품질을 선택하도록 합니다'; @override - String get downloadFilenameFormat => 'Filename Format'; + String get downloadFilenameFormat => '파일 이름 형식'; @override - String get downloadFolderOrganization => 'Folder Organization'; + String get downloadFolderOrganization => '폴더 분류 형식'; @override - String get appearanceTitle => 'Appearance'; + String get appearanceTitle => '외관'; @override String get appearanceThemeSystem => 'System'; @@ -94,10 +93,10 @@ class AppLocalizationsKo extends AppLocalizations { String get appearanceDynamicColor => 'Dynamic Color'; @override - String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + String get appearanceDynamicColorSubtitle => '배경 화면을 참고하여 강조 색상이 지정됩니다'; @override - String get appearanceHistoryView => 'History View'; + String get appearanceHistoryView => '기록 정렬 방식'; @override String get appearanceHistoryViewList => 'List'; @@ -106,112 +105,104 @@ class AppLocalizationsKo extends AppLocalizations { String get appearanceHistoryViewGrid => 'Grid'; @override - String get optionsTitle => 'Options'; + String get optionsTitle => '옵션'; @override - String get optionsPrimaryProvider => 'Primary Provider'; + String get optionsPrimaryProvider => '기본 제공자'; @override - String get optionsPrimaryProviderSubtitle => - 'Service used when searching by track name.'; + String get optionsPrimaryProviderSubtitle => '음반 이름으로 검색할 때 사용되는 서비스'; @override String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; + return '확장 기능을 사용: $extensionName'; } @override - String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; + String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.'; @override - String get optionsAutoFallback => 'Auto Fallback'; + String get optionsAutoFallback => '자동 재시도'; @override String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도'; @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; + String get optionsUseExtensionProviders => '확장 기능 사용'; @override String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다'; @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + String get optionsUseExtensionProvidersOff => '기본으로 제공되는 기능만 사용'; @override - String get optionsEmbedLyrics => 'Embed Lyrics'; + String get optionsEmbedLyrics => '가사 삽입'; @override - String get optionsEmbedLyricsSubtitle => - 'Embed synced lyrics into FLAC files'; + String get optionsEmbedLyricsSubtitle => 'FLAC 파일에 동기화된 가사를 삽입합니다'; @override - String get optionsMaxQualityCover => 'Max Quality Cover'; + String get optionsMaxQualityCover => '고품질 커버 이미지'; @override - String get optionsMaxQualityCoverSubtitle => - 'Download highest resolution cover art'; + String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드'; @override - String get optionsConcurrentDownloads => 'Concurrent Downloads'; + String get optionsConcurrentDownloads => '동시 다운로드'; @override - String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + String get optionsConcurrentSequential => '순차 다운로드 (한 번에 하나)'; @override String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; + return '$count개 동시 다운로드'; } @override - String get optionsConcurrentWarning => - 'Parallel downloads may trigger rate limiting'; + String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다'; @override - String get optionsExtensionStore => 'Extension Store'; + String get optionsExtensionStore => '확장 기능 스토어'; @override - String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + String get optionsExtensionStoreSubtitle => '탐색 메뉴에 스토어 탭 표시'; @override - String get optionsCheckUpdates => 'Check for Updates'; + String get optionsCheckUpdates => '업데이트 확인'; @override - String get optionsCheckUpdatesSubtitle => - 'Notify when new version is available'; + String get optionsCheckUpdatesSubtitle => '새로운 버전이 출시되면 알림'; @override - String get optionsUpdateChannel => 'Update Channel'; + String get optionsUpdateChannel => '업데이트 채널'; @override - String get optionsUpdateChannelStable => 'Stable releases only'; + String get optionsUpdateChannelStable => '안정적인 버전만 수령'; @override - String get optionsUpdateChannelPreview => 'Get preview releases'; + String get optionsUpdateChannelPreview => '미리보기 버전을 수령'; @override - String get optionsUpdateChannelWarning => - 'Preview may contain bugs or incomplete features'; + String get optionsUpdateChannelWarning => '미리보기 버전은 불안정할 수 있습니다'; @override - String get optionsClearHistory => 'Clear Download History'; + String get optionsClearHistory => '다운로드 기록 삭제'; @override - String get optionsClearHistorySubtitle => - 'Remove all downloaded tracks from history'; + String get optionsClearHistorySubtitle => '기록에서 모든 다운로드 음반을 제거합니다'; @override - String get optionsDetailedLogging => 'Detailed Logging'; + String get optionsDetailedLogging => '상세 로깅'; @override - String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + String get optionsDetailedLoggingOn => '상세한 로그가 기록되고 있습니다'; @override - String get optionsDetailedLoggingOff => 'Enable for bug reports'; + String get optionsDetailedLoggingOff => '버그 신고를 위한 기능입니다'; @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; + String get optionsSpotifyCredentials => 'Spotify 자격 증명'; @override String optionsSpotifyCredentialsConfigured(String clientId) { @@ -219,21 +210,21 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + String get optionsSpotifyCredentialsRequired => '탭하여 설정'; @override String get optionsSpotifyWarning => - 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + 'Spotify는 사용자 고유의 API 자격 증명을 요구합니다. developer.spotify.com에서 무료로 발급받으세요'; @override String get optionsSpotifyDeprecationWarning => - 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'; + 'Spotify API 변경으로 인해 Spotify 검색 기능은 2026년 3월 3일부터 더 이상 지원되지 않습니다. Deezer로 전환해 주세요'; @override - String get extensionsTitle => 'Extensions'; + String get extensionsTitle => '확장 기능'; @override - String get extensionsDisabled => 'Disabled'; + String get extensionsDisabled => '비활성화'; @override String extensionsVersion(String version) { @@ -246,80 +237,79 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String get extensionsUninstall => 'Uninstall'; + String get extensionsUninstall => '삭제'; @override - String get storeTitle => 'Extension Store'; + String get storeTitle => '확장 기능 스토어'; @override - String get storeSearch => 'Search extensions...'; + String get storeSearch => '확장 기능 검색'; @override - String get storeInstall => 'Install'; + String get storeInstall => '설치'; @override - String get storeInstalled => 'Installed'; + String get storeInstalled => '설치됨'; @override - String get storeUpdate => 'Update'; + String get storeUpdate => '업데이트'; @override - String get aboutTitle => 'About'; + String get aboutTitle => '정보'; @override - String get aboutContributors => 'Contributors'; + String get aboutContributors => '기여자'; @override - String get aboutMobileDeveloper => 'Mobile version developer'; + String get aboutMobileDeveloper => '모바일 버전 개발자'; @override - String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + String get aboutOriginalCreator => '오리지널 SpotiFLAC 제작자'; @override - String get aboutLogoArtist => - 'The talented artist who created our beautiful app logo!'; + String get aboutLogoArtist => '아름다운 로고를 만들어주신 재능 있는 아티스트!'; @override - String get aboutTranslators => 'Translators'; + String get aboutTranslators => '번역가들'; @override - String get aboutSpecialThanks => 'Special Thanks'; + String get aboutSpecialThanks => '특별 감사'; @override - String get aboutLinks => 'Links'; + String get aboutLinks => '바로가기'; @override String get aboutMobileSource => 'Mobile source code'; @override - String get aboutPCSource => 'PC source code'; + String get aboutPCSource => 'PC 소스 코드'; @override - String get aboutReportIssue => 'Report an issue'; + String get aboutReportIssue => '문제 신고'; @override - String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + String get aboutReportIssueSubtitle => '발생하는 모든 문제를 신고하여 주세요.'; @override - String get aboutFeatureRequest => 'Feature request'; + String get aboutFeatureRequest => '기능 요청'; @override - String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + String get aboutFeatureRequestSubtitle => '앱의 새로운 기능을 제안하여 주세요.'; @override String get aboutTelegramChannel => 'Telegram Channel'; @override - String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + String get aboutTelegramChannelSubtitle => '공지 및 업데이트 안내'; @override String get aboutTelegramChat => 'Telegram Community'; @override - String get aboutTelegramChatSubtitle => 'Chat with other users'; + String get aboutTelegramChatSubtitle => '다른 이용자와 소통'; @override - String get aboutSocial => 'Social'; + String get aboutSocial => '소셜'; @override String get aboutApp => 'App'; @@ -329,244 +319,242 @@ class AppLocalizationsKo extends AppLocalizations { @override String get aboutBinimumDesc => - 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + 'QQDL 및 HiFi API 개발자입니다. 이 API가 없었다면 Tidal 다운로드는 불가능했을 것입니다!'; @override - String get aboutSachinsenalDesc => - 'The original HiFi project creator. The foundation of Tidal integration!'; + String get aboutSachinsenalDesc => '최초의 하이파이 프로젝트 창시자. 타이달 연동의 기반을 마련한 사람!'; @override String get aboutSjdonadoDesc => - 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; + 'I Don\'t Have Spotify(IDHS) 개발자입니다. 위급 상황 발생 시 해결해 주는 대체 링크 해결 도구를 만들었습니다!'; @override String get aboutDabMusic => 'DAB Music'; @override String get aboutDabMusicDesc => - 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + '최고의 Qobuz 스트리밍 API입니다. 이 API가 없었다면 고해상도 다운로드는 불가능했을 겁니다!'; @override String get aboutSpotiSaver => 'SpotiSaver'; @override String get aboutSpotiSaverDesc => - 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'; + 'Tidal Hi-Res FLAC 스트리밍 엔드포인트. 무손실 음원 재생의 핵심 요소!'; @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; + 'Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.'; @override - String get artistAlbums => 'Albums'; + String get artistAlbums => '앨범'; @override - String get artistSingles => 'Singles & EPs'; + String get artistSingles => '싱글 및 EP'; @override - String get artistCompilations => 'Compilations'; + String get artistCompilations => '편집'; @override - String get artistPopular => 'Popular'; + String get artistPopular => '인기순'; @override String artistMonthlyListeners(String count) { - return '$count monthly listeners'; + return '월간 청취자: $count'; } @override - String get trackMetadataService => 'Service'; + String get trackMetadataService => '제공업체'; @override - String get trackMetadataPlay => 'Play'; + String get trackMetadataPlay => '재생'; @override - String get trackMetadataShare => 'Share'; + String get trackMetadataShare => '공유'; @override - String get trackMetadataDelete => 'Delete'; + String get trackMetadataDelete => '삭제'; @override - String get setupGrantPermission => 'Grant Permission'; + String get setupGrantPermission => '권한을 제공해 주세요.'; @override - String get setupSkip => 'Skip for now'; + String get setupSkip => '다음에 할래요'; @override - String get setupStorageAccessRequired => 'Storage Access Required'; + String get setupStorageAccessRequired => '스토리지 접근 권한 필요'; @override String get setupStorageAccessMessageAndroid11 => - 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; + 'Android 11 이상 버전에서는 선택한 다운로드 폴더에 파일을 저장하려면 \"모든 파일 접근\" 권한이 필요합니다.'; @override - String get setupOpenSettings => 'Open Settings'; + String get setupOpenSettings => '설정으로 이동'; @override String get setupPermissionDeniedMessage => - 'Permission denied. Please grant all permissions to continue.'; + '권한이 거부되었습니다. 계속하려면 모든 권한을 허용해 주세요.'; @override String setupPermissionRequired(String permissionType) { - return '$permissionType Permission Required'; + return '$permissionType 권한 필요'; } @override String setupPermissionRequiredMessage(String permissionType) { - return '$permissionType permission is required for the best experience. You can change this later in Settings.'; + return '최상의 사용 경험을 위해 $permissionType 권한이 필요합니다. 설정에서 나중에 변경할 수 있습니다.'; } @override - String get setupUseDefaultFolder => 'Use Default Folder?'; + String get setupUseDefaultFolder => '기본 폴더를 사용하시겠습니까?'; @override - String get setupNoFolderSelected => - 'No folder selected. Would you like to use the default Music folder?'; + String get setupNoFolderSelected => '선택된 폴더가 없습니다. 기본 음악 폴더를 사용하시겠습니까?'; @override - String get setupUseDefault => 'Use Default'; + String get setupUseDefault => '기본값 사용'; @override - String get setupDownloadLocationTitle => 'Download Location'; + String get setupDownloadLocationTitle => '다운로드 경로'; @override String get setupDownloadLocationIosMessage => - 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; + 'iOS에서는 다운로드한 파일이 앱의 문서 폴더에 저장됩니다. 파일 앱을 통해 해당 파일에 접근할 수 있습니다.'; @override - String get setupAppDocumentsFolder => 'App Documents Folder'; + String get setupAppDocumentsFolder => '앱 문서 폴더'; @override - String get setupAppDocumentsFolderSubtitle => - 'Recommended - accessible via Files app'; + String get setupAppDocumentsFolderSubtitle => '권장 사항 - 파일 앱을 통해 접근 가능'; @override - String get setupChooseFromFiles => 'Choose from Files'; + String get setupChooseFromFiles => '파일 탐색기에서 선택'; @override - String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; + String get setupChooseFromFilesSubtitle => 'iCloud 또는 다른 위치를 선택하세요'; @override String get setupIosEmptyFolderWarning => - 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; + 'iOS 제한 사항: 빈 폴더는 선택할 수 없습니다. 파일이 하나 이상 있는 폴더를 선택하세요.'; @override String get setupIcloudNotSupported => - 'iCloud Drive is not supported. Please use the app Documents folder.'; + 'iCloud Drive는 지원되지 않습니다. 앱의 문서 폴더를 사용해 주세요.'; @override - String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + String get setupDownloadInFlac => 'Spotify 음악을 FLAC 형식으로 다운로드하세요.'; @override - String get setupStorageGranted => 'Storage Permission Granted!'; + String get setupStorageGranted => '저장소 접근 권한이 부여되었습니다!'; @override - String get setupStorageRequired => 'Storage Permission Required'; + String get setupStorageRequired => '저장소 접근 권한이 필요합니다.'; @override String get setupStorageDescription => - 'SpotiFLAC needs storage permission to save your downloaded music files.'; + 'SpotiFLAC은 다운로드한 음악 파일을 저장하기 위해 저장소 접근 권한이 필요합니다.'; @override - String get setupNotificationGranted => 'Notification Permission Granted!'; + String get setupNotificationGranted => '알림 권한이 부여되었습니다!'; @override - String get setupNotificationEnable => 'Enable Notifications'; + String get setupNotificationEnable => '알림 활성화'; @override - String get setupFolderChoose => 'Choose Download Folder'; + String get setupFolderChoose => '다운로드 폴더를 선택하세요'; @override - String get setupFolderDescription => - 'Select a folder where your downloaded music will be saved.'; + String get setupFolderDescription => '다운로드한 음악 파일이 저장될 폴더를 선택하세요.'; @override - String get setupSelectFolder => 'Select Folder'; + String get setupSelectFolder => '폴더 선택'; @override - String get setupEnableNotifications => 'Enable Notifications'; + String get setupEnableNotifications => '알림 활성화'; @override String get setupNotificationBackgroundDescription => - 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; + '알림으로 다운로드 진행 상황을 확인하세요. 앱이 백그라운드에서 실행 중일 때 다운로드 상태와 완료 여부를 확인할 수 있습니다.'; @override - String get setupSkipForNow => 'Skip for now'; + String get setupSkipForNow => '다음에 할래요.'; @override - String get setupNext => 'Next'; + String get setupNext => '다음'; @override - String get setupGetStarted => 'Get Started'; + String get setupGetStarted => '시작하기'; @override String get setupAllowAccessToManageFiles => - 'Please enable \"Allow access to manage all files\" in the next screen.'; + '다음 화면에서 \"모든 파일 관리 권한 허용\"을 활성화해 주세요.'; @override - String get dialogCancel => 'Cancel'; + String get dialogCancel => '취소'; @override - String get dialogSave => 'Save'; + String get dialogSave => '저장'; @override - String get dialogDelete => 'Delete'; + String get dialogDelete => '삭제'; @override - String get dialogRetry => 'Retry'; + String get dialogRetry => '재시도'; @override - String get dialogClear => 'Clear'; + String get dialogClear => '지우기'; @override - String get dialogDone => 'Done'; + String get dialogDone => '완료'; @override - String get dialogImport => 'Import'; + String get dialogImport => '불러오기'; @override - String get dialogDiscard => 'Discard'; + String get dialogDownload => 'Download'; @override - String get dialogRemove => 'Remove'; + String get dialogDiscard => '취소'; @override - String get dialogUninstall => 'Uninstall'; + String get dialogRemove => '제거'; @override - String get dialogDiscardChanges => 'Discard Changes?'; + String get dialogUninstall => '삭제'; @override - String get dialogUnsavedChanges => - 'You have unsaved changes. Do you want to discard them?'; + String get dialogDiscardChanges => '변경사항 취소'; @override - String get dialogClearAll => 'Clear All'; + String get dialogUnsavedChanges => '저장되지 않은 변경 사항이 있습니다. 삭제하시겠습니까?'; @override - String get dialogRemoveExtension => 'Remove Extension'; + String get dialogClearAll => '모두 제거:'; + + @override + String get dialogRemoveExtension => '확장 프로그램 제거'; @override String get dialogRemoveExtensionMessage => - 'Are you sure you want to remove this extension? This cannot be undone.'; + '이 확장 프로그램을 정말로 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.'; @override - String get dialogUninstallExtension => 'Uninstall Extension?'; + String get dialogUninstallExtension => '확장 프로그램을 제거하시겠습니까?'; @override String dialogUninstallExtensionMessage(String extensionName) { - return 'Are you sure you want to remove $extensionName?'; + return '$extensionName을 정말로 삭제하시겠습니까?'; } @override - String get dialogClearHistoryTitle => 'Clear History'; + String get dialogClearHistoryTitle => '기록 삭제'; @override String get dialogClearHistoryMessage => - 'Are you sure you want to clear all download history? This cannot be undone.'; + '다운로드 기록을 모두 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.'; @override - String get dialogDeleteSelectedTitle => 'Delete Selected'; + String get dialogDeleteSelectedTitle => '선택한 항목 삭제'; @override String dialogDeleteSelectedMessage(int count) { @@ -576,50 +564,50 @@ class AppLocalizationsKo extends AppLocalizations { other: 'tracks', one: 'track', ); - return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + return '기록에서 $count $_temp0를 삭제하시겠습니까?'; } @override - String get dialogImportPlaylistTitle => 'Import Playlist'; + String get dialogImportPlaylistTitle => '재생 목록 가져오기'; @override String dialogImportPlaylistMessage(int count) { - return 'Found $count tracks in CSV. Add them to download queue?'; + return 'CSV 파일에서 $count개의 트랙을 찾았습니다. 다운로드 대기열에 추가하시겠습니까?'; } @override String csvImportTracks(int count) { - return '$count tracks from CSV'; + return 'CSV 파일의 트랙: $count'; } @override String snackbarAddedToQueue(String trackName) { - return 'Added \"$trackName\" to queue'; + return '\"$trackName\"(을)를 대기열에 추가했습니다.'; } @override String snackbarAddedTracksToQueue(int count) { - return 'Added $count tracks to queue'; + return '대기열에 $count개의 트랙을 추가했습니다.'; } @override String snackbarAlreadyDownloaded(String trackName) { - return '\"$trackName\" already downloaded'; + return '\"$trackName\"(은)는 이미 다운로드되었습니다.'; } @override String snackbarAlreadyInLibrary(String trackName) { - return '\"$trackName\" already exists in your library'; + return '라이브러리에 \"$trackName\"(은)는 이미 존재합니다.'; } @override - String get snackbarHistoryCleared => 'History cleared'; + String get snackbarHistoryCleared => '기록 삭제됨'; @override - String get snackbarCredentialsSaved => 'Credentials saved'; + String get snackbarCredentialsSaved => '자격 증명이 저장되었습니다.'; @override - String get snackbarCredentialsCleared => 'Credentials cleared'; + String get snackbarCredentialsCleared => '자격 증명이 제거되었습니다.'; @override String snackbarDeletedTracks(int count) { @@ -629,136 +617,152 @@ class AppLocalizationsKo extends AppLocalizations { other: 'tracks', one: 'track', ); - return 'Deleted $count $_temp0'; + return '$count$_temp0 제거됨'; } @override String snackbarCannotOpenFile(String error) { - return 'Cannot open file: $error'; + return '파일을 열 수 없습니다: $error'; } @override - String get snackbarFillAllFields => 'Please fill all fields'; + String get snackbarFillAllFields => '모든 항목을 입력해 주세요.'; @override String get snackbarViewQueue => 'View Queue'; @override String snackbarUrlCopied(String platform) { - return '$platform URL copied to clipboard'; + return '$platform 링크가 클립보드에 저장됨'; } @override - String get snackbarFileNotFound => 'File not found'; + String get snackbarFileNotFound => '파일을 찾을 수 없음'; @override - String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + String get snackbarSelectExtFile => '.spotiflac-ext 확장자 파일을 선택'; @override - String get snackbarProviderPrioritySaved => 'Provider priority saved'; + String get snackbarProviderPrioritySaved => '제공자 우선순위 저장됨'; @override - String get snackbarMetadataProviderSaved => - 'Metadata provider priority saved'; + String get snackbarMetadataProviderSaved => '메타데이터 제공자 우선순위 저장됨'; @override String snackbarExtensionInstalled(String extensionName) { - return '$extensionName installed.'; + return '$extensionName(이)가 설치됨'; } @override String snackbarExtensionUpdated(String extensionName) { - return '$extensionName updated.'; + return '$extensionName(이)가 설치됨.'; } @override - String get snackbarFailedToInstall => 'Failed to install extension'; + String get snackbarFailedToInstall => '확장 프로그램 설치 실패'; @override - String get snackbarFailedToUpdate => 'Failed to update extension'; + String get snackbarFailedToUpdate => '확장 프로그램 업데이트 실패'; @override String get errorRateLimited => 'Rate Limited'; @override - String get errorRateLimitedMessage => - 'Too many requests. Please wait a moment before searching again.'; + String get errorRateLimitedMessage => '요청이 너무 많습니다. 잠시 후 다시 검색해 주세요.'; @override - String get errorNoTracksFound => 'No tracks found'; + String get errorNoTracksFound => '트랙을 찾을 수 없습니다'; + + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; @override String errorMissingExtensionSource(String item) { - return 'Cannot load $item: missing extension source'; + return '확장 소스가 누락되어, $item(을)를 로드할 수 없습니다'; } @override - String get actionPause => 'Pause'; + String get actionPause => '멈추기'; @override - String get actionResume => 'Resume'; + String get actionResume => '재시작'; @override - String get actionCancel => 'Cancel'; + String get actionCancel => '취소'; @override - String get actionSelectAll => 'Select All'; + String get actionSelectAll => '모두 선택'; @override - String get actionDeselect => 'Deselect'; + String get actionDeselect => '선택 해제'; @override - String get actionRemoveCredentials => 'Remove Credentials'; + String get actionRemoveCredentials => '자격 증명 제거'; @override - String get actionSaveCredentials => 'Save Credentials'; + String get actionSaveCredentials => '자격 증명 저장'; @override String selectionSelected(int count) { - return '$count selected'; + return '$count개 선택됨'; } @override - String get selectionAllSelected => 'All tracks selected'; + String get selectionAllSelected => '모든 트랙 선택됨'; @override - String get selectionSelectToDelete => 'Select tracks to delete'; + String get selectionSelectToDelete => '삭제할 트랙을 선택'; @override String progressFetchingMetadata(int current, int total) { - return 'Fetching metadata... $current/$total'; + return '메타데이터 가져오는 중... $current/$total'; } @override - String get progressReadingCsv => 'Reading CSV...'; + String get progressReadingCsv => 'CSV 파일을 읽는 중...'; @override - String get searchSongs => 'Songs'; + String get searchSongs => '곡들'; @override - String get searchArtists => 'Artists'; + String get searchArtists => '아티스트들'; @override - String get searchAlbums => 'Albums'; + String get searchAlbums => '앨범들'; @override - String get searchPlaylists => 'Playlists'; + String get searchPlaylists => '재생목록들'; @override - String get tooltipPlay => 'Play'; + String get tooltipPlay => '재생'; @override - String get filenameFormat => 'Filename Format'; + String get filenameFormat => ''; @override - String get filenameShowAdvancedTags => 'Show advanced tags'; + String get filenameShowAdvancedTags => '고급 태그 표시'; @override String get filenameShowAdvancedTagsDescription => - 'Enable formatted tags for track padding and date patterns'; + '트랙 패딩 및 날짜 패턴에 대한 서식 있는 태그를 활성화합니다.'; @override - String get folderOrganizationNone => 'No organization'; + String get folderOrganizationNone => '정리하지 않음'; + + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; @override String get folderOrganizationByArtist => 'By Artist'; @@ -877,116 +881,114 @@ class AppLocalizationsKo extends AppLocalizations { String get logShareLogs => 'Share logs'; @override - String get logClearLogs => 'Clear logs'; + String get logClearLogs => '로그 제거'; @override - String get logClearLogsTitle => 'Clear Logs'; + String get logClearLogsTitle => '로그 제거'; @override - String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; + String get logClearLogsMessage => '모든 로그를 삭제하시겠습니까?'; @override - String get logFilterBySeverity => 'Filter logs by severity'; + String get logFilterBySeverity => '심각성에 따라 로그 분류'; @override - String get logNoLogsYet => 'No logs yet'; + String get logNoLogsYet => '어떠한 로그도 없음'; @override - String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; + String get logNoLogsYetSubtitle => '앱을 사용하는 동안 로그가 여기에 표시됩니다.'; @override String logEntriesFiltered(int count) { - return 'Entries ($count filtered)'; + return '($count filtered)개 항목 필터링'; } @override String logEntries(int count) { - return 'Entries ($count)'; + return '항목 수: ($count)'; } @override - String get credentialsTitle => 'Spotify Credentials'; + String get credentialsTitle => 'Spotify 자격 증명'; @override String get credentialsDescription => - 'Enter your Client ID and Secret to use your own Spotify application quota.'; + 'Spotify 애플리케이션 할당량을 사용하려면 클라이언트 ID와 비밀키를 입력하세요.'; @override String get credentialsClientId => 'Client ID'; @override - String get credentialsClientIdHint => 'Paste Client ID'; + String get credentialsClientIdHint => 'Client ID를 붙여넣으세요'; @override - String get credentialsClientSecret => 'Client Secret'; + String get credentialsClientSecret => '비밀키'; @override - String get credentialsClientSecretHint => 'Paste Client Secret'; + String get credentialsClientSecretHint => '비밀키를 붙여넣으세요'; @override - String get channelStable => 'Stable'; + String get channelStable => '안정'; @override - String get channelPreview => 'Preview'; + String get channelPreview => '베타'; @override - String get sectionSearchSource => 'Search Source'; + String get sectionSearchSource => '검색 소스'; @override - String get sectionDownload => 'Download'; + String get sectionDownload => '다운로드'; @override - String get sectionPerformance => 'Performance'; + String get sectionPerformance => '성능'; @override - String get sectionApp => 'App'; + String get sectionApp => '앱'; @override - String get sectionData => 'Data'; + String get sectionData => '데이터'; @override String get sectionDebug => 'Debug'; @override - String get sectionService => 'Service'; + String get sectionService => '서비스'; @override - String get sectionAudioQuality => 'Audio Quality'; + String get sectionAudioQuality => '오디오 품질'; @override - String get sectionFileSettings => 'File Settings'; + 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'; + String get lyricsModeDescription => '다운로드한 파일에 가사를 저장하는 방법을 선택하세요.'; @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'; + String get lyricsModeExternalSubtitle => '삼성 뮤직과 같은 플레이어용 별도 .lrc 파일'; @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 => 'Color'; + String get sectionColor => '색상'; @override String get sectionTheme => 'Theme'; @@ -1176,6 +1178,47 @@ class AppLocalizationsKo extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1326,6 +1369,38 @@ class AppLocalizationsKo extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1632,6 +1707,25 @@ class AppLocalizationsKo extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; @@ -1807,7 +1901,8 @@ class AppLocalizationsKo extends AppLocalizations { 'Download music from Spotify, Deezer, or paste any supported URL'; @override - String get tutorialWelcomeTip2 => 'Tidal, Qobuz 또는 Deezer에서 FLAC 품질 오디오 받기'; + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; @override String get tutorialWelcomeTip3 => @@ -2081,6 +2176,28 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2113,6 +2230,18 @@ class AppLocalizationsKo extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2124,6 +2253,54 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2318,6 +2495,17 @@ class AppLocalizationsKo extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; @@ -2340,4 +2528,418 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 81ecfd12..74e93a95 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; @override String get artistAlbums => 'Albums'; @@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -688,6 +691,17 @@ class AppLocalizationsNl extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +775,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -1177,6 +1198,47 @@ class AppLocalizationsNl extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1327,6 +1389,38 @@ class AppLocalizationsNl extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1633,6 +1727,25 @@ class AppLocalizationsNl extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; @@ -1809,7 +1922,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Krijg FLAC-kwaliteit audio van Tidal, Qobuz of Deezer'; + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; @override String get tutorialWelcomeTip3 => @@ -2083,6 +2196,28 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2115,6 +2250,18 @@ class AppLocalizationsNl extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2126,6 +2273,54 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2320,6 +2515,17 @@ class AppLocalizationsNl extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; @@ -2342,4 +2548,418 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 24b813f3..b53d2e07 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -525,6 +525,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -688,6 +691,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +775,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -1177,6 +1198,47 @@ class AppLocalizationsPt extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1327,6 +1389,38 @@ class AppLocalizationsPt extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1633,6 +1727,25 @@ class AppLocalizationsPt extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; @@ -2083,6 +2196,28 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2092,7 +2227,8 @@ class AppLocalizationsPt extends AppLocalizations { String get trackConvertFormat => 'Convert Format'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => + 'Convert to MP3, Opus, ALAC, or FLAC'; @override String get trackConvertTitle => 'Convert Audio'; @@ -2115,6 +2251,18 @@ class AppLocalizationsPt extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2126,6 +2274,54 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2320,6 +2516,17 @@ class AppLocalizationsPt extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; @@ -2342,6 +2549,420 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 77913294..810b35a9 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -67,7 +67,7 @@ class AppLocalizationsRu extends AppLocalizations { String get settingsAbout => 'О программе'; @override - String get downloadTitle => 'Скачивание'; + String get downloadTitle => 'Скачать'; @override String get downloadAskQualitySubtitle => @@ -146,11 +146,11 @@ class AppLocalizationsRu extends AppLocalizations { 'Использование только встроенных провайдеров'; @override - String get optionsEmbedLyrics => 'Вставить текст песни'; + String get optionsEmbedLyrics => 'Вписать текст песни'; @override String get optionsEmbedLyricsSubtitle => - 'Вставить синхронизированные тексты в FLAC файлы'; + 'Вписать синхронизированные тексты во FLAC файлы'; @override String get optionsMaxQualityCover => 'Максимальное качество обложки'; @@ -337,7 +337,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aboutBinimumDesc => - 'Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!'; + 'Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!'; @override String get aboutSachinsenalDesc => @@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aboutAppDescription => - 'Скачайте треки Spotify в Lossless качестве из Tidal и Qobuz.'; + 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; @override String get artistAlbums => 'Альбомы'; @@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get dialogImport => 'Импорт'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Отменить'; @@ -601,7 +604,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String csvImportTracks(int count) { - return '$count треков из CSV'; + return '$count трек(-ов) из CSV'; } @override @@ -702,6 +705,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String get errorNoTracksFound => 'Треки не найдены'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Невозможно загрузить $item: отсутствует источник расширения'; @@ -766,15 +780,22 @@ class AppLocalizationsRu extends AppLocalizations { String get filenameFormat => 'Формат имени файла'; @override - String get filenameShowAdvancedTags => 'Show advanced tags'; + String get filenameShowAdvancedTags => 'Показать расширенные теги'; @override String get filenameShowAdvancedTagsDescription => - 'Enable formatted tags for track padding and date patterns'; + 'Включить форматированные теги для отслеживания заполнения и шаблонов дат'; @override String get folderOrganizationNone => 'Без организации'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'По исполнителю'; @@ -983,7 +1004,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Выберите как сохранить тексты песен при скачивании'; @override - String get lyricsModeEmbed => 'Вставить в файл'; + String get lyricsModeEmbed => 'Вписать в файл'; @override String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC'; @@ -999,7 +1020,7 @@ class AppLocalizationsRu extends AppLocalizations { String get lyricsModeBoth => 'Оба варианта'; @override - String get lyricsModeBothSubtitle => 'Вставить и сохранить файл .lrc'; + String get lyricsModeBothSubtitle => 'Вписать и сохранить .lrc файл'; @override String get sectionColor => 'Цвет'; @@ -1138,7 +1159,7 @@ class AppLocalizationsRu extends AppLocalizations { String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни'; @override - String get trackEmbedLyrics => 'Вставить текст песни'; + String get trackEmbedLyrics => 'Вписать текст песни'; @override String get trackLyricsEmbedded => 'Текст успешно добавлен'; @@ -1198,6 +1219,47 @@ class AppLocalizationsRu extends AppLocalizations { @override String get storeClearFilters => 'Очистить фильтры'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)'; @@ -1352,6 +1414,38 @@ class AppLocalizationsRu extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Фактическое качество зависит от доступности треков в сервисе'; @@ -1361,10 +1455,10 @@ class AppLocalizationsRu extends AppLocalizations { 'YouTube обеспечивает только звук с потерями(Lossy).'; @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus'; @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3'; @override String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; @@ -1383,7 +1477,8 @@ class AppLocalizationsRu extends AppLocalizations { 'Использовать исполнителя альбома для папок'; @override - String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; + String get downloadUsePrimaryArtistOnly => + 'Основной исполнитель только для папок'; @override String get downloadUsePrimaryArtistOnlyEnabled => @@ -1391,7 +1486,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadUsePrimaryArtistOnlyDisabled => - 'Full artist string used for folder name'; + 'Полная строка исполнителя, используемая для имени папки'; @override String get downloadSelectQuality => 'Выбор качества'; @@ -1423,7 +1518,7 @@ class AppLocalizationsRu extends AppLocalizations { String get settingsDownloadNetwork => 'Сеть для скачивания'; @override - String get settingsDownloadNetworkAny => 'WiFi и мобильная сеть'; + String get settingsDownloadNetworkAny => 'WiFi и Мобильная сеть'; @override String get settingsDownloadNetworkWifiOnly => 'Только WiFi'; @@ -1668,6 +1763,25 @@ class AppLocalizationsRu extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Показать при поиске существующих треков'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Действия'; @@ -1712,8 +1826,10 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); return '$_temp0'; } @@ -1800,7 +1916,7 @@ class AppLocalizationsRu extends AppLocalizations { String get libraryFilterQualityCD => 'CD (16 бит)'; @override - String get libraryFilterQualityLossy => 'С потерями'; + String get libraryFilterQualityLossy => 'Lossy'; @override String get libraryFilterFormat => 'Формат'; @@ -1855,7 +1971,8 @@ class AppLocalizationsRu extends AppLocalizations { 'Скачивайте музыку из Spotify, Deezer, или вставьте любой поддерживаемый URL'; @override - String get tutorialWelcomeTip2 => 'Скачайте FLAC с Tidal, Qobuz или Deezer'; + String get tutorialWelcomeTip2 => + 'Скачайте FLAC с Tidal, Qobuz или Amazon Music'; @override String get tutorialWelcomeTip3 => @@ -1903,7 +2020,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tutorialExtensionsTip1 => - 'Browse the Store tab to discover useful extensions'; + 'Просмотрите вкладку Магазина, чтобы найти полезные расширения'; @override String get tutorialExtensionsTip2 => @@ -1911,14 +2028,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tutorialExtensionsTip3 => - 'Get lyrics, enhanced metadata, and more features'; + 'Получайте тексты песен, улучшенные метаданные и другие возможности'; @override String get tutorialSettingsTitle => 'Настройте приложение под себя'; @override String get tutorialSettingsDesc => - 'Personalize the app in Settings to match your preferences.'; + 'Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.'; @override String get tutorialSettingsTip1 => @@ -1943,11 +2060,11 @@ class AppLocalizationsRu extends AppLocalizations { 'Пересканировать все файлы, игнорировать кэш'; @override - String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads'; + String get cleanupOrphanedDownloads => 'Очистка отложенных скачиваний'; @override String get cleanupOrphanedDownloadsSubtitle => - 'Remove history entries for files that no longer exist'; + 'Удалить историю записи для файлов, которых больше не существует'; @override String cleanupOrphanedDownloadsResult(int count) { @@ -1955,7 +2072,7 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; + String get cleanupOrphanedDownloadsNone => 'Записей без описания не найдено'; @override String get cacheTitle => 'Хранилище и кэш'; @@ -1965,11 +2082,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get cacheSummarySubtitle => - 'Clearing cache will not remove downloaded music files.'; + 'Очистка кэша не приведет к удалению загруженных музыкальных файлов.'; @override String cacheEstimatedTotal(String size) { - return 'Estimated cache usage: $size'; + return 'Приблизительное использование кэша: $size'; } @override @@ -1983,42 +2100,42 @@ class AppLocalizationsRu extends AppLocalizations { @override String get cacheAppDirectoryDesc => - 'HTTP responses, WebView data, and other temporary app data.'; + 'HTTP-ответы, данные WebView и другие временные данные приложения.'; @override - String get cacheTempDirectory => 'Temporary directory'; + String get cacheTempDirectory => 'Временная директория'; @override String get cacheTempDirectoryDesc => - 'Temporary files from downloads and audio conversion.'; + 'Временные файлы из загрузок и аудио конвертации.'; @override - String get cacheCoverImage => 'Cover image cache'; + String get cacheCoverImage => 'Кэш обложек'; @override String get cacheCoverImageDesc => - 'Downloaded album and track cover art. Will re-download when viewed.'; + 'Скачанный альбом и трек обложки. Будет заново скачан после просмотра.'; @override - String get cacheLibraryCover => 'Library cover cache'; + String get cacheLibraryCover => 'Кэш обложек библиотеки'; @override String get cacheLibraryCoverDesc => - 'Cover art extracted from local music files. Will re-extract on next scan.'; + 'Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.'; @override - String get cacheExploreFeed => 'Explore feed cache'; + String get cacheExploreFeed => 'Просмотреть кэш ленты'; @override String get cacheExploreFeedDesc => - 'Explore tab content (new releases, trending). Will refresh on next visit.'; + 'Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.'; @override - String get cacheTrackLookup => 'Track lookup cache'; + String get cacheTrackLookup => 'Отслеживать кэш поиска'; @override String get cacheTrackLookupDesc => - 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'; + 'Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.'; @override String get cacheCleanupUnusedDesc => @@ -2039,7 +2156,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String cacheEntries(int count) { - return '$count entries'; + return '$count записей'; } @override @@ -2052,7 +2169,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String cacheClearConfirmMessage(String target) { - return 'This will clear cached data for $target. Downloaded music files will not be deleted.'; + return 'Это очистит кэш для $target. Загруженные музыкальные файлы не будут удалены.'; } @override @@ -2074,7 +2191,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String cacheCleanupResult(int downloadCount, int libraryCount) { - return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries'; + return 'Очистка завершена: $downloadCount потерянных загрузок, $libraryCount отсутствующих записей в библиотеке'; } @override @@ -2094,14 +2211,14 @@ class AppLocalizationsRu extends AppLocalizations { 'Получить и сохранить текст песни в формате .lrc'; @override - String get trackSaveLyricsProgress => 'Saving lyrics...'; + String get trackSaveLyricsProgress => 'Сохранение текста...'; @override - String get trackReEnrich => 'Re-enrich'; + String get trackReEnrich => 'Обновить'; @override String get trackReEnrichOnlineSubtitle => - 'Search metadata online and embed into file'; + 'Поиск в сети метаданных и встраивание в файл'; @override String get trackEditMetadata => 'Редактировать метаданные'; @@ -2120,40 +2237,62 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get trackReEnrichProgress => 'Re-enriching metadata...'; + String get trackReEnrichProgress => 'Обновление метаданных...'; @override String get trackReEnrichSearching => 'Поиск метаданных в сети...'; @override - String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + String get trackReEnrichSuccess => 'Метаданные успешно обновлены'; @override String get trackReEnrichFfmpegFailed => 'Ошибка встраивания метаданных FFmpeg'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Ошибка: $error'; } @override - String get trackConvertFormat => 'Convert Format'; + String get trackConvertFormat => 'Переконвертировать формат'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => 'Конвертировать в MP3 или Opus'; @override - String get trackConvertTitle => 'Convert Audio'; + String get trackConvertTitle => 'Конвертировать аудио'; @override - String get trackConvertTargetFormat => 'Target Format'; + String get trackConvertTargetFormat => 'Целевой формат'; @override - String get trackConvertBitrate => 'Bitrate'; + String get trackConvertBitrate => 'Битрейт'; @override - String get trackConvertConfirmTitle => 'Confirm Conversion'; + String get trackConvertConfirmTitle => 'Подтвердить конвертацию'; @override String trackConvertConfirmMessage( @@ -2161,177 +2300,241 @@ class AppLocalizationsRu extends AppLocalizations { String targetFormat, String bitrate, ) { - return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; + return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.'; } @override - String get trackConvertConverting => 'Converting audio...'; + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + + @override + String get trackConvertConverting => 'Конвертация аудио...'; @override String trackConvertSuccess(String format) { - return 'Converted to $format successfully'; + return 'Успешно конвертировано в $format'; } @override - String get trackConvertFailed => 'Conversion failed'; + String get trackConvertFailed => 'Ошибка конвертации'; @override - String get actionCreate => 'Create'; + String get cueSplitTitle => 'Split CUE Sheet'; @override - String get collectionFoldersTitle => 'My folders'; + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; @override - String get collectionWishlist => 'Wishlist'; + String cueSplitAlbum(String album) { + return 'Album: $album'; + } @override - String get collectionLoved => 'Loved'; + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } @override - String get collectionPlaylists => 'Playlists'; + String cueSplitTrackCount(int count) { + return '$count tracks'; + } @override - String get collectionPlaylist => 'Playlist'; + String get cueSplitConfirmTitle => 'Split CUE Album'; @override - String get collectionAddToPlaylist => 'Add to playlist'; + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } @override - String get collectionCreatePlaylist => 'Create playlist'; + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } @override - String get collectionNoPlaylistsYet => 'No playlists yet'; + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + + @override + String get actionCreate => 'Создать'; + + @override + String get collectionFoldersTitle => 'Мои папки'; + + @override + String get collectionWishlist => 'Список желаемого'; + + @override + String get collectionLoved => 'Любимые'; + + @override + String get collectionPlaylists => 'Плейлисты'; + + @override + String get collectionPlaylist => 'Плейлист'; + + @override + String get collectionAddToPlaylist => 'Добавить в плейлист'; + + @override + String get collectionCreatePlaylist => 'Создать плейлист'; + + @override + String get collectionNoPlaylistsYet => 'Плейлисты отсутствуют'; @override String get collectionNoPlaylistsSubtitle => - 'Create a playlist to start categorizing tracks'; + 'Создайте плейлист, чтобы начать классифицировать треки'; @override String collectionPlaylistTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count треков', + many: '$count треков', + few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @override String collectionAddedToPlaylist(String playlistName) { - return 'Added to \"$playlistName\"'; + return 'Добавлено в \"$playlistName\"'; } @override String collectionAlreadyInPlaylist(String playlistName) { - return 'Already in \"$playlistName\"'; + return 'Уже в \"$playlistName\"'; } @override - String get collectionPlaylistCreated => 'Playlist created'; + String get collectionPlaylistCreated => 'Плейлист создан'; @override - String get collectionPlaylistNameHint => 'Playlist name'; + String get collectionPlaylistNameHint => 'Название плейлиста'; @override - String get collectionPlaylistNameRequired => 'Playlist name is required'; + String get collectionPlaylistNameRequired => 'Имя плейлиста обязательно'; @override - String get collectionRenamePlaylist => 'Rename playlist'; + String get collectionRenamePlaylist => 'Переименовать плейлист'; @override - String get collectionDeletePlaylist => 'Delete playlist'; + String get collectionDeletePlaylist => 'Удалить плейлист'; @override String collectionDeletePlaylistMessage(String playlistName) { - return 'Delete \"$playlistName\" and all tracks inside it?'; + return 'Удалить \"$playlistName\" и все треки внутри него?'; } @override - String get collectionPlaylistDeleted => 'Playlist deleted'; + String get collectionPlaylistDeleted => 'Плейлист удалён'; @override - String get collectionPlaylistRenamed => 'Playlist renamed'; + String get collectionPlaylistRenamed => 'Плейлист переименован'; @override - String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + String get collectionWishlistEmptyTitle => 'Список желаний пуст'; @override String get collectionWishlistEmptySubtitle => - 'Tap + on tracks to save what you want to download later'; + 'Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже'; @override - String get collectionLovedEmptyTitle => 'Loved folder is empty'; + String get collectionLovedEmptyTitle => 'Папка Любимые пуста'; @override String get collectionLovedEmptySubtitle => - 'Tap love on tracks to keep your favorites'; + 'Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные'; @override - String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + String get collectionPlaylistEmptyTitle => 'Плейлист пуст'; @override String get collectionPlaylistEmptySubtitle => - 'Long-press + on any track to add it here'; + 'Удерживайте + на любом треке, чтобы добавить его сюда'; @override - String get collectionRemoveFromPlaylist => 'Remove from playlist'; + String get collectionRemoveFromPlaylist => 'Удалить из плейлиста'; @override - String get collectionRemoveFromFolder => 'Remove from folder'; + String get collectionRemoveFromFolder => 'Убрать из папки'; @override String collectionRemoved(String trackName) { - return '\"$trackName\" removed'; + return '\"$trackName\" удалён'; } @override String collectionAddedToLoved(String trackName) { - return '\"$trackName\" added to Loved'; + return '\"$trackName\" добавлен в Любимые'; } @override String collectionRemovedFromLoved(String trackName) { - return '\"$trackName\" removed from Loved'; + return '\"$trackName\" удалено из Любимых'; } @override String collectionAddedToWishlist(String trackName) { - return '\"$trackName\" added to Wishlist'; + return '\"$trackName\" добавлен в список желаний'; } @override String collectionRemovedFromWishlist(String trackName) { - return '\"$trackName\" removed from Wishlist'; + return '\"$trackName\" удалён из списка желаний'; } @override - String get trackOptionAddToLoved => 'Add to Loved'; + String get trackOptionAddToLoved => 'Добавить в Любимое'; @override - String get trackOptionRemoveFromLoved => 'Remove from Loved'; + String get trackOptionRemoveFromLoved => 'Исключить из Любимых'; @override - String get trackOptionAddToWishlist => 'Add to Wishlist'; + String get trackOptionAddToWishlist => 'Добавить в список желаний'; @override - String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + String get trackOptionRemoveFromWishlist => 'Удалить из списка желаний'; @override - String get collectionPlaylistChangeCover => 'Change cover image'; + String get collectionPlaylistChangeCover => 'Изменить обложку'; @override - String get collectionPlaylistRemoveCover => 'Remove cover image'; + String get collectionPlaylistRemoveCover => 'Удалить обложку'; @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); - return 'Share $count $_temp0'; + return 'Отправить $count $_temp0'; } @override @@ -2342,17 +2545,19 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); - return 'Convert $count $_temp0'; + return 'Конвертировать $count $_temp0'; } @override - String get selectionConvertNoConvertible => 'No convertible tracks selected'; + String get selectionConvertNoConvertible => 'Не выбраны конвертируемые треки'; @override - String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + String get selectionBatchConvertConfirmTitle => 'Пакетная конвертация'; @override String selectionBatchConvertConfirmMessage( @@ -2369,14 +2574,25 @@ class AppLocalizationsRu extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { - return 'Converting $current of $total...'; + return 'Конвертация $current из $total...'; } @override String selectionBatchConvertSuccess(int success, int total, String format) { - return 'Converted $success of $total tracks to $format'; + return 'Конвертировано $success треков $total в $format'; } @override @@ -2386,9 +2602,423 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; + 'Для папок исполнителей используется исполнитель альбома, если он указан'; @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Папки исполнителя используют только трек исполнителя'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 53034492..63a54638 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -530,6 +530,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get dialogImport => 'İçe aktar'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Vazgeç'; @@ -693,6 +696,17 @@ class AppLocalizationsTr extends AppLocalizations { @override String get errorNoTracksFound => 'Parça bulunamadı'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return '$item yüklenemedi: Eksik eklenti kaynağı'; @@ -766,6 +780,13 @@ class AppLocalizationsTr extends AppLocalizations { @override String get folderOrganizationNone => 'Organizasyon yok'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'Sanatçıya Göre'; @@ -1188,6 +1209,47 @@ class AppLocalizationsTr extends AppLocalizations { @override String get storeClearFilters => 'Filtreleri temizle'; + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + @override String get extensionDefaultProvider => 'Varsayılan (Deezer/Spotify)'; @@ -1339,6 +1401,38 @@ class AppLocalizationsTr extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1645,6 +1739,25 @@ class AppLocalizationsTr extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; @@ -2095,6 +2208,28 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2127,6 +2262,18 @@ class AppLocalizationsTr extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2138,6 +2285,54 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2332,6 +2527,17 @@ class AppLocalizationsTr extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; @@ -2354,4 +2560,418 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a4fc7e77..31adb1ff 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -525,6 +525,2967 @@ class AppLocalizationsZh extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + + @override + String get dialogDiscard => 'Discard'; + + @override + String get dialogRemove => 'Remove'; + + @override + String get dialogUninstall => 'Uninstall'; + + @override + String get dialogDiscardChanges => 'Discard Changes?'; + + @override + String get dialogUnsavedChanges => + 'You have unsaved changes. Do you want to discard them?'; + + @override + String get dialogClearAll => 'Clear All'; + + @override + String get dialogRemoveExtension => 'Remove Extension'; + + @override + String get dialogRemoveExtensionMessage => + 'Are you sure you want to remove this extension? This cannot be undone.'; + + @override + String get dialogUninstallExtension => 'Uninstall Extension?'; + + @override + String dialogUninstallExtensionMessage(String extensionName) { + return 'Are you sure you want to remove $extensionName?'; + } + + @override + String get dialogClearHistoryTitle => 'Clear History'; + + @override + String get dialogClearHistoryMessage => + 'Are you sure you want to clear all download history? This cannot be undone.'; + + @override + String get dialogDeleteSelectedTitle => 'Delete Selected'; + + @override + String dialogDeleteSelectedMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + } + + @override + String get dialogImportPlaylistTitle => 'Import Playlist'; + + @override + String dialogImportPlaylistMessage(int count) { + return 'Found $count tracks in CSV. Add them to download queue?'; + } + + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + + @override + String snackbarAddedToQueue(String trackName) { + return 'Added \"$trackName\" to queue'; + } + + @override + String snackbarAddedTracksToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String snackbarAlreadyDownloaded(String trackName) { + return '\"$trackName\" already downloaded'; + } + + @override + String snackbarAlreadyInLibrary(String trackName) { + return '\"$trackName\" already exists in your library'; + } + + @override + String get snackbarHistoryCleared => 'History cleared'; + + @override + String get snackbarCredentialsSaved => 'Credentials saved'; + + @override + String get snackbarCredentialsCleared => 'Credentials cleared'; + + @override + String snackbarDeletedTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Deleted $count $_temp0'; + } + + @override + String snackbarCannotOpenFile(String error) { + return 'Cannot open file: $error'; + } + + @override + String get snackbarFillAllFields => 'Please fill all fields'; + + @override + String get snackbarViewQueue => 'View Queue'; + + @override + String snackbarUrlCopied(String platform) { + return '$platform URL copied to clipboard'; + } + + @override + String get snackbarFileNotFound => 'File not found'; + + @override + String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + + @override + String get snackbarProviderPrioritySaved => 'Provider priority saved'; + + @override + String get snackbarMetadataProviderSaved => + 'Metadata provider priority saved'; + + @override + String snackbarExtensionInstalled(String extensionName) { + return '$extensionName installed.'; + } + + @override + String snackbarExtensionUpdated(String extensionName) { + return '$extensionName updated.'; + } + + @override + String get snackbarFailedToInstall => 'Failed to install extension'; + + @override + String get snackbarFailedToUpdate => 'Failed to update extension'; + + @override + String get errorRateLimited => 'Rate Limited'; + + @override + String get errorRateLimitedMessage => + 'Too many requests. Please wait a moment before searching again.'; + + @override + String get errorNoTracksFound => 'No tracks found'; + + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + + @override + String errorMissingExtensionSource(String item) { + return 'Cannot load $item: missing extension source'; + } + + @override + String get actionPause => 'Pause'; + + @override + String get actionResume => 'Resume'; + + @override + String get actionCancel => 'Cancel'; + + @override + String get actionSelectAll => 'Select All'; + + @override + String get actionDeselect => 'Deselect'; + + @override + String get actionRemoveCredentials => 'Remove Credentials'; + + @override + String get actionSaveCredentials => 'Save Credentials'; + + @override + String selectionSelected(int count) { + return '$count selected'; + } + + @override + String get selectionAllSelected => 'All tracks selected'; + + @override + String get selectionSelectToDelete => 'Select tracks to delete'; + + @override + String progressFetchingMetadata(int current, int total) { + return 'Fetching metadata... $current/$total'; + } + + @override + String get progressReadingCsv => 'Reading CSV...'; + + @override + String get searchSongs => 'Songs'; + + @override + String get searchArtists => 'Artists'; + + @override + String get searchAlbums => 'Albums'; + + @override + String get searchPlaylists => 'Playlists'; + + @override + String get tooltipPlay => 'Play'; + + @override + String get filenameFormat => 'Filename Format'; + + @override + String get filenameShowAdvancedTags => 'Show advanced tags'; + + @override + String get filenameShowAdvancedTagsDescription => + 'Enable formatted tags for track padding and date patterns'; + + @override + String get folderOrganizationNone => 'No organization'; + + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + + @override + String get folderOrganizationByArtist => 'By Artist'; + + @override + String get folderOrganizationByAlbum => 'By Album'; + + @override + String get folderOrganizationByArtistAlbum => 'Artist/Album'; + + @override + String get folderOrganizationDescription => + 'Organize downloaded files into folders'; + + @override + String get folderOrganizationNoneSubtitle => 'All files in download folder'; + + @override + String get folderOrganizationByArtistSubtitle => + 'Separate folder for each artist'; + + @override + String get folderOrganizationByAlbumSubtitle => + 'Separate folder for each album'; + + @override + String get folderOrganizationByArtistAlbumSubtitle => + 'Nested folders for artist and album'; + + @override + String get updateAvailable => 'Update Available'; + + @override + String get updateLater => 'Later'; + + @override + String get updateStartingDownload => 'Starting download...'; + + @override + String get updateDownloadFailed => 'Download failed'; + + @override + String get updateFailedMessage => 'Failed to download update'; + + @override + String get updateNewVersionReady => 'A new version is ready'; + + @override + String get updateCurrent => 'Current'; + + @override + String get updateNew => 'New'; + + @override + String get updateDownloading => 'Downloading...'; + + @override + String get updateWhatsNew => 'What\'s New'; + + @override + String get updateDownloadInstall => 'Download & Install'; + + @override + String get updateDontRemind => 'Don\'t remind'; + + @override + String get providerPriorityTitle => 'Provider Priority'; + + @override + String get providerPriorityDescription => + 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; + + @override + String get providerPriorityInfo => + 'If a track is not available on the first provider, the app will automatically try the next one.'; + + @override + String get providerBuiltIn => 'Built-in'; + + @override + String get providerExtension => 'Extension'; + + @override + String get metadataProviderPriorityTitle => 'Metadata Priority'; + + @override + String get metadataProviderPriorityDescription => + 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; + + @override + String get metadataProviderPriorityInfo => + 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; + + @override + String get metadataNoRateLimits => 'No rate limits'; + + @override + String get metadataMayRateLimit => 'May rate limit'; + + @override + String get logTitle => 'Logs'; + + @override + String get logCopied => 'Logs copied to clipboard'; + + @override + String get logSearchHint => 'Search logs...'; + + @override + String get logFilterLevel => 'Level'; + + @override + String get logFilterSection => 'Filter'; + + @override + String get logShareLogs => 'Share logs'; + + @override + String get logClearLogs => 'Clear logs'; + + @override + String get logClearLogsTitle => 'Clear Logs'; + + @override + String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; + + @override + String get logFilterBySeverity => 'Filter logs by severity'; + + @override + String get logNoLogsYet => 'No logs yet'; + + @override + String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; + + @override + String logEntriesFiltered(int count) { + return 'Entries ($count filtered)'; + } + + @override + String logEntries(int count) { + return 'Entries ($count)'; + } + + @override + String get credentialsTitle => 'Spotify Credentials'; + + @override + String get credentialsDescription => + 'Enter your Client ID and Secret to use your own Spotify application quota.'; + + @override + String get credentialsClientId => 'Client ID'; + + @override + String get credentialsClientIdHint => 'Paste Client ID'; + + @override + String get credentialsClientSecret => 'Client Secret'; + + @override + String get credentialsClientSecretHint => 'Paste Client Secret'; + + @override + String get channelStable => 'Stable'; + + @override + String get channelPreview => 'Preview'; + + @override + String get sectionSearchSource => 'Search Source'; + + @override + String get sectionDownload => 'Download'; + + @override + String get sectionPerformance => 'Performance'; + + @override + String get sectionApp => 'App'; + + @override + String get sectionData => 'Data'; + + @override + String get sectionDebug => 'Debug'; + + @override + String get sectionService => 'Service'; + + @override + String get sectionAudioQuality => 'Audio Quality'; + + @override + String get sectionFileSettings => 'File Settings'; + + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + + @override + String get sectionColor => 'Color'; + + @override + String get sectionTheme => 'Theme'; + + @override + String get sectionLayout => 'Layout'; + + @override + String get sectionLanguage => 'Language'; + + @override + String get appearanceLanguage => 'App Language'; + + @override + String get settingsAppearanceSubtitle => 'Theme, colors, display'; + + @override + String get settingsDownloadSubtitle => 'Service, quality, filename format'; + + @override + String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; + + @override + String get settingsExtensionsSubtitle => 'Manage download providers'; + + @override + String get settingsLogsSubtitle => 'View app logs for debugging'; + + @override + String get loadingSharedLink => 'Loading shared link...'; + + @override + String get pressBackAgainToExit => 'Press back again to exit'; + + @override + String downloadAllCount(int count) { + return 'Download All ($count)'; + } + + @override + String tracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String get trackCopyFilePath => 'Copy file path'; + + @override + String get trackRemoveFromDevice => 'Remove from device'; + + @override + String get trackLoadLyrics => 'Load Lyrics'; + + @override + String get trackMetadata => 'Metadata'; + + @override + String get trackFileInfo => 'File Info'; + + @override + String get trackLyrics => 'Lyrics'; + + @override + String get trackFileNotFound => 'File not found'; + + @override + String get trackOpenInDeezer => 'Open in Deezer'; + + @override + String get trackOpenInSpotify => 'Open in Spotify'; + + @override + String get trackTrackName => 'Track name'; + + @override + String get trackArtist => 'Artist'; + + @override + String get trackAlbumArtist => 'Album artist'; + + @override + String get trackAlbum => 'Album'; + + @override + String get trackTrackNumber => 'Track number'; + + @override + String get trackDiscNumber => 'Disc number'; + + @override + String get trackDuration => 'Duration'; + + @override + String get trackAudioQuality => 'Audio quality'; + + @override + String get trackReleaseDate => 'Release date'; + + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + + @override + String get trackDownloaded => 'Downloaded'; + + @override + String get trackCopyLyrics => 'Copy lyrics'; + + @override + String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + + @override + String get trackLyricsTimeout => 'Request timed out. Try again later.'; + + @override + String get trackLyricsLoadFailed => 'Failed to load lyrics'; + + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + + @override + String get trackCopiedToClipboard => 'Copied to clipboard'; + + @override + String get trackDeleteConfirmTitle => 'Remove from device?'; + + @override + String get trackDeleteConfirmMessage => + 'This will permanently delete the downloaded file and remove it from your history.'; + + @override + String get dateToday => 'Today'; + + @override + String get dateYesterday => 'Yesterday'; + + @override + String dateDaysAgo(int count) { + return '$count days ago'; + } + + @override + String dateWeeksAgo(int count) { + return '$count weeks ago'; + } + + @override + String dateMonthsAgo(int count) { + return '$count months ago'; + } + + @override + String get storeFilterAll => 'All'; + + @override + String get storeFilterMetadata => 'Metadata'; + + @override + String get storeFilterDownload => 'Download'; + + @override + String get storeFilterUtility => 'Utility'; + + @override + String get storeFilterLyrics => 'Lyrics'; + + @override + String get storeFilterIntegration => 'Integration'; + + @override + String get storeClearFilters => 'Clear filters'; + + @override + String get storeAddRepoTitle => 'Add Extension Repository'; + + @override + String get storeAddRepoDescription => + 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'; + + @override + String get storeRepoUrlLabel => 'Repository URL'; + + @override + String get storeRepoUrlHint => 'https://github.com/user/repo'; + + @override + String get storeRepoUrlHelper => + 'e.g. https://github.com/user/extensions-repo'; + + @override + String get storeAddRepoButton => 'Add Repository'; + + @override + String get storeChangeRepoTooltip => 'Change repository'; + + @override + String get storeRepoDialogTitle => 'Extension Repository'; + + @override + String get storeRepoDialogCurrent => 'Current repository:'; + + @override + String get storeNewRepoUrlLabel => 'New Repository URL'; + + @override + String get storeLoadError => 'Failed to load store'; + + @override + String get storeEmptyNoExtensions => 'No extensions available'; + + @override + String get storeEmptyNoResults => 'No extensions found'; + + @override + String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Use built-in search'; + + @override + String get extensionAuthor => 'Author'; + + @override + String get extensionId => 'ID'; + + @override + String get extensionError => 'Error'; + + @override + String get extensionCapabilities => 'Capabilities'; + + @override + String get extensionMetadataProvider => 'Metadata Provider'; + + @override + String get extensionDownloadProvider => 'Download Provider'; + + @override + String get extensionLyricsProvider => 'Lyrics Provider'; + + @override + String get extensionUrlHandler => 'URL Handler'; + + @override + String get extensionQualityOptions => 'Quality Options'; + + @override + String get extensionPostProcessingHooks => 'Post-Processing Hooks'; + + @override + String get extensionPermissions => 'Permissions'; + + @override + String get extensionSettings => 'Settings'; + + @override + String get extensionRemoveButton => 'Remove Extension'; + + @override + String get extensionUpdated => 'Updated'; + + @override + String get extensionMinAppVersion => 'Min App Version'; + + @override + String get extensionCustomTrackMatching => 'Custom Track Matching'; + + @override + String get extensionPostProcessing => 'Post-Processing'; + + @override + String extensionHooksAvailable(int count) { + return '$count hook(s) available'; + } + + @override + String extensionPatternsCount(int count) { + return '$count pattern(s)'; + } + + @override + String extensionStrategy(String strategy) { + return 'Strategy: $strategy'; + } + + @override + String get extensionsProviderPrioritySection => 'Provider Priority'; + + @override + String get extensionsInstalledSection => 'Installed Extensions'; + + @override + String get extensionsNoExtensions => 'No extensions installed'; + + @override + String get extensionsNoExtensionsSubtitle => + 'Install .spotiflac-ext files to add new providers'; + + @override + String get extensionsInstallButton => 'Install Extension'; + + @override + String get extensionsInfoTip => + 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; + + @override + String get extensionsInstalledSuccess => 'Extension installed successfully'; + + @override + String get extensionsDownloadPriority => 'Download Priority'; + + @override + String get extensionsDownloadPrioritySubtitle => 'Set download service order'; + + @override + String get extensionsNoDownloadProvider => + 'No extensions with download provider'; + + @override + String get extensionsMetadataPriority => 'Metadata Priority'; + + @override + String get extensionsMetadataPrioritySubtitle => + 'Set search & metadata source order'; + + @override + String get extensionsNoMetadataProvider => + 'No extensions with metadata provider'; + + @override + String get extensionsSearchProvider => 'Search Provider'; + + @override + String get extensionsNoCustomSearch => 'No extensions with custom search'; + + @override + String get extensionsSearchProviderDescription => + 'Choose which service to use for searching tracks'; + + @override + String get extensionsCustomSearch => 'Custom search'; + + @override + String get extensionsErrorLoading => 'Error loading extension'; + + @override + String get qualityFlacLossless => 'FLAC Lossless'; + + @override + String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + + @override + String get qualityHiResFlac => 'Hi-Res FLAC'; + + @override + String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + + @override + String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + + @override + String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + + @override + String get downloadLossy320 => 'Lossy 320kbps'; + + @override + String get downloadLossyFormat => 'Lossy Format'; + + @override + String get downloadLossy320Format => 'Lossy 320kbps Format'; + + @override + String get downloadLossy320FormatDesc => + 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; + + @override + String get downloadLossyMp3 => 'MP3 320kbps'; + + @override + String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; + + @override + String get downloadLossyOpus256 => 'Opus 256kbps'; + + @override + String get downloadLossyOpus256Subtitle => + 'Best quality Opus, ~8MB per track'; + + @override + String get downloadLossyOpus128 => 'Opus 128kbps'; + + @override + String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; + + @override + String get qualityNote => + 'Actual quality depends on track availability from the service'; + + @override + String get youtubeQualityNote => + 'YouTube provides lossy audio only. Not part of lossless fallback.'; + + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + + @override + String get downloadAskBeforeDownload => 'Ask Before Download'; + + @override + String get downloadDirectory => 'Download Directory'; + + @override + String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + + @override + String get downloadAlbumFolderStructure => 'Album Folder Structure'; + + @override + String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; + + @override + String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; + + @override + String get downloadUsePrimaryArtistOnlyEnabled => + 'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'; + + @override + String get downloadUsePrimaryArtistOnlyDisabled => + 'Full artist string used for folder name'; + + @override + String get downloadSelectQuality => 'Select Quality'; + + @override + String get downloadFrom => 'Download From'; + + @override + String get appearanceAmoledDark => 'AMOLED Dark'; + + @override + String get appearanceAmoledDarkSubtitle => 'Pure black background'; + + @override + String get queueClearAll => 'Clear All'; + + @override + String get queueClearAllMessage => + 'Are you sure you want to clear all downloads?'; + + @override + String get settingsAutoExportFailed => 'Auto-export failed downloads'; + + @override + String get settingsAutoExportFailedSubtitle => + 'Save failed downloads to TXT file automatically'; + + @override + String get settingsDownloadNetwork => 'Download Network'; + + @override + String get settingsDownloadNetworkAny => 'WiFi + Mobile Data'; + + @override + String get settingsDownloadNetworkWifiOnly => 'WiFi Only'; + + @override + String get settingsDownloadNetworkSubtitle => + 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; + + @override + String get albumFolderArtistAlbum => 'Artist / Album'; + + @override + String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; + + @override + String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; + + @override + String get albumFolderArtistYearAlbumSubtitle => + 'Albums/Artist Name/[2005] Album Name/'; + + @override + String get albumFolderAlbumOnly => 'Album Only'; + + @override + String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + + @override + String get albumFolderYearAlbum => '[Year] Album'; + + @override + String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + + @override + String get downloadedAlbumDeleteSelected => 'Delete Selected'; + + @override + String downloadedAlbumDeleteMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + } + + @override + String downloadedAlbumSelectedCount(int count) { + return '$count selected'; + } + + @override + String get downloadedAlbumAllSelected => 'All tracks selected'; + + @override + String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + + @override + String downloadedAlbumDeleteCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String get recentEmpty => 'No recent items yet'; + + @override + String get recentShowAllDownloads => 'Show All Downloads'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @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.'; + + @override + String get settingsLocalLibrary => 'Local Library'; + + @override + String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; + + @override + String get settingsCache => 'Storage & Cache'; + + @override + String get settingsCacheSubtitle => 'View size and clear cached data'; + + @override + String get libraryTitle => 'Local Library'; + + @override + String get libraryScanSettings => 'Scan Settings'; + + @override + String get libraryEnableLocalLibrary => 'Enable Local Library'; + + @override + String get libraryEnableLocalLibrarySubtitle => + 'Scan and track your existing music'; + + @override + String get libraryFolder => 'Library Folder'; + + @override + String get libraryFolderHint => 'Tap to select folder'; + + @override + String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; + + @override + String get libraryShowDuplicateIndicatorSubtitle => + 'Show when searching for existing tracks'; + + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + + @override + String get libraryActions => 'Actions'; + + @override + String get libraryScan => 'Scan Library'; + + @override + String get libraryScanSubtitle => 'Scan for audio files'; + + @override + String get libraryScanSelectFolderFirst => 'Select a folder first'; + + @override + String get libraryCleanupMissingFiles => 'Cleanup Missing Files'; + + @override + String get libraryCleanupMissingFilesSubtitle => + 'Remove entries for files that no longer exist'; + + @override + String get libraryClear => 'Clear Library'; + + @override + String get libraryClearSubtitle => 'Remove all scanned tracks'; + + @override + String get libraryClearConfirmTitle => 'Clear Library'; + + @override + String get libraryClearConfirmMessage => + 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'; + + @override + String get libraryAbout => 'About Local Library'; + + @override + String get libraryAboutDescription => + 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; + + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + + @override + String libraryLastScanned(String time) { + return 'Last scanned: $time'; + } + + @override + String get libraryLastScannedNever => 'Never'; + + @override + String get libraryScanning => 'Scanning...'; + + @override + String libraryScanProgress(String progress, int total) { + return '$progress% of $total files'; + } + + @override + String get libraryInLibrary => 'In Library'; + + @override + String libraryRemovedMissingFiles(int count) { + return 'Removed $count missing files from library'; + } + + @override + String get libraryCleared => 'Library cleared'; + + @override + String get libraryStorageAccessRequired => 'Storage Access Required'; + + @override + String get libraryStorageAccessMessage => + 'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.'; + + @override + String get libraryFolderNotExist => 'Selected folder does not exist'; + + @override + String get librarySourceDownloaded => 'Downloaded'; + + @override + String get librarySourceLocal => 'Local'; + + @override + String get libraryFilterAll => 'All'; + + @override + String get libraryFilterDownloaded => 'Downloaded'; + + @override + String get libraryFilterLocal => 'Local'; + + @override + String get libraryFilterTitle => 'Filters'; + + @override + String get libraryFilterReset => 'Reset'; + + @override + String get libraryFilterApply => 'Apply'; + + @override + String get libraryFilterSource => 'Source'; + + @override + String get libraryFilterQuality => 'Quality'; + + @override + String get libraryFilterQualityHiRes => 'Hi-Res (24bit)'; + + @override + String get libraryFilterQualityCD => 'CD (16bit)'; + + @override + String get libraryFilterQualityLossy => 'Lossy'; + + @override + String get libraryFilterFormat => 'Format'; + + @override + String get libraryFilterSort => 'Sort'; + + @override + String get libraryFilterSortLatest => 'Latest'; + + @override + String get libraryFilterSortOldest => 'Oldest'; + + @override + String get timeJustNow => 'Just now'; + + @override + String timeMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count minutes ago', + one: '1 minute ago', + ); + return '$_temp0'; + } + + @override + String timeHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hours ago', + one: '1 hour ago', + ); + return '$_temp0'; + } + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get libraryForceFullScan => 'Force Full Scan'; + + @override + String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache'; + + @override + String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads'; + + @override + String get cleanupOrphanedDownloadsSubtitle => + 'Remove history entries for files that no longer exist'; + + @override + String cleanupOrphanedDownloadsResult(int count) { + return 'Removed $count orphaned entries from history'; + } + + @override + String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; + + @override + String get cacheTitle => 'Storage & Cache'; + + @override + String get cacheSummaryTitle => 'Cache overview'; + + @override + String get cacheSummarySubtitle => + 'Clearing cache will not remove downloaded music files.'; + + @override + String cacheEstimatedTotal(String size) { + return 'Estimated cache usage: $size'; + } + + @override + String get cacheSectionStorage => 'Cached Data'; + + @override + String get cacheSectionMaintenance => 'Maintenance'; + + @override + String get cacheAppDirectory => 'App cache directory'; + + @override + String get cacheAppDirectoryDesc => + 'HTTP responses, WebView data, and other temporary app data.'; + + @override + String get cacheTempDirectory => 'Temporary directory'; + + @override + String get cacheTempDirectoryDesc => + 'Temporary files from downloads and audio conversion.'; + + @override + String get cacheCoverImage => 'Cover image cache'; + + @override + String get cacheCoverImageDesc => + 'Downloaded album and track cover art. Will re-download when viewed.'; + + @override + String get cacheLibraryCover => 'Library cover cache'; + + @override + String get cacheLibraryCoverDesc => + 'Cover art extracted from local music files. Will re-extract on next scan.'; + + @override + String get cacheExploreFeed => 'Explore feed cache'; + + @override + String get cacheExploreFeedDesc => + 'Explore tab content (new releases, trending). Will refresh on next visit.'; + + @override + String get cacheTrackLookup => 'Track lookup cache'; + + @override + String get cacheTrackLookupDesc => + 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'; + + @override + String get cacheCleanupUnusedDesc => + 'Remove orphaned download history and library entries for missing files.'; + + @override + String get cacheNoData => 'No cached data'; + + @override + String cacheSizeWithFiles(String size, int count) { + return '$size in $count files'; + } + + @override + String cacheSizeOnly(String size) { + return '$size'; + } + + @override + String cacheEntries(int count) { + return '$count entries'; + } + + @override + String cacheClearSuccess(String target) { + return 'Cleared: $target'; + } + + @override + String get cacheClearConfirmTitle => 'Clear cache?'; + + @override + String cacheClearConfirmMessage(String target) { + return 'This will clear cached data for $target. Downloaded music files will not be deleted.'; + } + + @override + String get cacheClearAllConfirmTitle => 'Clear all cache?'; + + @override + String get cacheClearAllConfirmMessage => + 'This will clear all cache categories on this page. Downloaded music files will not be deleted.'; + + @override + String get cacheClearAll => 'Clear all cache'; + + @override + String get cacheCleanupUnused => 'Cleanup unused data'; + + @override + String get cacheCleanupUnusedSubtitle => + 'Remove orphaned download history and missing library entries'; + + @override + String cacheCleanupResult(int downloadCount, int libraryCount) { + return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries'; + } + + @override + String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + + @override + String get trackReEnrich => 'Re-enrich'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } + + @override + String get trackConvertFormat => 'Convert Format'; + + @override + String get trackConvertFormatSubtitle => + 'Convert to MP3, Opus, ALAC, or FLAC'; + + @override + String get trackConvertTitle => 'Convert Audio'; + + @override + String get trackConvertTargetFormat => 'Target Format'; + + @override + String get trackConvertBitrate => 'Bitrate'; + + @override + String get trackConvertConfirmTitle => 'Confirm Conversion'; + + @override + String trackConvertConfirmMessage( + String sourceFormat, + String targetFormat, + String bitrate, + ) { + return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; + } + + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + + @override + String get trackConvertConverting => 'Converting audio...'; + + @override + String trackConvertSuccess(String format) { + return 'Converted to $format successfully'; + } + + @override + String get trackConvertFailed => 'Conversion failed'; + + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; + + @override + String get lyricsProvidersTitle => 'Lyrics Providers'; + + @override + String get lyricsProvidersDescription => + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'; + + @override + String get lyricsProvidersInfoText => + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'; + + @override + String lyricsProvidersEnabledSection(int count) { + return 'Enabled ($count)'; + } + + @override + String lyricsProvidersDisabledSection(int count) { + return 'Disabled ($count)'; + } + + @override + String get lyricsProvidersAtLeastOne => + 'At least one provider must remain enabled'; + + @override + String get lyricsProvidersSaved => 'Lyrics provider priority saved'; + + @override + String get lyricsProvidersDiscardContent => + 'You have unsaved changes that will be lost.'; + + @override + String get lyricsProviderSpotifyApiDesc => + 'Spotify-sourced synced lyrics via community API'; + + @override + String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; + + @override + String get lyricsProviderNeteaseDesc => + 'NetEase Cloud Music (good for Asian songs)'; + + @override + String get lyricsProviderMusixmatchDesc => + 'Largest lyrics database (multi-language)'; + + @override + String get lyricsProviderAppleMusicDesc => + 'Word-by-word synced lyrics (via proxy)'; + + @override + String get lyricsProviderQqMusicDesc => + 'QQ Music (good for Chinese songs, via proxy)'; + + @override + String get lyricsProviderExtensionDesc => 'Extension provider'; + + @override + String get safMigrationTitle => 'Storage Update Required'; + + @override + String get safMigrationMessage1 => + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'; + + @override + String get safMigrationMessage2 => + 'Please select your download folder again to switch to the new storage system.'; + + @override + String get safMigrationSuccess => 'Download folder updated to SAF mode'; + + @override + String get settingsDonate => 'Donate'; + + @override + String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development'; + + @override + String get tooltipLoveAll => 'Love All'; + + @override + String get tooltipAddToPlaylist => 'Add to Playlist'; + + @override + String snackbarRemovedTracksFromLoved(int count) { + return 'Removed $count tracks from Loved'; + } + + @override + String snackbarAddedTracksToLoved(int count) { + return 'Added $count tracks to Loved'; + } + + @override + String get dialogDownloadAllTitle => 'Download All'; + + @override + String dialogDownloadAllMessage(int count) { + return 'Download $count tracks?'; + } + + @override + String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; + + @override + String get homeGoToAlbum => 'Go to Album'; + + @override + String get homeAlbumInfoUnavailable => 'Album info not available'; + + @override + String get snackbarLoadingCueSheet => 'Loading CUE sheet...'; + + @override + String get snackbarMetadataSaved => 'Metadata saved successfully'; + + @override + String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics'; + + @override + String get snackbarFailedToWriteStorage => 'Failed to write back to storage'; + + @override + String snackbarError(String error) { + return 'Error: $error'; + } + + @override + String get snackbarNoActionDefined => 'No action defined for this button'; + + @override + String get noTracksFoundForAlbum => 'No tracks found for this album'; + + @override + String get downloadLocationSubtitle => + 'Choose storage mode for downloaded files.'; + + @override + String get storageModeAppFolder => 'App folder (non-SAF)'; + + @override + String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path'; + + @override + String get storageModeSaf => 'SAF folder'; + + @override + String get storageModeSafSubtitle => + 'Pick folder via Android Storage Access Framework'; + + @override + String get downloadFilenameDescription => + 'Customize how your files are named.'; + + @override + String get downloadFilenameInsertTag => 'Tap to insert tag:'; + + @override + String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders'; + + @override + String get downloadSeparateSinglesDisabled => 'All files in same structure'; + + @override + String get downloadArtistNameFilters => 'Artist Name Filters'; + + @override + String get downloadCreatePlaylistSourceFolder => + 'Create playlist source folder'; + + @override + String get downloadCreatePlaylistSourceFolderEnabled => + 'Playlist downloads use Playlist/ plus your normal folder structure.'; + + @override + String get downloadCreatePlaylistSourceFolderDisabled => + 'Playlist downloads use the normal folder structure only.'; + + @override + String get downloadCreatePlaylistSourceFolderRedundant => + 'By Playlist already places downloads inside a playlist folder.'; + + @override + String get downloadSongLinkRegion => 'SongLink Region'; + + @override + String get downloadNetworkCompatibilityMode => 'Network compatibility mode'; + + @override + String get downloadNetworkCompatibilityModeEnabled => + 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'; + + @override + String get downloadNetworkCompatibilityModeDisabled => + 'Off: strict HTTPS certificate validation (recommended)'; + + @override + String get downloadSelectServiceToEnable => + 'Select a built-in service to enable'; + + @override + String get downloadSelectTidalQobuz => + 'Select Tidal or Qobuz above to configure quality'; + + @override + String get downloadEmbedLyricsDisabled => + 'Disabled while Embed Metadata is turned off'; + + @override + String get downloadNeteaseIncludeTranslation => + 'Netease: Include Translation'; + + @override + String get downloadNeteaseIncludeTranslationEnabled => + 'Append translated lyrics when available'; + + @override + String get downloadNeteaseIncludeTranslationDisabled => + 'Use original lyrics only'; + + @override + String get downloadNeteaseIncludeRomanization => + 'Netease: Include Romanization'; + + @override + String get downloadNeteaseIncludeRomanizationEnabled => + 'Append romanized lyrics when available'; + + @override + String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled'; + + @override + String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word'; + + @override + String get downloadAppleQqMultiPersonEnabled => + 'Enable v1/v2 speaker and [bg:] tags'; + + @override + String get downloadAppleQqMultiPersonDisabled => + 'Simplified word-by-word formatting'; + + @override + String get downloadMusixmatchLanguage => 'Musixmatch Language'; + + @override + String get downloadMusixmatchLanguageAuto => 'Auto (original)'; + + @override + String get downloadFilterContributing => + 'Filter contributing artists in Album Artist'; + + @override + String get downloadFilterContributingEnabled => + 'Album Artist metadata uses primary artist only'; + + @override + String get downloadFilterContributingDisabled => + 'Keep full Album Artist metadata value'; + + @override + String get downloadProvidersNoneEnabled => 'None enabled'; + + @override + String get downloadMusixmatchLanguageCode => 'Language code'; + + @override + String get downloadMusixmatchLanguageHint => 'auto / en / es / ja'; + + @override + String get downloadMusixmatchLanguageDesc => + 'Set preferred language code (example: en, es, ja). Leave empty for auto.'; + + @override + String get downloadMusixmatchAuto => 'Auto'; + + @override + String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; + + @override + String get downloadNetworkWifiOnlySubtitle => + 'Pause downloads on mobile data'; + + @override + String get downloadSongLinkRegionDesc => + 'Used as userCountry for SongLink API lookup.'; + + @override + String get snackbarUnsupportedAudioFormat => 'Unsupported audio format'; + + @override + String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; +} + +/// The translations for Chinese, as used in China (`zh_CN`). +class AppLocalizationsZhCn extends AppLocalizationsZh { + AppLocalizationsZhCn() : super('zh_CN'); + + @override + String get appName => 'SpotiFLAC'; + + @override + String get navHome => 'Home'; + + @override + String get navLibrary => 'Library'; + + @override + String get navSettings => 'Settings'; + + @override + String get navStore => 'Store'; + + @override + String get homeTitle => 'Home'; + + @override + String get homeSubtitle => 'Paste a Spotify link or search by name'; + + @override + String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; + + @override + String get homeRecent => 'Recent'; + + @override + String get historyFilterAll => 'All'; + + @override + String get historyFilterAlbums => 'Albums'; + + @override + String get historyFilterSingles => 'Singles'; + + @override + String get historySearchHint => 'Search history...'; + + @override + String get settingsTitle => 'Settings'; + + @override + String get settingsDownload => 'Download'; + + @override + String get settingsAppearance => 'Appearance'; + + @override + String get settingsOptions => 'Options'; + + @override + String get settingsExtensions => 'Extensions'; + + @override + String get settingsAbout => 'About'; + + @override + String get downloadTitle => 'Download'; + + @override + String get downloadAskQualitySubtitle => + 'Show quality picker for each download'; + + @override + String get downloadFilenameFormat => 'Filename Format'; + + @override + String get downloadFolderOrganization => 'Folder Organization'; + + @override + String get appearanceTitle => 'Appearance'; + + @override + String get appearanceThemeSystem => 'System'; + + @override + String get appearanceThemeLight => 'Light'; + + @override + String get appearanceThemeDark => 'Dark'; + + @override + String get appearanceDynamicColor => 'Dynamic Color'; + + @override + String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + + @override + String get appearanceHistoryView => 'History View'; + + @override + String get appearanceHistoryViewList => 'List'; + + @override + String get appearanceHistoryViewGrid => 'Grid'; + + @override + String get optionsTitle => 'Options'; + + @override + String get optionsPrimaryProvider => 'Primary Provider'; + + @override + String get optionsPrimaryProviderSubtitle => + 'Service used when searching by track name.'; + + @override + String optionsUsingExtension(String extensionName) { + return 'Using extension: $extensionName'; + } + + @override + String get optionsSwitchBack => + 'Tap Deezer or Spotify to switch back from extension'; + + @override + String get optionsAutoFallback => 'Auto Fallback'; + + @override + String get optionsAutoFallbackSubtitle => + 'Try other services if download fails'; + + @override + String get optionsUseExtensionProviders => 'Use Extension Providers'; + + @override + String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; + + @override + String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + + @override + String get optionsEmbedLyrics => 'Embed Lyrics'; + + @override + String get optionsEmbedLyricsSubtitle => + 'Embed synced lyrics into FLAC files'; + + @override + String get optionsMaxQualityCover => 'Max Quality Cover'; + + @override + String get optionsMaxQualityCoverSubtitle => + 'Download highest resolution cover art'; + + @override + String get optionsConcurrentDownloads => 'Concurrent Downloads'; + + @override + String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + + @override + String optionsConcurrentParallel(int count) { + return '$count parallel downloads'; + } + + @override + String get optionsConcurrentWarning => + 'Parallel downloads may trigger rate limiting'; + + @override + String get optionsExtensionStore => 'Extension Store'; + + @override + String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + + @override + String get optionsCheckUpdates => 'Check for Updates'; + + @override + String get optionsCheckUpdatesSubtitle => + 'Notify when new version is available'; + + @override + String get optionsUpdateChannel => 'Update Channel'; + + @override + String get optionsUpdateChannelStable => 'Stable releases only'; + + @override + String get optionsUpdateChannelPreview => 'Get preview releases'; + + @override + String get optionsUpdateChannelWarning => + 'Preview may contain bugs or incomplete features'; + + @override + String get optionsClearHistory => 'Clear Download History'; + + @override + String get optionsClearHistorySubtitle => + 'Remove all downloaded tracks from history'; + + @override + String get optionsDetailedLogging => 'Detailed Logging'; + + @override + String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + + @override + String get optionsDetailedLoggingOff => 'Enable for bug reports'; + + @override + String get optionsSpotifyCredentials => 'Spotify Credentials'; + + @override + String optionsSpotifyCredentialsConfigured(String clientId) { + return 'Client ID: $clientId...'; + } + + @override + String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + + @override + String get optionsSpotifyWarning => + 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + + @override + String get optionsSpotifyDeprecationWarning => + 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'; + + @override + String get extensionsTitle => 'Extensions'; + + @override + String get extensionsDisabled => 'Disabled'; + + @override + String extensionsVersion(String version) { + return 'Version $version'; + } + + @override + String extensionsAuthor(String author) { + return 'by $author'; + } + + @override + String get extensionsUninstall => 'Uninstall'; + + @override + String get storeTitle => 'Extension Store'; + + @override + String get storeSearch => 'Search extensions...'; + + @override + String get storeInstall => 'Install'; + + @override + String get storeInstalled => 'Installed'; + + @override + String get storeUpdate => 'Update'; + + @override + String get aboutTitle => 'About'; + + @override + String get aboutContributors => 'Contributors'; + + @override + String get aboutMobileDeveloper => 'Mobile version developer'; + + @override + String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + + @override + String get aboutLogoArtist => + 'The talented artist who created our beautiful app logo!'; + + @override + String get aboutTranslators => 'Translators'; + + @override + String get aboutSpecialThanks => 'Special Thanks'; + + @override + String get aboutLinks => 'Links'; + + @override + String get aboutMobileSource => 'Mobile source code'; + + @override + String get aboutPCSource => 'PC source code'; + + @override + String get aboutReportIssue => 'Report an issue'; + + @override + String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + + @override + String get aboutFeatureRequest => 'Feature request'; + + @override + String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + + @override + String get aboutApp => 'App'; + + @override + String get aboutVersion => 'Version'; + + @override + String get aboutBinimumDesc => + 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + + @override + 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 aboutDabMusic => 'DAB Music'; + + @override + String get aboutDabMusicDesc => + 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + + @override + String get aboutSpotiSaver => 'SpotiSaver'; + + @override + String get aboutSpotiSaverDesc => + 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'; + + @override + String get aboutAppDescription => + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + + @override + String get artistAlbums => 'Albums'; + + @override + String get artistSingles => 'Singles & EPs'; + + @override + String get artistCompilations => 'Compilations'; + + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + + @override + String get trackMetadataService => 'Service'; + + @override + String get trackMetadataPlay => 'Play'; + + @override + String get trackMetadataShare => 'Share'; + + @override + String get trackMetadataDelete => 'Delete'; + + @override + String get setupGrantPermission => 'Grant Permission'; + + @override + String get setupSkip => 'Skip for now'; + + @override + String get setupStorageAccessRequired => 'Storage Access Required'; + + @override + String get setupStorageAccessMessageAndroid11 => + 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; + + @override + String get setupOpenSettings => 'Open Settings'; + + @override + String get setupPermissionDeniedMessage => + 'Permission denied. Please grant all permissions to continue.'; + + @override + String setupPermissionRequired(String permissionType) { + return '$permissionType Permission Required'; + } + + @override + String setupPermissionRequiredMessage(String permissionType) { + return '$permissionType permission is required for the best experience. You can change this later in Settings.'; + } + + @override + String get setupUseDefaultFolder => 'Use Default Folder?'; + + @override + String get setupNoFolderSelected => + 'No folder selected. Would you like to use the default Music folder?'; + + @override + String get setupUseDefault => 'Use Default'; + + @override + String get setupDownloadLocationTitle => 'Download Location'; + + @override + String get setupDownloadLocationIosMessage => + 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; + + @override + String get setupAppDocumentsFolder => 'App Documents Folder'; + + @override + String get setupAppDocumentsFolderSubtitle => + 'Recommended - accessible via Files app'; + + @override + String get setupChooseFromFiles => 'Choose from Files'; + + @override + String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; + + @override + 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'; + + @override + String get setupStorageGranted => 'Storage Permission Granted!'; + + @override + String get setupStorageRequired => 'Storage Permission Required'; + + @override + String get setupStorageDescription => + 'SpotiFLAC needs storage permission to save your downloaded music files.'; + + @override + String get setupNotificationGranted => 'Notification Permission Granted!'; + + @override + String get setupNotificationEnable => 'Enable Notifications'; + + @override + String get setupFolderChoose => 'Choose Download Folder'; + + @override + String get setupFolderDescription => + 'Select a folder where your downloaded music will be saved.'; + + @override + String get setupSelectFolder => 'Select Folder'; + + @override + String get setupEnableNotifications => 'Enable Notifications'; + + @override + String get setupNotificationBackgroundDescription => + 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; + + @override + String get setupSkipForNow => 'Skip for now'; + + @override + String get setupNext => 'Next'; + + @override + String get setupGetStarted => 'Get Started'; + + @override + String get setupAllowAccessToManageFiles => + 'Please enable \"Allow access to manage all files\" in the next screen.'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogSave => 'Save'; + + @override + String get dialogDelete => 'Delete'; + + @override + String get dialogRetry => 'Retry'; + + @override + String get dialogClear => 'Clear'; + + @override + String get dialogDone => 'Done'; + + @override + String get dialogImport => 'Import'; + @override String get dialogDiscard => 'Discard'; @@ -1809,7 +4770,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; @override String get tutorialWelcomeTip3 => @@ -2344,2117 +5305,6 @@ class AppLocalizationsZh extends AppLocalizations { 'Artist folders use Track Artist only'; } -/// The translations for Chinese, as used in China (`zh_CN`). -class AppLocalizationsZhCn extends AppLocalizationsZh { - AppLocalizationsZhCn() : super('zh_CN'); - - @override - String get appName => 'SpotiFLAC'; - - @override - String get navHome => 'Home'; - - @override - String get navLibrary => 'Library'; - - @override - String get navSettings => 'Settings'; - - @override - String get navStore => 'Store'; - - @override - String get homeTitle => 'Home'; - - @override - String get homeSubtitle => 'Paste a Spotify link or search by name'; - - @override - String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; - - @override - String get homeRecent => 'Recent'; - - @override - String get historyFilterAll => 'All'; - - @override - String get historyFilterAlbums => 'Albums'; - - @override - String get historyFilterSingles => 'Singles'; - - @override - String get historySearchHint => 'Search history...'; - - @override - String get settingsTitle => 'Settings'; - - @override - String get settingsDownload => 'Download'; - - @override - String get settingsAppearance => 'Appearance'; - - @override - String get settingsOptions => 'Options'; - - @override - String get settingsExtensions => 'Extensions'; - - @override - String get settingsAbout => 'About'; - - @override - String get downloadTitle => 'Download'; - - @override - String get downloadAskQualitySubtitle => - 'Show quality picker for each download'; - - @override - String get downloadFilenameFormat => 'Filename Format'; - - @override - String get downloadFolderOrganization => 'Folder Organization'; - - @override - String get appearanceTitle => 'Appearance'; - - @override - String get appearanceThemeSystem => 'System'; - - @override - String get appearanceThemeLight => 'Light'; - - @override - String get appearanceThemeDark => 'Dark'; - - @override - String get appearanceDynamicColor => 'Dynamic Color'; - - @override - String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - - @override - String get appearanceHistoryView => 'History View'; - - @override - String get appearanceHistoryViewList => 'List'; - - @override - String get appearanceHistoryViewGrid => 'Grid'; - - @override - String get optionsTitle => 'Options'; - - @override - String get optionsPrimaryProvider => 'Primary Provider'; - - @override - String get optionsPrimaryProviderSubtitle => - 'Service used when searching by track name.'; - - @override - String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; - } - - @override - String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; - - @override - String get optionsAutoFallback => 'Auto Fallback'; - - @override - String get optionsAutoFallbackSubtitle => - 'Try other services if download fails'; - - @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; - - @override - String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; - - @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; - - @override - String get optionsEmbedLyrics => 'Embed Lyrics'; - - @override - String get optionsEmbedLyricsSubtitle => - 'Embed synced lyrics into FLAC files'; - - @override - String get optionsMaxQualityCover => 'Max Quality Cover'; - - @override - String get optionsMaxQualityCoverSubtitle => - 'Download highest resolution cover art'; - - @override - String get optionsConcurrentDownloads => 'Concurrent Downloads'; - - @override - String get optionsConcurrentSequential => 'Sequential (1 at a time)'; - - @override - String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; - } - - @override - String get optionsConcurrentWarning => - 'Parallel downloads may trigger rate limiting'; - - @override - String get optionsExtensionStore => 'Extension Store'; - - @override - String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; - - @override - String get optionsCheckUpdates => 'Check for Updates'; - - @override - String get optionsCheckUpdatesSubtitle => - 'Notify when new version is available'; - - @override - String get optionsUpdateChannel => 'Update Channel'; - - @override - String get optionsUpdateChannelStable => 'Stable releases only'; - - @override - String get optionsUpdateChannelPreview => 'Get preview releases'; - - @override - String get optionsUpdateChannelWarning => - 'Preview may contain bugs or incomplete features'; - - @override - String get optionsClearHistory => 'Clear Download History'; - - @override - String get optionsClearHistorySubtitle => - 'Remove all downloaded tracks from history'; - - @override - String get optionsDetailedLogging => 'Detailed Logging'; - - @override - String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; - - @override - String get optionsDetailedLoggingOff => 'Enable for bug reports'; - - @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; - - @override - String optionsSpotifyCredentialsConfigured(String clientId) { - return 'Client ID: $clientId...'; - } - - @override - String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; - - @override - String get optionsSpotifyWarning => - 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; - - @override - String get optionsSpotifyDeprecationWarning => - 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'; - - @override - String get extensionsTitle => 'Extensions'; - - @override - String get extensionsDisabled => 'Disabled'; - - @override - String extensionsVersion(String version) { - return 'Version $version'; - } - - @override - String extensionsAuthor(String author) { - return 'by $author'; - } - - @override - String get extensionsUninstall => 'Uninstall'; - - @override - String get storeTitle => 'Extension Store'; - - @override - String get storeSearch => 'Search extensions...'; - - @override - String get storeInstall => 'Install'; - - @override - String get storeInstalled => 'Installed'; - - @override - String get storeUpdate => 'Update'; - - @override - String get aboutTitle => 'About'; - - @override - String get aboutContributors => 'Contributors'; - - @override - String get aboutMobileDeveloper => 'Mobile version developer'; - - @override - String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; - - @override - String get aboutLogoArtist => - 'The talented artist who created our beautiful app logo!'; - - @override - String get aboutTranslators => 'Translators'; - - @override - String get aboutSpecialThanks => 'Special Thanks'; - - @override - String get aboutLinks => 'Links'; - - @override - String get aboutMobileSource => 'Mobile source code'; - - @override - String get aboutPCSource => 'PC source code'; - - @override - String get aboutReportIssue => 'Report an issue'; - - @override - String get aboutReportIssueSubtitle => 'Report any problems you encounter'; - - @override - String get aboutFeatureRequest => 'Feature request'; - - @override - String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; - - @override - String get aboutTelegramChannel => 'Telegram Channel'; - - @override - String get aboutTelegramChannelSubtitle => 'Announcements and updates'; - - @override - String get aboutTelegramChat => 'Telegram Community'; - - @override - String get aboutTelegramChatSubtitle => 'Chat with other users'; - - @override - String get aboutSocial => 'Social'; - - @override - String get aboutApp => 'App'; - - @override - String get aboutVersion => 'Version'; - - @override - String get aboutBinimumDesc => - 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; - - @override - 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 aboutDabMusic => 'DAB Music'; - - @override - String get aboutDabMusicDesc => - 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; - - @override - String get aboutSpotiSaver => 'SpotiSaver'; - - @override - String get aboutSpotiSaverDesc => - 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'; - - @override - String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; - - @override - String get artistAlbums => 'Albums'; - - @override - String get artistSingles => 'Singles & EPs'; - - @override - String get artistCompilations => 'Compilations'; - - @override - String get artistPopular => 'Popular'; - - @override - String artistMonthlyListeners(String count) { - return '$count monthly listeners'; - } - - @override - String get trackMetadataService => 'Service'; - - @override - String get trackMetadataPlay => 'Play'; - - @override - String get trackMetadataShare => 'Share'; - - @override - String get trackMetadataDelete => 'Delete'; - - @override - String get setupGrantPermission => 'Grant Permission'; - - @override - String get setupSkip => 'Skip for now'; - - @override - String get setupStorageAccessRequired => 'Storage Access Required'; - - @override - String get setupStorageAccessMessageAndroid11 => - 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; - - @override - String get setupOpenSettings => 'Open Settings'; - - @override - String get setupPermissionDeniedMessage => - 'Permission denied. Please grant all permissions to continue.'; - - @override - String setupPermissionRequired(String permissionType) { - return '$permissionType Permission Required'; - } - - @override - String setupPermissionRequiredMessage(String permissionType) { - return '$permissionType permission is required for the best experience. You can change this later in Settings.'; - } - - @override - String get setupUseDefaultFolder => 'Use Default Folder?'; - - @override - String get setupNoFolderSelected => - 'No folder selected. Would you like to use the default Music folder?'; - - @override - String get setupUseDefault => 'Use Default'; - - @override - String get setupDownloadLocationTitle => 'Download Location'; - - @override - String get setupDownloadLocationIosMessage => - 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; - - @override - String get setupAppDocumentsFolder => 'App Documents Folder'; - - @override - String get setupAppDocumentsFolderSubtitle => - 'Recommended - accessible via Files app'; - - @override - String get setupChooseFromFiles => 'Choose from Files'; - - @override - String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; - - @override - 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'; - - @override - String get setupStorageGranted => 'Storage Permission Granted!'; - - @override - String get setupStorageRequired => 'Storage Permission Required'; - - @override - String get setupStorageDescription => - 'SpotiFLAC needs storage permission to save your downloaded music files.'; - - @override - String get setupNotificationGranted => 'Notification Permission Granted!'; - - @override - String get setupNotificationEnable => 'Enable Notifications'; - - @override - String get setupFolderChoose => 'Choose Download Folder'; - - @override - String get setupFolderDescription => - 'Select a folder where your downloaded music will be saved.'; - - @override - String get setupSelectFolder => 'Select Folder'; - - @override - String get setupEnableNotifications => 'Enable Notifications'; - - @override - String get setupNotificationBackgroundDescription => - 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; - - @override - String get setupSkipForNow => 'Skip for now'; - - @override - String get setupNext => 'Next'; - - @override - String get setupGetStarted => 'Get Started'; - - @override - String get setupAllowAccessToManageFiles => - 'Please enable \"Allow access to manage all files\" in the next screen.'; - - @override - String get dialogCancel => 'Cancel'; - - @override - String get dialogSave => 'Save'; - - @override - String get dialogDelete => 'Delete'; - - @override - String get dialogRetry => 'Retry'; - - @override - String get dialogClear => 'Clear'; - - @override - String get dialogDone => 'Done'; - - @override - String get dialogImport => 'Import'; - - @override - String get dialogDiscard => 'Discard'; - - @override - String get dialogRemove => 'Remove'; - - @override - String get dialogUninstall => 'Uninstall'; - - @override - String get dialogDiscardChanges => 'Discard Changes?'; - - @override - String get dialogUnsavedChanges => - 'You have unsaved changes. Do you want to discard them?'; - - @override - String get dialogClearAll => 'Clear All'; - - @override - String get dialogRemoveExtension => 'Remove Extension'; - - @override - String get dialogRemoveExtensionMessage => - 'Are you sure you want to remove this extension? This cannot be undone.'; - - @override - String get dialogUninstallExtension => 'Uninstall Extension?'; - - @override - String dialogUninstallExtensionMessage(String extensionName) { - return 'Are you sure you want to remove $extensionName?'; - } - - @override - String get dialogClearHistoryTitle => 'Clear History'; - - @override - String get dialogClearHistoryMessage => - 'Are you sure you want to clear all download history? This cannot be undone.'; - - @override - String get dialogDeleteSelectedTitle => 'Delete Selected'; - - @override - String dialogDeleteSelectedMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; - } - - @override - String get dialogImportPlaylistTitle => 'Import Playlist'; - - @override - String dialogImportPlaylistMessage(int count) { - return 'Found $count tracks in CSV. Add them to download queue?'; - } - - @override - String csvImportTracks(int count) { - return '$count tracks from CSV'; - } - - @override - String snackbarAddedToQueue(String trackName) { - return 'Added \"$trackName\" to queue'; - } - - @override - String snackbarAddedTracksToQueue(int count) { - return 'Added $count tracks to queue'; - } - - @override - String snackbarAlreadyDownloaded(String trackName) { - return '\"$trackName\" already downloaded'; - } - - @override - String snackbarAlreadyInLibrary(String trackName) { - return '\"$trackName\" already exists in your library'; - } - - @override - String get snackbarHistoryCleared => 'History cleared'; - - @override - String get snackbarCredentialsSaved => 'Credentials saved'; - - @override - String get snackbarCredentialsCleared => 'Credentials cleared'; - - @override - String snackbarDeletedTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Deleted $count $_temp0'; - } - - @override - String snackbarCannotOpenFile(String error) { - return 'Cannot open file: $error'; - } - - @override - String get snackbarFillAllFields => 'Please fill all fields'; - - @override - String get snackbarViewQueue => 'View Queue'; - - @override - String snackbarUrlCopied(String platform) { - return '$platform URL copied to clipboard'; - } - - @override - String get snackbarFileNotFound => 'File not found'; - - @override - String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; - - @override - String get snackbarProviderPrioritySaved => 'Provider priority saved'; - - @override - String get snackbarMetadataProviderSaved => - 'Metadata provider priority saved'; - - @override - String snackbarExtensionInstalled(String extensionName) { - return '$extensionName installed.'; - } - - @override - String snackbarExtensionUpdated(String extensionName) { - return '$extensionName updated.'; - } - - @override - String get snackbarFailedToInstall => 'Failed to install extension'; - - @override - String get snackbarFailedToUpdate => 'Failed to update extension'; - - @override - String get errorRateLimited => 'Rate Limited'; - - @override - String get errorRateLimitedMessage => - 'Too many requests. Please wait a moment before searching again.'; - - @override - String get errorNoTracksFound => 'No tracks found'; - - @override - String errorMissingExtensionSource(String item) { - return 'Cannot load $item: missing extension source'; - } - - @override - String get actionPause => 'Pause'; - - @override - String get actionResume => 'Resume'; - - @override - String get actionCancel => 'Cancel'; - - @override - String get actionSelectAll => 'Select All'; - - @override - String get actionDeselect => 'Deselect'; - - @override - String get actionRemoveCredentials => 'Remove Credentials'; - - @override - String get actionSaveCredentials => 'Save Credentials'; - - @override - String selectionSelected(int count) { - return '$count selected'; - } - - @override - String get selectionAllSelected => 'All tracks selected'; - - @override - String get selectionSelectToDelete => 'Select tracks to delete'; - - @override - String progressFetchingMetadata(int current, int total) { - return 'Fetching metadata... $current/$total'; - } - - @override - String get progressReadingCsv => 'Reading CSV...'; - - @override - String get searchSongs => 'Songs'; - - @override - String get searchArtists => 'Artists'; - - @override - String get searchAlbums => 'Albums'; - - @override - String get searchPlaylists => 'Playlists'; - - @override - String get tooltipPlay => 'Play'; - - @override - String get filenameFormat => 'Filename Format'; - - @override - String get folderOrganizationNone => 'No organization'; - - @override - String get folderOrganizationByArtist => 'By Artist'; - - @override - String get folderOrganizationByAlbum => 'By Album'; - - @override - String get folderOrganizationByArtistAlbum => 'Artist/Album'; - - @override - String get folderOrganizationDescription => - 'Organize downloaded files into folders'; - - @override - String get folderOrganizationNoneSubtitle => 'All files in download folder'; - - @override - String get folderOrganizationByArtistSubtitle => - 'Separate folder for each artist'; - - @override - String get folderOrganizationByAlbumSubtitle => - 'Separate folder for each album'; - - @override - String get folderOrganizationByArtistAlbumSubtitle => - 'Nested folders for artist and album'; - - @override - String get updateAvailable => 'Update Available'; - - @override - String get updateLater => 'Later'; - - @override - String get updateStartingDownload => 'Starting download...'; - - @override - String get updateDownloadFailed => 'Download failed'; - - @override - String get updateFailedMessage => 'Failed to download update'; - - @override - String get updateNewVersionReady => 'A new version is ready'; - - @override - String get updateCurrent => 'Current'; - - @override - String get updateNew => 'New'; - - @override - String get updateDownloading => 'Downloading...'; - - @override - String get updateWhatsNew => 'What\'s New'; - - @override - String get updateDownloadInstall => 'Download & Install'; - - @override - String get updateDontRemind => 'Don\'t remind'; - - @override - String get providerPriorityTitle => 'Provider Priority'; - - @override - String get providerPriorityDescription => - 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; - - @override - String get providerPriorityInfo => - 'If a track is not available on the first provider, the app will automatically try the next one.'; - - @override - String get providerBuiltIn => 'Built-in'; - - @override - String get providerExtension => 'Extension'; - - @override - String get metadataProviderPriorityTitle => 'Metadata Priority'; - - @override - String get metadataProviderPriorityDescription => - 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; - - @override - String get metadataProviderPriorityInfo => - 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; - - @override - String get metadataNoRateLimits => 'No rate limits'; - - @override - String get metadataMayRateLimit => 'May rate limit'; - - @override - String get logTitle => 'Logs'; - - @override - String get logCopied => 'Logs copied to clipboard'; - - @override - String get logSearchHint => 'Search logs...'; - - @override - String get logFilterLevel => 'Level'; - - @override - String get logFilterSection => 'Filter'; - - @override - String get logShareLogs => 'Share logs'; - - @override - String get logClearLogs => 'Clear logs'; - - @override - String get logClearLogsTitle => 'Clear Logs'; - - @override - String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - - @override - String get logFilterBySeverity => 'Filter logs by severity'; - - @override - String get logNoLogsYet => 'No logs yet'; - - @override - String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - - @override - String logEntriesFiltered(int count) { - return 'Entries ($count filtered)'; - } - - @override - String logEntries(int count) { - return 'Entries ($count)'; - } - - @override - String get credentialsTitle => 'Spotify Credentials'; - - @override - String get credentialsDescription => - 'Enter your Client ID and Secret to use your own Spotify application quota.'; - - @override - String get credentialsClientId => 'Client ID'; - - @override - String get credentialsClientIdHint => 'Paste Client ID'; - - @override - String get credentialsClientSecret => 'Client Secret'; - - @override - String get credentialsClientSecretHint => 'Paste Client Secret'; - - @override - String get channelStable => 'Stable'; - - @override - String get channelPreview => 'Preview'; - - @override - String get sectionSearchSource => 'Search Source'; - - @override - String get sectionDownload => 'Download'; - - @override - String get sectionPerformance => 'Performance'; - - @override - String get sectionApp => 'App'; - - @override - String get sectionData => 'Data'; - - @override - String get sectionDebug => 'Debug'; - - @override - String get sectionService => 'Service'; - - @override - String get sectionAudioQuality => 'Audio Quality'; - - @override - String get sectionFileSettings => 'File Settings'; - - @override - String get sectionLyrics => 'Lyrics'; - - @override - String get lyricsMode => 'Lyrics Mode'; - - @override - String get lyricsModeDescription => - 'Choose how lyrics are saved with your downloads'; - - @override - String get lyricsModeEmbed => 'Embed in file'; - - @override - String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; - - @override - String get lyricsModeExternal => 'External .lrc file'; - - @override - String get lyricsModeExternalSubtitle => - 'Separate .lrc file for players like Samsung Music'; - - @override - String get lyricsModeBoth => 'Both'; - - @override - String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; - - @override - String get sectionColor => 'Color'; - - @override - String get sectionTheme => 'Theme'; - - @override - String get sectionLayout => 'Layout'; - - @override - String get sectionLanguage => 'Language'; - - @override - String get appearanceLanguage => 'App Language'; - - @override - String get settingsAppearanceSubtitle => 'Theme, colors, display'; - - @override - String get settingsDownloadSubtitle => 'Service, quality, filename format'; - - @override - String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; - - @override - String get settingsExtensionsSubtitle => 'Manage download providers'; - - @override - String get settingsLogsSubtitle => 'View app logs for debugging'; - - @override - String get loadingSharedLink => 'Loading shared link...'; - - @override - String get pressBackAgainToExit => 'Press back again to exit'; - - @override - String downloadAllCount(int count) { - return 'Download All ($count)'; - } - - @override - String tracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get trackCopyFilePath => 'Copy file path'; - - @override - String get trackRemoveFromDevice => 'Remove from device'; - - @override - String get trackLoadLyrics => 'Load Lyrics'; - - @override - String get trackMetadata => 'Metadata'; - - @override - String get trackFileInfo => 'File Info'; - - @override - String get trackLyrics => 'Lyrics'; - - @override - String get trackFileNotFound => 'File not found'; - - @override - String get trackOpenInDeezer => 'Open in Deezer'; - - @override - String get trackOpenInSpotify => 'Open in Spotify'; - - @override - String get trackTrackName => 'Track name'; - - @override - String get trackArtist => 'Artist'; - - @override - String get trackAlbumArtist => 'Album artist'; - - @override - String get trackAlbum => 'Album'; - - @override - String get trackTrackNumber => 'Track number'; - - @override - String get trackDiscNumber => 'Disc number'; - - @override - String get trackDuration => 'Duration'; - - @override - String get trackAudioQuality => 'Audio quality'; - - @override - String get trackReleaseDate => 'Release date'; - - @override - String get trackGenre => 'Genre'; - - @override - String get trackLabel => 'Label'; - - @override - String get trackCopyright => 'Copyright'; - - @override - String get trackDownloaded => 'Downloaded'; - - @override - String get trackCopyLyrics => 'Copy lyrics'; - - @override - String get trackLyricsNotAvailable => 'Lyrics not available for this track'; - - @override - String get trackLyricsTimeout => 'Request timed out. Try again later.'; - - @override - String get trackLyricsLoadFailed => 'Failed to load lyrics'; - - @override - String get trackEmbedLyrics => 'Embed Lyrics'; - - @override - String get trackLyricsEmbedded => 'Lyrics embedded successfully'; - - @override - String get trackInstrumental => 'Instrumental track'; - - @override - String get trackCopiedToClipboard => 'Copied to clipboard'; - - @override - String get trackDeleteConfirmTitle => 'Remove from device?'; - - @override - String get trackDeleteConfirmMessage => - 'This will permanently delete the downloaded file and remove it from your history.'; - - @override - String get dateToday => 'Today'; - - @override - String get dateYesterday => 'Yesterday'; - - @override - String dateDaysAgo(int count) { - return '$count days ago'; - } - - @override - String dateWeeksAgo(int count) { - return '$count weeks ago'; - } - - @override - String dateMonthsAgo(int count) { - return '$count months ago'; - } - - @override - String get storeFilterAll => 'All'; - - @override - String get storeFilterMetadata => 'Metadata'; - - @override - String get storeFilterDownload => 'Download'; - - @override - String get storeFilterUtility => 'Utility'; - - @override - String get storeFilterLyrics => 'Lyrics'; - - @override - String get storeFilterIntegration => 'Integration'; - - @override - String get storeClearFilters => 'Clear filters'; - - @override - String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; - - @override - String get extensionDefaultProviderSubtitle => 'Use built-in search'; - - @override - String get extensionAuthor => 'Author'; - - @override - String get extensionId => 'ID'; - - @override - String get extensionError => 'Error'; - - @override - String get extensionCapabilities => 'Capabilities'; - - @override - String get extensionMetadataProvider => 'Metadata Provider'; - - @override - String get extensionDownloadProvider => 'Download Provider'; - - @override - String get extensionLyricsProvider => 'Lyrics Provider'; - - @override - String get extensionUrlHandler => 'URL Handler'; - - @override - String get extensionQualityOptions => 'Quality Options'; - - @override - String get extensionPostProcessingHooks => 'Post-Processing Hooks'; - - @override - String get extensionPermissions => 'Permissions'; - - @override - String get extensionSettings => 'Settings'; - - @override - String get extensionRemoveButton => 'Remove Extension'; - - @override - String get extensionUpdated => 'Updated'; - - @override - String get extensionMinAppVersion => 'Min App Version'; - - @override - String get extensionCustomTrackMatching => 'Custom Track Matching'; - - @override - String get extensionPostProcessing => 'Post-Processing'; - - @override - String extensionHooksAvailable(int count) { - return '$count hook(s) available'; - } - - @override - String extensionPatternsCount(int count) { - return '$count pattern(s)'; - } - - @override - String extensionStrategy(String strategy) { - return 'Strategy: $strategy'; - } - - @override - String get extensionsProviderPrioritySection => 'Provider Priority'; - - @override - String get extensionsInstalledSection => 'Installed Extensions'; - - @override - String get extensionsNoExtensions => 'No extensions installed'; - - @override - String get extensionsNoExtensionsSubtitle => - 'Install .spotiflac-ext files to add new providers'; - - @override - String get extensionsInstallButton => 'Install Extension'; - - @override - String get extensionsInfoTip => - 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; - - @override - String get extensionsInstalledSuccess => 'Extension installed successfully'; - - @override - String get extensionsDownloadPriority => 'Download Priority'; - - @override - String get extensionsDownloadPrioritySubtitle => 'Set download service order'; - - @override - String get extensionsNoDownloadProvider => - 'No extensions with download provider'; - - @override - String get extensionsMetadataPriority => 'Metadata Priority'; - - @override - String get extensionsMetadataPrioritySubtitle => - 'Set search & metadata source order'; - - @override - String get extensionsNoMetadataProvider => - 'No extensions with metadata provider'; - - @override - String get extensionsSearchProvider => 'Search Provider'; - - @override - String get extensionsNoCustomSearch => 'No extensions with custom search'; - - @override - String get extensionsSearchProviderDescription => - 'Choose which service to use for searching tracks'; - - @override - String get extensionsCustomSearch => 'Custom search'; - - @override - String get extensionsErrorLoading => 'Error loading extension'; - - @override - String get qualityFlacLossless => 'FLAC Lossless'; - - @override - String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; - - @override - String get qualityHiResFlac => 'Hi-Res FLAC'; - - @override - String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; - - @override - String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; - - @override - String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - - @override - String get qualityNote => - 'Actual quality depends on track availability from the service'; - - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get downloadAskBeforeDownload => 'Ask Before Download'; - - @override - String get downloadDirectory => 'Download Directory'; - - @override - String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; - - @override - String get downloadAlbumFolderStructure => 'Album Folder Structure'; - - @override - String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - - @override - String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; - - @override - String get downloadUsePrimaryArtistOnlyEnabled => - 'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'; - - @override - String get downloadUsePrimaryArtistOnlyDisabled => - 'Full artist string used for folder name'; - - @override - String get downloadSelectQuality => 'Select Quality'; - - @override - String get downloadFrom => 'Download From'; - - @override - String get appearanceAmoledDark => 'AMOLED Dark'; - - @override - String get appearanceAmoledDarkSubtitle => 'Pure black background'; - - @override - String get queueClearAll => 'Clear All'; - - @override - String get queueClearAllMessage => - 'Are you sure you want to clear all downloads?'; - - @override - String get settingsAutoExportFailed => 'Auto-export failed downloads'; - - @override - String get settingsAutoExportFailedSubtitle => - 'Save failed downloads to TXT file automatically'; - - @override - String get settingsDownloadNetwork => 'Download Network'; - - @override - String get settingsDownloadNetworkAny => 'WiFi + Mobile Data'; - - @override - String get settingsDownloadNetworkWifiOnly => 'WiFi Only'; - - @override - String get settingsDownloadNetworkSubtitle => - 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - - @override - String get albumFolderArtistAlbum => 'Artist / Album'; - - @override - String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; - - @override - String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; - - @override - String get albumFolderArtistYearAlbumSubtitle => - 'Albums/Artist Name/[2005] Album Name/'; - - @override - String get albumFolderAlbumOnly => 'Album Only'; - - @override - String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; - - @override - String get albumFolderYearAlbum => '[Year] Album'; - - @override - String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; - - @override - String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; - - @override - String get albumFolderArtistAlbumSinglesSubtitle => - 'Artist/Album/ and Artist/Singles/'; - - @override - String get downloadedAlbumDeleteSelected => 'Delete Selected'; - - @override - String downloadedAlbumDeleteMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; - } - - @override - String downloadedAlbumSelectedCount(int count) { - return '$count selected'; - } - - @override - String get downloadedAlbumAllSelected => 'All tracks selected'; - - @override - String get downloadedAlbumTapToSelect => 'Tap tracks to select'; - - @override - String downloadedAlbumDeleteCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - - @override - String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; - - @override - String downloadedAlbumDiscHeader(int discNumber) { - return 'Disc $discNumber'; - } - - @override - String get recentTypeArtist => 'Artist'; - - @override - String get recentTypeAlbum => 'Album'; - - @override - String get recentTypeSong => 'Song'; - - @override - String get recentTypePlaylist => 'Playlist'; - - @override - String get recentEmpty => 'No recent items yet'; - - @override - String get recentShowAllDownloads => 'Show All Downloads'; - - @override - String recentPlaylistInfo(String name) { - return 'Playlist: $name'; - } - - @override - String get discographyDownload => 'Download Discography'; - - @override - String get discographyDownloadAll => 'Download All'; - - @override - String discographyDownloadAllSubtitle(int count, int albumCount) { - return '$count tracks from $albumCount releases'; - } - - @override - String get discographyAlbumsOnly => 'Albums Only'; - - @override - String discographyAlbumsOnlySubtitle(int count, int albumCount) { - return '$count tracks from $albumCount albums'; - } - - @override - String get discographySinglesOnly => 'Singles & EPs Only'; - - @override - String discographySinglesOnlySubtitle(int count, int albumCount) { - return '$count tracks from $albumCount singles'; - } - - @override - String get discographySelectAlbums => 'Select Albums...'; - - @override - String get discographySelectAlbumsSubtitle => - 'Choose specific albums or singles'; - - @override - String get discographyFetchingTracks => 'Fetching tracks...'; - - @override - String discographyFetchingAlbum(int current, int total) { - return 'Fetching $current of $total...'; - } - - @override - String discographySelectedCount(int count) { - return '$count selected'; - } - - @override - String get discographyDownloadSelected => 'Download Selected'; - - @override - String discographyAddedToQueue(int count) { - return 'Added $count tracks to queue'; - } - - @override - String discographySkippedDownloaded(int added, int skipped) { - return '$added added, $skipped already downloaded'; - } - - @override - String get discographyNoAlbums => 'No albums available'; - - @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.'; - - @override - String get settingsLocalLibrary => 'Local Library'; - - @override - String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; - - @override - String get settingsCache => 'Storage & Cache'; - - @override - String get settingsCacheSubtitle => 'View size and clear cached data'; - - @override - String get libraryTitle => 'Local Library'; - - @override - String get libraryScanSettings => 'Scan Settings'; - - @override - String get libraryEnableLocalLibrary => 'Enable Local Library'; - - @override - String get libraryEnableLocalLibrarySubtitle => - 'Scan and track your existing music'; - - @override - String get libraryFolder => 'Library Folder'; - - @override - String get libraryFolderHint => 'Tap to select folder'; - - @override - String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; - - @override - String get libraryShowDuplicateIndicatorSubtitle => - 'Show when searching for existing tracks'; - - @override - String get libraryActions => 'Actions'; - - @override - String get libraryScan => 'Scan Library'; - - @override - String get libraryScanSubtitle => 'Scan for audio files'; - - @override - String get libraryScanSelectFolderFirst => 'Select a folder first'; - - @override - String get libraryCleanupMissingFiles => 'Cleanup Missing Files'; - - @override - String get libraryCleanupMissingFilesSubtitle => - 'Remove entries for files that no longer exist'; - - @override - String get libraryClear => 'Clear Library'; - - @override - String get libraryClearSubtitle => 'Remove all scanned tracks'; - - @override - String get libraryClearConfirmTitle => 'Clear Library'; - - @override - String get libraryClearConfirmMessage => - 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'; - - @override - String get libraryAbout => 'About Local Library'; - - @override - String get libraryAboutDescription => - 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - - @override - String libraryLastScanned(String time) { - return 'Last scanned: $time'; - } - - @override - String get libraryLastScannedNever => 'Never'; - - @override - String get libraryScanning => 'Scanning...'; - - @override - String libraryScanProgress(String progress, int total) { - return '$progress% of $total files'; - } - - @override - String get libraryInLibrary => 'In Library'; - - @override - String libraryRemovedMissingFiles(int count) { - return 'Removed $count missing files from library'; - } - - @override - String get libraryCleared => 'Library cleared'; - - @override - String get libraryStorageAccessRequired => 'Storage Access Required'; - - @override - String get libraryStorageAccessMessage => - 'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.'; - - @override - String get libraryFolderNotExist => 'Selected folder does not exist'; - - @override - String get librarySourceDownloaded => 'Downloaded'; - - @override - String get librarySourceLocal => 'Local'; - - @override - String get libraryFilterAll => 'All'; - - @override - String get libraryFilterDownloaded => 'Downloaded'; - - @override - String get libraryFilterLocal => 'Local'; - - @override - String get libraryFilterTitle => 'Filters'; - - @override - String get libraryFilterReset => 'Reset'; - - @override - String get libraryFilterApply => 'Apply'; - - @override - String get libraryFilterSource => 'Source'; - - @override - String get libraryFilterQuality => 'Quality'; - - @override - String get libraryFilterQualityHiRes => 'Hi-Res (24bit)'; - - @override - String get libraryFilterQualityCD => 'CD (16bit)'; - - @override - String get libraryFilterQualityLossy => 'Lossy'; - - @override - String get libraryFilterFormat => 'Format'; - - @override - String get libraryFilterSort => 'Sort'; - - @override - String get libraryFilterSortLatest => 'Latest'; - - @override - String get libraryFilterSortOldest => 'Oldest'; - - @override - String get timeJustNow => 'Just now'; - - @override - String timeMinutesAgo(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count minutes ago', - one: '1 minute ago', - ); - return '$_temp0'; - } - - @override - String timeHoursAgo(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count hours ago', - one: '1 hour ago', - ); - return '$_temp0'; - } - - @override - String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; - - @override - String get tutorialWelcomeDesc => - 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; - - @override - String get tutorialWelcomeTip1 => - 'Download music from Spotify, Deezer, or paste any supported URL'; - - @override - String get tutorialWelcomeTip2 => '从 Tidal、Qobuz 或 Deezer 获取 FLAC 品质音频'; - - @override - String get tutorialWelcomeTip3 => - 'Automatic metadata, cover art, and lyrics embedding'; - - @override - String get tutorialSearchTitle => 'Finding Music'; - - @override - String get tutorialSearchDesc => - 'There are two easy ways to find music you want to download.'; - - @override - String get tutorialDownloadTitle => 'Downloading Music'; - - @override - String get tutorialDownloadDesc => - 'Downloading music is simple and fast. Here\'s how it works.'; - - @override - String get tutorialLibraryTitle => 'Your Library'; - - @override - String get tutorialLibraryDesc => - 'All your downloaded music is organized in the Library tab.'; - - @override - String get tutorialLibraryTip1 => - 'View download progress and queue in the Library tab'; - - @override - String get tutorialLibraryTip2 => - 'Tap any track to play it with your music player'; - - @override - String get tutorialLibraryTip3 => - 'Switch between list and grid view for better browsing'; - - @override - String get tutorialExtensionsTitle => 'Extensions'; - - @override - String get tutorialExtensionsDesc => - 'Extend the app\'s capabilities with community extensions.'; - - @override - String get tutorialExtensionsTip1 => - 'Browse the Store tab to discover useful extensions'; - - @override - String get tutorialExtensionsTip2 => - 'Add new download providers or search sources'; - - @override - String get tutorialExtensionsTip3 => - 'Get lyrics, enhanced metadata, and more features'; - - @override - String get tutorialSettingsTitle => 'Customize Your Experience'; - - @override - String get tutorialSettingsDesc => - 'Personalize the app in Settings to match your preferences.'; - - @override - String get tutorialSettingsTip1 => - 'Change download location and folder organization'; - - @override - String get tutorialSettingsTip2 => - 'Set default audio quality and format preferences'; - - @override - String get tutorialSettingsTip3 => 'Customize app theme and appearance'; - - @override - String get tutorialReadyMessage => - 'You\'re all set! Start downloading your favorite music now.'; - - @override - String get libraryForceFullScan => 'Force Full Scan'; - - @override - String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache'; - - @override - String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads'; - - @override - String get cleanupOrphanedDownloadsSubtitle => - 'Remove history entries for files that no longer exist'; - - @override - String cleanupOrphanedDownloadsResult(int count) { - return 'Removed $count orphaned entries from history'; - } - - @override - String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; - - @override - String get cacheTitle => 'Storage & Cache'; - - @override - String get cacheSummaryTitle => 'Cache overview'; - - @override - String get cacheSummarySubtitle => - 'Clearing cache will not remove downloaded music files.'; - - @override - String cacheEstimatedTotal(String size) { - return 'Estimated cache usage: $size'; - } - - @override - String get cacheSectionStorage => 'Cached Data'; - - @override - String get cacheSectionMaintenance => 'Maintenance'; - - @override - String get cacheAppDirectory => 'App cache directory'; - - @override - String get cacheAppDirectoryDesc => - 'HTTP responses, WebView data, and other temporary app data.'; - - @override - String get cacheTempDirectory => 'Temporary directory'; - - @override - String get cacheTempDirectoryDesc => - 'Temporary files from downloads and audio conversion.'; - - @override - String get cacheCoverImage => 'Cover image cache'; - - @override - String get cacheCoverImageDesc => - 'Downloaded album and track cover art. Will re-download when viewed.'; - - @override - String get cacheLibraryCover => 'Library cover cache'; - - @override - String get cacheLibraryCoverDesc => - 'Cover art extracted from local music files. Will re-extract on next scan.'; - - @override - String get cacheExploreFeed => 'Explore feed cache'; - - @override - String get cacheExploreFeedDesc => - 'Explore tab content (new releases, trending). Will refresh on next visit.'; - - @override - String get cacheTrackLookup => 'Track lookup cache'; - - @override - String get cacheTrackLookupDesc => - 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'; - - @override - String get cacheCleanupUnusedDesc => - 'Remove orphaned download history and library entries for missing files.'; - - @override - String get cacheNoData => 'No cached data'; - - @override - String cacheSizeWithFiles(String size, int count) { - return '$size in $count files'; - } - - @override - String cacheSizeOnly(String size) { - return '$size'; - } - - @override - String cacheEntries(int count) { - return '$count entries'; - } - - @override - String cacheClearSuccess(String target) { - return 'Cleared: $target'; - } - - @override - String get cacheClearConfirmTitle => 'Clear cache?'; - - @override - String cacheClearConfirmMessage(String target) { - return 'This will clear cached data for $target. Downloaded music files will not be deleted.'; - } - - @override - String get cacheClearAllConfirmTitle => 'Clear all cache?'; - - @override - String get cacheClearAllConfirmMessage => - 'This will clear all cache categories on this page. Downloaded music files will not be deleted.'; - - @override - String get cacheClearAll => 'Clear all cache'; - - @override - String get cacheCleanupUnused => 'Cleanup unused data'; - - @override - String get cacheCleanupUnusedSubtitle => - 'Remove orphaned download history and missing library entries'; - - @override - String cacheCleanupResult(int downloadCount, int libraryCount) { - return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries'; - } - - @override - String get cacheRefreshStats => 'Refresh stats'; - - @override - String get trackSaveCoverArt => 'Save Cover Art'; - - @override - String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; - - @override - String get trackSaveLyrics => 'Save Lyrics (.lrc)'; - - @override - String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; - - @override - String get trackSaveLyricsProgress => 'Saving lyrics...'; - - @override - String get trackReEnrich => 'Re-enrich'; - - @override - String get trackReEnrichOnlineSubtitle => - 'Search metadata online and embed into file'; - - @override - String get trackEditMetadata => 'Edit Metadata'; - - @override - String trackCoverSaved(String fileName) { - return 'Cover art saved to $fileName'; - } - - @override - String get trackCoverNoSource => 'No cover art source available'; - - @override - String trackLyricsSaved(String fileName) { - return 'Lyrics saved to $fileName'; - } - - @override - String get trackReEnrichProgress => 'Re-enriching metadata...'; - - @override - String get trackReEnrichSearching => 'Searching metadata online...'; - - @override - String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; - - @override - String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; - - @override - String trackSaveFailed(String error) { - return 'Failed: $error'; - } - - @override - String get trackConvertFormat => 'Convert Format'; - - @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; - - @override - String get trackConvertTitle => 'Convert Audio'; - - @override - String get trackConvertTargetFormat => 'Target Format'; - - @override - String get trackConvertBitrate => 'Bitrate'; - - @override - String get trackConvertConfirmTitle => 'Confirm Conversion'; - - @override - String trackConvertConfirmMessage( - String sourceFormat, - String targetFormat, - String bitrate, - ) { - return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; - } - - @override - String get trackConvertConverting => 'Converting audio...'; - - @override - String trackConvertSuccess(String format) { - return 'Converted to $format successfully'; - } - - @override - String get trackConvertFailed => 'Conversion failed'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; -} - /// The translations for Chinese, as used in Taiwan (`zh_TW`). class AppLocalizationsZhTw extends AppLocalizationsZh { AppLocalizationsZhTw() : super('zh_TW'); @@ -4807,7 +5657,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; @override String get artistAlbums => 'Albums'; @@ -5202,6 +6052,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get filenameFormat => 'Filename Format'; + @override + String get filenameShowAdvancedTags => 'Show advanced tags'; + + @override + String get filenameShowAdvancedTagsDescription => + 'Enable formatted tags for track padding and date patterns'; + @override String get folderOrganizationNone => 'No organization'; @@ -5779,6 +6636,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -6110,6 +6973,17 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -6235,7 +7109,8 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { 'Download music from Spotify, Deezer, or paste any supported URL'; @override - String get tutorialWelcomeTip2 => '從 Tidal、Qobuz 或 Deezer 取得 FLAC 品質音訊'; + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; @override String get tutorialWelcomeTip3 => @@ -6552,6 +7427,210 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + @override String downloadedAlbumDownloadedCount(int count) { return '$count downloaded'; diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index bdc91aa3..3dce0557 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -728,7 +728,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "Lösche {count} {count, plural, one {}=1{Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.", + "dialogDeleteSelectedMessage": "Lösche {count} {count, plural, one {Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -807,7 +807,7 @@ "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "{count} {count, plural, one {}=1{Titel} other{Titel}}", + "snackbarDeletedTracks": "{count} {count, plural, one {Titel} other{Titel}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -1909,7 +1909,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "{count} {count, plural, one {}=1{Titel} other{Titel}} aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.", + "downloadedAlbumDeleteMessage": "{count} {count, plural, one {Titel} other{Titel}} aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -1935,7 +1935,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "Lösche {count} {count, plural, one {}=1{Titel}other{Titel}}", + "downloadedAlbumDeleteCount": "Lösche {count} {count, plural, one {Titel} other{Titel}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { @@ -2373,7 +2373,7 @@ "@timeJustNow": { "description": "Relative time - less than a minute ago" }, - "timeMinutesAgo": "{count, plural, one {}=1{vor 1 Minute} other{vor {count} Minuten}}", + "timeMinutesAgo": "{count, plural, one {vor {count} Minute} other{vor {count} Minuten}}", "@timeMinutesAgo": { "description": "Relative time - minutes ago", "placeholders": { @@ -2382,7 +2382,7 @@ } } }, - "timeHoursAgo": "{count, plural, one {}=1{vor 1 Stunde} other{vor {count} Stunden}}", + "timeHoursAgo": "{count, plural, one {vor {count} Stunde} other{vor {count} Stunden}}", "@timeHoursAgo": { "description": "Relative time - hours ago", "placeholders": { @@ -3117,7 +3117,7 @@ "@collectionPlaylistRemoveCover": { "description": "Bottom sheet action to remove custom cover image from a playlist" }, - "selectionShareCount": "Teile {count} {count, plural, one {}=1{Titel}other{Titel}}", + "selectionShareCount": "Teile {count} {count, plural, one {Titel} other{Titel}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", "placeholders": { @@ -3130,7 +3130,7 @@ "@selectionShareNoFiles": { "description": "Snackbar when no selected files exist on disk" }, - "selectionConvertCount": "Konvertiere {count} {count, plural, one {}=1{Titel}other{Titel}}", + "selectionConvertCount": "Konvertiere {count} {count, plural, one {Titel} other{Titel}}", "@selectionConvertCount": { "description": "Convert button text with count in selection mode", "placeholders": { @@ -3147,7 +3147,7 @@ "@selectionBatchConvertConfirmTitle": { "description": "Confirmation dialog title for batch conversion" }, - "selectionBatchConvertConfirmMessage": "Konvertiere {count} {format} {count, plural, one {}=1{Titel} other{Titel}} zu {bitrate}?\n\nOriginaldateien werden nach der Konvertierung gelöscht.", + "selectionBatchConvertConfirmMessage": "Konvertiere {count} {format} {count, plural, one {Titel} other{Titel}} zu {bitrate}?\n\nOriginaldateien werden nach der Konvertierung gelöscht.", "@selectionBatchConvertConfirmMessage": { "description": "Confirmation dialog message for batch conversion", "placeholders": { diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 03313d7b..7e882ab2 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -671,6 +671,10 @@ "@dialogImport": { "description": "Dialog button - import data" }, + "dialogDownload": "Download", + "@dialogDownload": { + "description": "Dialog button - download action" + }, "dialogDiscard": "Discard", "@dialogDiscard": { "description": "Dialog button - discard changes" @@ -1570,7 +1574,59 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "extensionDefaultProvider": "Default (Deezer/Spotify)", + "storeAddRepoTitle": "Add Extension Repository", + "@storeAddRepoTitle": { + "description": "Store setup screen - heading when no repo is configured" + }, + "storeAddRepoDescription": "Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.", + "@storeAddRepoDescription": { + "description": "Store setup screen - explanatory text" + }, + "storeRepoUrlLabel": "Repository URL", + "@storeRepoUrlLabel": { + "description": "Label for the repository URL input field" + }, + "storeRepoUrlHint": "https://github.com/user/repo", + "@storeRepoUrlHint": { + "description": "Hint/placeholder for the repository URL input field" + }, + "storeRepoUrlHelper": "e.g. https://github.com/user/extensions-repo", + "@storeRepoUrlHelper": { + "description": "Helper text below the repository URL input field" + }, + "storeAddRepoButton": "Add Repository", + "@storeAddRepoButton": { + "description": "Button to submit a new repository URL" + }, + "storeChangeRepoTooltip": "Change repository", + "@storeChangeRepoTooltip": { + "description": "Tooltip for the change-repository icon button in the app bar" + }, + "storeRepoDialogTitle": "Extension Repository", + "@storeRepoDialogTitle": { + "description": "Title of the change/remove repository dialog" + }, + "storeRepoDialogCurrent": "Current repository:", + "@storeRepoDialogCurrent": { + "description": "Label shown above the current repository URL in the dialog" + }, + "storeNewRepoUrlLabel": "New Repository URL", + "@storeNewRepoUrlLabel": { + "description": "Label for the new repository URL field inside the dialog" + }, + "storeLoadError": "Failed to load store", + "@storeLoadError": { + "description": "Error heading when the store cannot be loaded" + }, + "storeEmptyNoExtensions": "No extensions available", + "@storeEmptyNoExtensions": { + "description": "Message when store has no extensions" + }, + "storeEmptyNoResults": "No extensions found", + "@storeEmptyNoResults": { + "description": "Message when search/filter returns no results" + }, + "extensionDefaultProvider": "Default (Deezer)", "@extensionDefaultProvider": { "description": "Default search provider option" }, @@ -1769,6 +1825,46 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, + "downloadLossy320": "Lossy 320kbps", + "@downloadLossy320": { + "description": "Quality option label for Tidal lossy 320kbps" + }, + "downloadLossyFormat": "Lossy Format", + "@downloadLossyFormat": { + "description": "Setting title to pick output format for Tidal lossy downloads" + }, + "downloadLossy320Format": "Lossy 320kbps Format", + "@downloadLossy320Format": { + "description": "Title of the Tidal lossy format picker bottom sheet" + }, + "downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.", + "@downloadLossy320FormatDesc": { + "description": "Description in the Tidal lossy format picker" + }, + "downloadLossyMp3": "MP3 320kbps", + "@downloadLossyMp3": { + "description": "Tidal lossy format option - MP3 320kbps" + }, + "downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track", + "@downloadLossyMp3Subtitle": { + "description": "Subtitle for MP3 320kbps Tidal lossy option" + }, + "downloadLossyOpus256": "Opus 256kbps", + "@downloadLossyOpus256": { + "description": "Tidal lossy format option - Opus 256kbps" + }, + "downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track", + "@downloadLossyOpus256Subtitle": { + "description": "Subtitle for Opus 256kbps Tidal lossy option" + }, + "downloadLossyOpus128": "Opus 128kbps", + "@downloadLossyOpus128": { + "description": "Tidal lossy format option - Opus 128kbps" + }, + "downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track", + "@downloadLossyOpus128Subtitle": { + "description": "Subtitle for Opus 128kbps Tidal lossy option" + }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2186,6 +2282,30 @@ "@libraryShowDuplicateIndicatorSubtitle": { "description": "Subtitle for duplicate indicator toggle" }, + "libraryAutoScan": "Auto Scan", + "@libraryAutoScan": { + "description": "Setting for automatic library scanning" + }, + "libraryAutoScanSubtitle": "Automatically scan your library for new files", + "@libraryAutoScanSubtitle": { + "description": "Subtitle for auto scan setting" + }, + "libraryAutoScanOff": "Off", + "@libraryAutoScanOff": { + "description": "Auto scan disabled" + }, + "libraryAutoScanOnOpen": "Every app open", + "@libraryAutoScanOnOpen": { + "description": "Auto scan when app opens" + }, + "libraryAutoScanDaily": "Daily", + "@libraryAutoScanDaily": { + "description": "Auto scan once per day" + }, + "libraryAutoScanWeekly": "Weekly", + "@libraryAutoScanWeekly": { + "description": "Auto scan once per week" + }, "libraryActions": "Actions", "@libraryActions": { "description": "Section header for library actions" @@ -2763,6 +2883,47 @@ "@trackReEnrichFfmpegFailed": { "description": "Snackbar when FFmpeg embed fails for MP3/Opus" }, + "queueFlacAction": "Queue FLAC", + "@queueFlacAction": { + "description": "Action/button label for queueing FLAC redownloads for local tracks" + }, + "queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected", + "@queueFlacConfirmMessage": { + "description": "Confirmation dialog body before queueing FLAC redownloads for local tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})", + "@queueFlacFindingProgress": { + "description": "Snackbar while resolving remote matches for local FLAC redownloads", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "queueFlacNoReliableMatches": "No reliable online matches found for the selection", + "@queueFlacNoReliableMatches": { + "description": "Snackbar when no safe FLAC redownload matches were found" + }, + "queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}", + "@queueFlacQueuedWithSkipped": { + "description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped", + "placeholders": { + "addedCount": { + "type": "int" + }, + "skippedCount": { + "type": "int" + } + } + }, "trackSaveFailed": "Failed: {error}", "@trackSaveFailed": { "description": "Snackbar when save operation fails", @@ -2776,7 +2937,7 @@ "@trackConvertFormat": { "description": "Menu item - convert audio format" }, - "trackConvertFormatSubtitle": "Convert to MP3 or Opus", + "trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC", "@trackConvertFormatSubtitle": { "description": "Subtitle for convert format menu item" }, @@ -2811,6 +2972,22 @@ } } }, + "trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.", + "@trackConvertConfirmMessageLossless": { + "description": "Confirmation dialog message for lossless-to-lossless conversion", + "placeholders": { + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + } + } + }, + "trackConvertLosslessHint": "Lossless conversion — no quality loss", + "@trackConvertLosslessHint": { + "description": "Hint shown when converting between lossless formats" + }, "trackConvertConverting": "Converting audio...", "@trackConvertConverting": { "description": "Snackbar while converting" @@ -3162,6 +3339,18 @@ } } }, + "selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.", + "@selectionBatchConvertConfirmMessageLossless": { + "description": "Confirmation dialog message for lossless batch conversion", + "placeholders": { + "count": { + "type": "int" + }, + "format": { + "type": "String" + } + } + }, "selectionBatchConvertProgress": "Converting {current} of {total}...", "@selectionBatchConvertProgress": { "description": "Snackbar during batch conversion progress", @@ -3205,5 +3394,514 @@ "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", "@downloadUseAlbumArtistForFoldersTrackSubtitle": { "description": "Subtitle when Track Artist is used for folder naming" + }, + + "lyricsProvidersTitle": "Lyrics Providers", + "@lyricsProvidersTitle": { + "description": "Title for the lyrics provider priority page" + }, + "lyricsProvidersDescription": "Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.", + "@lyricsProvidersDescription": { + "description": "Description on the lyrics provider priority page" + }, + "lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.", + "@lyricsProvidersInfoText": { + "description": "Info tip on lyrics provider priority page" + }, + "lyricsProvidersEnabledSection": "Enabled ({count})", + "@lyricsProvidersEnabledSection": { + "description": "Section header for enabled providers", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "lyricsProvidersDisabledSection": "Disabled ({count})", + "@lyricsProvidersDisabledSection": { + "description": "Section header for disabled providers", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "lyricsProvidersAtLeastOne": "At least one provider must remain enabled", + "@lyricsProvidersAtLeastOne": { + "description": "Snackbar when user tries to disable the last enabled provider" + }, + "lyricsProvidersSaved": "Lyrics provider priority saved", + "@lyricsProvidersSaved": { + "description": "Snackbar after saving lyrics provider priority" + }, + "lyricsProvidersDiscardContent": "You have unsaved changes that will be lost.", + "@lyricsProvidersDiscardContent": { + "description": "Body text of the discard-changes dialog on lyrics provider page" + }, + "lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API", + "@lyricsProviderSpotifyApiDesc": { + "description": "Description for Spotify Lyrics API provider" + }, + "lyricsProviderLrclibDesc": "Open-source synced lyrics database", + "@lyricsProviderLrclibDesc": { + "description": "Description for LRCLIB provider" + }, + "lyricsProviderNeteaseDesc": "NetEase Cloud Music (good for Asian songs)", + "@lyricsProviderNeteaseDesc": { + "description": "Description for Netease provider" + }, + "lyricsProviderMusixmatchDesc": "Largest lyrics database (multi-language)", + "@lyricsProviderMusixmatchDesc": { + "description": "Description for Musixmatch provider" + }, + "lyricsProviderAppleMusicDesc": "Word-by-word synced lyrics (via proxy)", + "@lyricsProviderAppleMusicDesc": { + "description": "Description for Apple Music provider" + }, + "lyricsProviderQqMusicDesc": "QQ Music (good for Chinese songs, via proxy)", + "@lyricsProviderQqMusicDesc": { + "description": "Description for QQ Music provider" + }, + "lyricsProviderExtensionDesc": "Extension provider", + "@lyricsProviderExtensionDesc": { + "description": "Generic description for extension-based lyrics providers" + }, + + "safMigrationTitle": "Storage Update Required", + "@safMigrationTitle": { + "description": "Title of SAF migration dialog" + }, + "safMigrationMessage1": "SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.", + "@safMigrationMessage1": { + "description": "First paragraph of SAF migration dialog" + }, + "safMigrationMessage2": "Please select your download folder again to switch to the new storage system.", + "@safMigrationMessage2": { + "description": "Second paragraph of SAF migration dialog" + }, + "safMigrationSuccess": "Download folder updated to SAF mode", + "@safMigrationSuccess": { + "description": "Snackbar after successfully migrating to SAF" + }, + + "settingsDonate": "Donate", + "@settingsDonate": { + "description": "Settings menu item - donate" + }, + "settingsDonateSubtitle": "Support SpotiFLAC-Mobile development", + "@settingsDonateSubtitle": { + "description": "Subtitle for donate menu item" + }, + + "tooltipLoveAll": "Love All", + "@tooltipLoveAll": { + "description": "Tooltip for the Love All button on album/playlist screens" + }, + "tooltipAddToPlaylist": "Add to Playlist", + "@tooltipAddToPlaylist": { + "description": "Tooltip for the Add to Playlist button" + }, + "snackbarRemovedTracksFromLoved": "Removed {count} tracks from Loved", + "@snackbarRemovedTracksFromLoved": { + "description": "Snackbar after removing multiple tracks from Loved folder", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAddedTracksToLoved": "Added {count} tracks to Loved", + "@snackbarAddedTracksToLoved": { + "description": "Snackbar after adding multiple tracks to Loved folder", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "dialogDownloadAllTitle": "Download All", + "@dialogDownloadAllTitle": { + "description": "Title of the Download All confirmation dialog" + }, + "dialogDownloadAllMessage": "Download {count} tracks?", + "@dialogDownloadAllMessage": { + "description": "Body of the Download All confirmation dialog", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dialogDownload": "Download", + "@dialogDownload": { + "description": "Confirm button in Download All dialog" + }, + + "homeSkipAlreadyDownloaded": "Skip already downloaded songs", + "@homeSkipAlreadyDownloaded": { + "description": "Checkbox label in import dialog to skip already-downloaded songs" + }, + "homeGoToAlbum": "Go to Album", + "@homeGoToAlbum": { + "description": "Context menu item to navigate to the album page" + }, + "homeAlbumInfoUnavailable": "Album info not available", + "@homeAlbumInfoUnavailable": { + "description": "Snackbar when album info cannot be loaded" + }, + + "snackbarLoadingCueSheet": "Loading CUE sheet...", + "@snackbarLoadingCueSheet": { + "description": "Snackbar while loading a CUE sheet file" + }, + "snackbarMetadataSaved": "Metadata saved successfully", + "@snackbarMetadataSaved": { + "description": "Snackbar after successfully saving track metadata" + }, + "snackbarFailedToEmbedLyrics": "Failed to embed lyrics", + "@snackbarFailedToEmbedLyrics": { + "description": "Snackbar when lyrics embedding fails" + }, + "snackbarFailedToWriteStorage": "Failed to write back to storage", + "@snackbarFailedToWriteStorage": { + "description": "Snackbar when writing metadata back to file fails" + }, + "snackbarError": "Error: {error}", + "@snackbarError": { + "description": "Generic error snackbar with error detail", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarNoActionDefined": "No action defined for this button", + "@snackbarNoActionDefined": { + "description": "Snackbar when an extension button has no action configured" + }, + + "noTracksFoundForAlbum": "No tracks found for this album", + "@noTracksFoundForAlbum": { + "description": "Empty state message when an album has no tracks" + }, + + "downloadLocationSubtitle": "Choose storage mode for downloaded files.", + "@downloadLocationSubtitle": { + "description": "Subtitle text in Android download location bottom sheet" + }, + "storageModeAppFolder": "App folder (non-SAF)", + "@storageModeAppFolder": { + "description": "Storage mode option - use legacy app folder" + }, + "storageModeAppFolderSubtitle": "Use default Music/SpotiFLAC path", + "@storageModeAppFolderSubtitle": { + "description": "Subtitle for app folder storage mode" + }, + "storageModeSaf": "SAF folder", + "@storageModeSaf": { + "description": "Storage mode option - use Android SAF picker" + }, + "storageModeSafSubtitle": "Pick folder via Android Storage Access Framework", + "@storageModeSafSubtitle": { + "description": "Subtitle for SAF storage mode" + }, + "downloadFilenameDescription": "Customize how your files are named.", + "@downloadFilenameDescription": { + "description": "Description text in filename format bottom sheet" + }, + "downloadFilenameInsertTag": "Tap to insert tag:", + "@downloadFilenameInsertTag": { + "description": "Label above filename tag chips" + }, + "downloadSeparateSinglesEnabled": "Albums/ and Singles/ folders", + "@downloadSeparateSinglesEnabled": { + "description": "Subtitle when separate singles folder is enabled" + }, + "downloadSeparateSinglesDisabled": "All files in same structure", + "@downloadSeparateSinglesDisabled": { + "description": "Subtitle when separate singles folder is disabled" + }, + "downloadArtistNameFilters": "Artist Name Filters", + "@downloadArtistNameFilters": { + "description": "Setting title for artist folder filter options" + }, + "downloadCreatePlaylistSourceFolder": "Create playlist source folder", + "@downloadCreatePlaylistSourceFolder": { + "description": "Setting title for adding a playlist folder prefix before the normal organization structure" + }, + "downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.", + "@downloadCreatePlaylistSourceFolderEnabled": { + "description": "Subtitle when playlist source folder prefix is enabled" + }, + "downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.", + "@downloadCreatePlaylistSourceFolderDisabled": { + "description": "Subtitle when playlist source folder prefix is disabled" + }, + "downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.", + "@downloadCreatePlaylistSourceFolderRedundant": { + "description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist" + }, + "downloadSongLinkRegion": "SongLink Region", + "@downloadSongLinkRegion": { + "description": "Setting title for SongLink country region" + }, + "downloadNetworkCompatibilityMode": "Network compatibility mode", + "@downloadNetworkCompatibilityMode": { + "description": "Setting title for network compatibility toggle" + }, + "downloadNetworkCompatibilityModeEnabled": "Enabled: try HTTP + accept invalid TLS certificates (unsafe)", + "@downloadNetworkCompatibilityModeEnabled": { + "description": "Subtitle when network compatibility mode is enabled" + }, + "downloadNetworkCompatibilityModeDisabled": "Off: strict HTTPS certificate validation (recommended)", + "@downloadNetworkCompatibilityModeDisabled": { + "description": "Subtitle when network compatibility mode is disabled" + }, + "downloadSelectServiceToEnable": "Select a built-in service to enable", + "@downloadSelectServiceToEnable": { + "description": "Hint shown instead of Ask-quality subtitle when no built-in service selected" + }, + + "downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality", + "@downloadSelectTidalQobuz": { + "description": "Info hint when non-Tidal/Qobuz service is selected" + }, + "downloadEmbedLyricsDisabled": "Disabled while Embed Metadata is turned off", + "@downloadEmbedLyricsDisabled": { + "description": "Subtitle for Embed Lyrics when Embed Metadata is disabled" + }, + "downloadNeteaseIncludeTranslation": "Netease: Include Translation", + "@downloadNeteaseIncludeTranslation": { + "description": "Toggle title for including Netease translated lyrics" + }, + "downloadNeteaseIncludeTranslationEnabled": "Append translated lyrics when available", + "@downloadNeteaseIncludeTranslationEnabled": { + "description": "Subtitle when Netease translation is enabled" + }, + "downloadNeteaseIncludeTranslationDisabled": "Use original lyrics only", + "@downloadNeteaseIncludeTranslationDisabled": { + "description": "Subtitle when Netease translation is disabled" + }, + "downloadNeteaseIncludeRomanization": "Netease: Include Romanization", + "@downloadNeteaseIncludeRomanization": { + "description": "Toggle title for including Netease romanized lyrics" + }, + "downloadNeteaseIncludeRomanizationEnabled": "Append romanized lyrics when available", + "@downloadNeteaseIncludeRomanizationEnabled": { + "description": "Subtitle when Netease romanization is enabled" + }, + "downloadNeteaseIncludeRomanizationDisabled": "Disabled", + "@downloadNeteaseIncludeRomanizationDisabled": { + "description": "Subtitle when Netease romanization is disabled" + }, + "downloadAppleQqMultiPerson": "Apple/QQ Multi-Person Word-by-Word", + "@downloadAppleQqMultiPerson": { + "description": "Toggle title for Apple/QQ multi-person word-by-word lyrics" + }, + "downloadAppleQqMultiPersonEnabled": "Enable v1/v2 speaker and [bg:] tags", + "@downloadAppleQqMultiPersonEnabled": { + "description": "Subtitle when multi-person word-by-word is enabled" + }, + "downloadAppleQqMultiPersonDisabled": "Simplified word-by-word formatting", + "@downloadAppleQqMultiPersonDisabled": { + "description": "Subtitle when multi-person word-by-word is disabled" + }, + "downloadMusixmatchLanguage": "Musixmatch Language", + "@downloadMusixmatchLanguage": { + "description": "Setting title for Musixmatch language preference" + }, + "downloadMusixmatchLanguageAuto": "Auto (original)", + "@downloadMusixmatchLanguageAuto": { + "description": "Option label when Musixmatch uses original language" + }, + "downloadFilterContributing": "Filter contributing artists in Album Artist", + "@downloadFilterContributing": { + "description": "Toggle title for filtering contributing artists in Album Artist metadata" + }, + "downloadFilterContributingEnabled": "Album Artist metadata uses primary artist only", + "@downloadFilterContributingEnabled": { + "description": "Subtitle when contributing artist filter is enabled" + }, + "downloadFilterContributingDisabled": "Keep full Album Artist metadata value", + "@downloadFilterContributingDisabled": { + "description": "Subtitle when contributing artist filter is disabled" + }, + + "downloadProvidersNoneEnabled": "None enabled", + "@downloadProvidersNoneEnabled": { + "description": "Subtitle for lyrics providers setting when no providers are enabled" + }, + "downloadMusixmatchLanguageCode": "Language code", + "@downloadMusixmatchLanguageCode": { + "description": "Label for the Musixmatch language code text field" + }, + "downloadMusixmatchLanguageHint": "auto / en / es / ja", + "@downloadMusixmatchLanguageHint": { + "description": "Hint text for the Musixmatch language code field" + }, + "downloadMusixmatchLanguageDesc": "Set preferred language code (example: en, es, ja). Leave empty for auto.", + "@downloadMusixmatchLanguageDesc": { + "description": "Description in the Musixmatch language picker" + }, + "downloadMusixmatchAuto": "Auto", + "@downloadMusixmatchAuto": { + "description": "Button to reset Musixmatch language to automatic" + }, + + "downloadNetworkAnySubtitle": "WiFi + Mobile Data", + "@downloadNetworkAnySubtitle": { + "description": "Subtitle for 'Any' network mode option" + }, + "downloadNetworkWifiOnlySubtitle": "Pause downloads on mobile data", + "@downloadNetworkWifiOnlySubtitle": { + "description": "Subtitle for 'WiFi only' network mode option" + }, + "downloadSongLinkRegionDesc": "Used as userCountry for SongLink API lookup.", + "@downloadSongLinkRegionDesc": { + "description": "Description in the SongLink region picker" + }, + "downloadFolderOrganization": "Folder Organization", + "@downloadFolderOrganization": { + "description": "Title of the folder organization picker bottom sheet" + }, + "snackbarUnsupportedAudioFormat": "Unsupported audio format", + "@snackbarUnsupportedAudioFormat": { + "description": "Snackbar when the audio format is not supported for the requested operation" + }, + "cacheRefresh": "Refresh", + "@cacheRefresh": { + "description": "Tooltip for refresh button on cache management page" + }, + "dialogDownloadAllTitle": "Download All", + "@dialogDownloadAllTitle": { + "description": "Dialog title for bulk download confirmation" + }, + "dialogDownloadPlaylistsMessage": "Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?", + "@dialogDownloadPlaylistsMessage": { + "description": "Dialog message for bulk playlist download confirmation", + "placeholders": { + "trackCount": { + "type": "int" + }, + "playlistCount": { + "type": "int" + } + } + }, + "bulkDownloadPlaylistsButton": "Download {count} {count, plural, =1{playlist} other{playlists}}", + "@bulkDownloadPlaylistsButton": { + "description": "Button label for bulk downloading selected playlists", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bulkDownloadSelectPlaylists": "Select playlists to download", + "@bulkDownloadSelectPlaylists": { + "description": "Button label when no playlists are selected for download" + }, + "snackbarSelectedPlaylistsEmpty": "Selected playlists have no tracks", + "@snackbarSelectedPlaylistsEmpty": { + "description": "Snackbar when selected playlists contain no tracks" + }, + "playlistsCount": "{count, plural, =1{1 playlist} other{{count} playlists}}", + "@playlistsCount": { + "description": "Playlist count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editMetadataAutoFill": "Auto-fill from online", + "@editMetadataAutoFill": { + "description": "Section title for selective online metadata auto-fill in the edit metadata sheet" + }, + "editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata", + "@editMetadataAutoFillDesc": { + "description": "Description for the auto-fill section" + }, + "editMetadataAutoFillFetch": "Fetch & Fill", + "@editMetadataAutoFillFetch": { + "description": "Button label to fetch online metadata and fill selected fields" + }, + "editMetadataAutoFillSearching": "Searching online...", + "@editMetadataAutoFillSearching": { + "description": "Snackbar shown while searching for online metadata" + }, + "editMetadataAutoFillNoResults": "No matching metadata found online", + "@editMetadataAutoFillNoResults": { + "description": "Snackbar when online metadata search returns no results" + }, + "editMetadataAutoFillDone": "Filled {count} {count, plural, =1{field} other{fields}} from online metadata", + "@editMetadataAutoFillDone": { + "description": "Snackbar confirming how many fields were auto-filled", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editMetadataAutoFillNoneSelected": "Select at least one field to auto-fill", + "@editMetadataAutoFillNoneSelected": { + "description": "Snackbar when user taps Fetch without selecting any fields" + }, + "editMetadataFieldTitle": "Title", + "@editMetadataFieldTitle": { + "description": "Chip label for title field in auto-fill selector" + }, + "editMetadataFieldArtist": "Artist", + "@editMetadataFieldArtist": { + "description": "Chip label for artist field in auto-fill selector" + }, + "editMetadataFieldAlbum": "Album", + "@editMetadataFieldAlbum": { + "description": "Chip label for album field in auto-fill selector" + }, + "editMetadataFieldAlbumArtist": "Album Artist", + "@editMetadataFieldAlbumArtist": { + "description": "Chip label for album artist field in auto-fill selector" + }, + "editMetadataFieldDate": "Date", + "@editMetadataFieldDate": { + "description": "Chip label for date field in auto-fill selector" + }, + "editMetadataFieldTrackNum": "Track #", + "@editMetadataFieldTrackNum": { + "description": "Chip label for track number field in auto-fill selector" + }, + "editMetadataFieldDiscNum": "Disc #", + "@editMetadataFieldDiscNum": { + "description": "Chip label for disc number field in auto-fill selector" + }, + "editMetadataFieldGenre": "Genre", + "@editMetadataFieldGenre": { + "description": "Chip label for genre field in auto-fill selector" + }, + "editMetadataFieldIsrc": "ISRC", + "@editMetadataFieldIsrc": { + "description": "Chip label for ISRC field in auto-fill selector" + }, + "editMetadataFieldLabel": "Label", + "@editMetadataFieldLabel": { + "description": "Chip label for label field in auto-fill selector" + }, + "editMetadataFieldCopyright": "Copyright", + "@editMetadataFieldCopyright": { + "description": "Chip label for copyright field in auto-fill selector" + }, + "editMetadataFieldCover": "Cover Art", + "@editMetadataFieldCover": { + "description": "Chip label for cover art field in auto-fill selector" + }, + "editMetadataSelectAll": "All", + "@editMetadataSelectAll": { + "description": "Button to select all fields for auto-fill" + }, + "editMetadataSelectEmpty": "Empty only", + "@editMetadataSelectEmpty": { + "description": "Button to select only fields that are currently empty" } } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 970b6d82..eba93ff1 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -897,15 +897,15 @@ "@errorNoTracksFound": { "description": "Error - search returned no results" }, - "errorUrlNotRecognized": "Tautan tidak dikenali", + "errorUrlNotRecognized": "Link tidak dikenali", "@errorUrlNotRecognized": { "description": "Error title - URL not handled by any extension or service" }, - "errorUrlNotRecognizedMessage": "Tautan ini tidak didukung. Pastikan URL sudah benar dan ekstensi yang kompatibel telah terpasang.", + "errorUrlNotRecognizedMessage": "Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.", "@errorUrlNotRecognizedMessage": { "description": "Error message - URL not recognized explanation" }, - "errorUrlFetchFailed": "Konten dari tautan ini gagal dimuat. Silakan coba lagi.", + "errorUrlFetchFailed": "Gagal memuat konten dari link ini. Silakan coba lagi.", "@errorUrlFetchFailed": { "description": "Error message - generic URL fetch failure" }, @@ -1805,6 +1805,22 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, + "downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist", + "@downloadCreatePlaylistSourceFolder": { + "description": "Setting title for adding a playlist folder prefix before the normal organization structure" + }, + "downloadCreatePlaylistSourceFolderEnabled": "Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.", + "@downloadCreatePlaylistSourceFolderEnabled": { + "description": "Subtitle when playlist source folder prefix is enabled" + }, + "downloadCreatePlaylistSourceFolderDisabled": "Unduhan dari playlist hanya memakai struktur folder normal.", + "@downloadCreatePlaylistSourceFolderDisabled": { + "description": "Subtitle when playlist source folder prefix is disabled" + }, + "downloadCreatePlaylistSourceFolderRedundant": "Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.", + "@downloadCreatePlaylistSourceFolderRedundant": { + "description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist" + }, "downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2763,6 +2779,47 @@ "@trackReEnrichFfmpegFailed": { "description": "Snackbar when FFmpeg embed fails for MP3/Opus" }, + "queueFlacAction": "Antrekan FLAC", + "@queueFlacAction": { + "description": "Action/button label for queueing FLAC redownloads for local tracks" + }, + "queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih", + "@queueFlacConfirmMessage": { + "description": "Confirmation dialog body before queueing FLAC redownloads for local tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})", + "@queueFlacFindingProgress": { + "description": "Snackbar while resolving remote matches for local FLAC redownloads", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini", + "@queueFlacNoReliableMatches": { + "description": "Snackbar when no safe FLAC redownload matches were found" + }, + "queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}", + "@queueFlacQueuedWithSkipped": { + "description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped", + "placeholders": { + "addedCount": { + "type": "int" + }, + "skippedCount": { + "type": "int" + } + } + }, "trackSaveFailed": "Failed: {error}", "@trackSaveFailed": { "description": "Snackbar when save operation fails", @@ -2776,7 +2833,7 @@ "@trackConvertFormat": { "description": "Menu item - convert audio format" }, - "trackConvertFormatSubtitle": "Convert to MP3 or Opus", + "trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC", "@trackConvertFormatSubtitle": { "description": "Subtitle for convert format menu item" }, @@ -2811,6 +2868,22 @@ } } }, + "trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.", + "@trackConvertConfirmMessageLossless": { + "description": "Confirmation dialog message for lossless-to-lossless conversion", + "placeholders": { + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + } + } + }, + "trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas", + "@trackConvertLosslessHint": { + "description": "Hint shown when converting between lossless formats" + }, "trackConvertConverting": "Converting audio...", "@trackConvertConverting": { "description": "Snackbar while converting" @@ -3206,4 +3279,4 @@ "@downloadUseAlbumArtistForFoldersTrackSubtitle": { "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 665fb473..4a1ffc71 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -728,7 +728,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", + "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -807,7 +807,7 @@ "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -1370,7 +1370,7 @@ } } }, - "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", + "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -1909,7 +1909,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", + "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -1935,7 +1935,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { @@ -2234,7 +2234,7 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} other{треков}}", "@libraryTracksUnit": { "description": "Unit label for tracks count (without the number itself)", "placeholders": { @@ -2373,7 +2373,7 @@ "@timeJustNow": { "description": "Relative time - less than a minute ago" }, - "timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} =1 {1 минуту} other {{count} минут}} назад", + "timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} other {{count} минут}} назад", "@timeMinutesAgo": { "description": "Relative time - minutes ago", "placeholders": { @@ -2382,7 +2382,7 @@ } } }, - "timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} =1 {1 час} other {{count} часов}} назад", + "timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} other {{count} часов}} назад", "@timeHoursAgo": { "description": "Relative time - hours ago", "placeholders": { @@ -2952,7 +2952,7 @@ "@collectionNoPlaylistsSubtitle": { "description": "Empty state subtitle when user has no playlists" }, - "collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", + "collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "@collectionPlaylistTracks": { "description": "Track count label for custom playlists", "placeholders": { @@ -3117,7 +3117,7 @@ "@collectionPlaylistRemoveCover": { "description": "Bottom sheet action to remove custom cover image from a playlist" }, - "selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", "placeholders": { @@ -3130,7 +3130,7 @@ "@selectionShareNoFiles": { "description": "Snackbar when no selected files exist on disk" }, - "selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@selectionConvertCount": { "description": "Convert button text with count in selection mode", "placeholders": { diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index db36f07c..db6943ab 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh-CN", + "@@locale": "zh_CN", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 940c7e28..cf4f7b4a 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh-TW", + "@@locale": "zh_TW", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { diff --git a/lib/main.dart b/lib/main.dart index 6963a9ee..89d5d93c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,20 @@ +import 'dart:async'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/app.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/utils/local_library_scan_prefs.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -88,15 +92,142 @@ class _EagerInitialization extends ConsumerStatefulWidget { _EagerInitializationState(); } -class _EagerInitializationState extends ConsumerState<_EagerInitialization> { +class _EagerInitializationState extends ConsumerState<_EagerInitialization> + with WidgetsBindingObserver { + ProviderSubscription? _localLibraryEnabledSub; + Timer? _downloadHistoryWarmupTimer; + Timer? _libraryCollectionsWarmupTimer; + Timer? _localLibraryWarmupTimer; + bool _localLibraryWarmupScheduled = false; + bool _autoScanTriggeredOnLaunch = false; + @override void initState() { super.initState(); - _initializeAppServices(); - _initializeExtensions(); - ref.read(downloadHistoryProvider); - ref.read(localLibraryProvider); - ref.read(libraryCollectionsProvider); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _initializeAppServices(); + _initializeExtensions(); + _initializeDeferredProviders(); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _localLibraryEnabledSub?.close(); + _downloadHistoryWarmupTimer?.cancel(); + _libraryCollectionsWarmupTimer?.cancel(); + _localLibraryWarmupTimer?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _maybeAutoScanLocalLibrary(); + } + } + + void _initializeDeferredProviders() { + _downloadHistoryWarmupTimer = _scheduleProviderWarmup( + const Duration(milliseconds: 400), + () => ref.read(downloadHistoryProvider), + ); + _libraryCollectionsWarmupTimer = _scheduleProviderWarmup( + const Duration(milliseconds: 900), + () => ref.read(libraryCollectionsProvider), + ); + + _maybeScheduleLocalLibraryWarmup( + ref.read( + settingsProvider.select((settings) => settings.localLibraryEnabled), + ), + ); + + _localLibraryEnabledSub = ref.listenManual( + settingsProvider.select((settings) => settings.localLibraryEnabled), + (previous, next) { + if (next == true) { + _maybeScheduleLocalLibraryWarmup(true); + } + }, + ); + } + + Timer _scheduleProviderWarmup(Duration delay, VoidCallback action) { + return Timer(delay, () { + if (!mounted) return; + action(); + }); + } + + void _maybeScheduleLocalLibraryWarmup(bool enabled) { + if (!enabled || _localLibraryWarmupScheduled) return; + _localLibraryWarmupScheduled = true; + _localLibraryWarmupTimer = _scheduleProviderWarmup( + const Duration(milliseconds: 1600), + () { + ref.read(localLibraryProvider); + // Trigger auto-scan after initial warmup on first app launch. + if (!_autoScanTriggeredOnLaunch) { + _autoScanTriggeredOnLaunch = true; + // Give the provider a moment to load existing data before scanning. + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) _maybeAutoScanLocalLibrary(); + }); + } + }, + ); + } + + /// Checks whether an automatic incremental scan should be triggered based on + /// the user's auto-scan preference and the time since the last scan. + Future _maybeAutoScanLocalLibrary() async { + if (!mounted) return; + + final settings = ref.read(settingsProvider); + if (!settings.localLibraryEnabled) return; + if (settings.localLibraryPath.isEmpty) return; + if (settings.localLibraryAutoScan == 'off') return; + + // Don't start a scan if one is already running. + final libraryState = ref.read(localLibraryProvider); + if (libraryState.isScanning) return; + + // Determine cooldown based on auto-scan mode. + final now = DateTime.now(); + final prefs = await SharedPreferences.getInstance(); + final lastScanned = readLocalLibraryLastScannedAt(prefs); + + if (lastScanned != null) { + final elapsed = now.difference(lastScanned); + + switch (settings.localLibraryAutoScan) { + case 'on_open': + // Cooldown of 10 minutes to prevent rapid re-scans. + if (elapsed.inMinutes < 10) return; + break; + case 'daily': + if (elapsed.inHours < 24) return; + break; + case 'weekly': + if (elapsed.inDays < 7) return; + break; + default: + return; + } + } + + // All checks passed -- start an incremental scan. + final iosBookmark = settings.localLibraryBookmark; + ref + .read(localLibraryProvider.notifier) + .startScan( + settings.localLibraryPath, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } Future _initializeAppServices() async { diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index 4d8a650e..db55029b 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -34,6 +34,7 @@ class DownloadItem { final DownloadErrorType? errorType; final DateTime createdAt; final String? qualityOverride; // Override quality for this specific download + final String? playlistName; // Playlist context for folder organization const DownloadItem({ required this.id, @@ -48,6 +49,7 @@ class DownloadItem { this.errorType, required this.createdAt, this.qualityOverride, + this.playlistName, }); DownloadItem copyWith({ @@ -63,6 +65,7 @@ class DownloadItem { DownloadErrorType? errorType, DateTime? createdAt, String? qualityOverride, + String? playlistName, }) { return DownloadItem( id: id ?? this.id, @@ -77,6 +80,7 @@ class DownloadItem { errorType: errorType ?? this.errorType, createdAt: createdAt ?? this.createdAt, qualityOverride: qualityOverride ?? this.qualityOverride, + playlistName: playlistName ?? this.playlistName, ); } diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 098290ce..961e6d6d 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -21,6 +21,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), createdAt: DateTime.parse(json['createdAt'] as String), qualityOverride: json['qualityOverride'] as String?, + playlistName: json['playlistName'] as String?, ); Map _$DownloadItemToJson(DownloadItem instance) => @@ -37,6 +38,7 @@ Map _$DownloadItemToJson(DownloadItem instance) => 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], 'createdAt': instance.createdAt.toIso8601String(), 'qualityOverride': instance.qualityOverride, + 'playlistName': instance.playlistName, }; const _$DownloadStatusEnumMap = { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 8501a4e3..c2f2eed6 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -20,6 +20,7 @@ class AppSettings { final String updateChannel; final bool hasSearchedBefore; final String folderOrganization; + final bool createPlaylistFolder; final bool useAlbumArtistForFolders; final bool usePrimaryArtistOnly; // Strip featured artists from folder name final bool filterContributingArtistsInAlbumArtist; @@ -33,6 +34,7 @@ class AppSettings { final bool enableLogging; final bool useExtensionProviders; final String? searchProvider; + final String? homeFeedProvider; final bool separateSingles; final String albumFolderStructure; final bool showExtensionStore; @@ -41,7 +43,7 @@ class AppSettings { final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' final int - youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps) + youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps) final int youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps) final bool @@ -61,6 +63,8 @@ class AppSettings { localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks + final String + localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly' final bool hasCompletedTutorial; // Track if user has completed the app tutorial @@ -96,6 +100,7 @@ class AppSettings { this.updateChannel = 'stable', this.hasSearchedBefore = false, this.folderOrganization = 'none', + this.createPlaylistFolder = false, this.useAlbumArtistForFolders = true, this.usePrimaryArtistOnly = false, this.filterContributingArtistsInAlbumArtist = false, @@ -104,11 +109,12 @@ class AppSettings { this.askQualityBeforeDownload = true, this.spotifyClientId = '', this.spotifyClientSecret = '', - this.useCustomSpotifyCredentials = true, + this.useCustomSpotifyCredentials = false, this.metadataSource = 'deezer', this.enableLogging = false, this.useExtensionProviders = true, this.searchProvider, + this.homeFeedProvider, this.separateSingles = false, this.albumFolderStructure = 'artist_album', this.showExtensionStore = true, @@ -126,6 +132,7 @@ class AppSettings { this.localLibraryPath = '', this.localLibraryBookmark = '', this.localLibraryShowDuplicates = true, + this.localLibraryAutoScan = 'off', this.hasCompletedTutorial = false, this.lyricsProviders = const [ 'lrclib', @@ -149,7 +156,7 @@ class AppSettings { String? downloadDirectory, String? storageMode, String? downloadTreeUri, - bool? autoFallback, + bool? autoFallback, bool? embedMetadata, bool? embedLyrics, bool? maxQualityCover, @@ -159,6 +166,7 @@ class AppSettings { String? updateChannel, bool? hasSearchedBefore, String? folderOrganization, + bool? createPlaylistFolder, bool? useAlbumArtistForFolders, bool? usePrimaryArtistOnly, bool? filterContributingArtistsInAlbumArtist, @@ -173,6 +181,8 @@ class AppSettings { bool? useExtensionProviders, String? searchProvider, bool clearSearchProvider = false, + String? homeFeedProvider, + bool clearHomeFeedProvider = false, bool? separateSingles, String? albumFolderStructure, bool? showExtensionStore, @@ -190,6 +200,7 @@ class AppSettings { String? localLibraryPath, String? localLibraryBookmark, bool? localLibraryShowDuplicates, + String? localLibraryAutoScan, bool? hasCompletedTutorial, List? lyricsProviders, bool? lyricsIncludeTranslationNetease, @@ -215,6 +226,7 @@ class AppSettings { updateChannel: updateChannel ?? this.updateChannel, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, folderOrganization: folderOrganization ?? this.folderOrganization, + createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder, useAlbumArtistForFolders: useAlbumArtistForFolders ?? this.useAlbumArtistForFolders, usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly, @@ -236,6 +248,9 @@ class AppSettings { searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider), + homeFeedProvider: clearHomeFeedProvider + ? null + : (homeFeedProvider ?? this.homeFeedProvider), separateSingles: separateSingles ?? this.separateSingles, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, showExtensionStore: showExtensionStore ?? this.showExtensionStore, @@ -256,6 +271,7 @@ class AppSettings { localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark, localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, + localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan, hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, lyricsProviders: lyricsProviders ?? this.lyricsProviders, lyricsIncludeTranslationNetease: diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index cc47ca8e..914e224f 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( updateChannel: json['updateChannel'] as String? ?? 'stable', hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, folderOrganization: json['folderOrganization'] as String? ?? 'none', + createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false, useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true, usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false, filterContributingArtistsInAlbumArtist: @@ -33,11 +34,12 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( spotifyClientId: json['spotifyClientId'] as String? ?? '', spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '', useCustomSpotifyCredentials: - json['useCustomSpotifyCredentials'] as bool? ?? true, + json['useCustomSpotifyCredentials'] as bool? ?? false, metadataSource: json['metadataSource'] as String? ?? 'deezer', enableLogging: json['enableLogging'] as bool? ?? false, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, searchProvider: json['searchProvider'] as String?, + homeFeedProvider: json['homeFeedProvider'] as String?, separateSingles: json['separateSingles'] as bool? ?? false, albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album', @@ -58,6 +60,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '', localLibraryShowDuplicates: json['localLibraryShowDuplicates'] as bool? ?? true, + localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off', hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false, lyricsProviders: (json['lyricsProviders'] as List?) @@ -100,6 +103,7 @@ Map _$AppSettingsToJson( 'updateChannel': instance.updateChannel, 'hasSearchedBefore': instance.hasSearchedBefore, 'folderOrganization': instance.folderOrganization, + 'createPlaylistFolder': instance.createPlaylistFolder, 'useAlbumArtistForFolders': instance.useAlbumArtistForFolders, 'usePrimaryArtistOnly': instance.usePrimaryArtistOnly, 'filterContributingArtistsInAlbumArtist': @@ -114,6 +118,7 @@ Map _$AppSettingsToJson( 'enableLogging': instance.enableLogging, 'useExtensionProviders': instance.useExtensionProviders, 'searchProvider': instance.searchProvider, + 'homeFeedProvider': instance.homeFeedProvider, 'separateSingles': instance.separateSingles, 'albumFolderStructure': instance.albumFolderStructure, 'showExtensionStore': instance.showExtensionStore, @@ -131,6 +136,7 @@ Map _$AppSettingsToJson( 'localLibraryPath': instance.localLibraryPath, 'localLibraryBookmark': instance.localLibraryBookmark, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, + 'localLibraryAutoScan': instance.localLibraryAutoScan, 'hasCompletedTutorial': instance.hasCompletedTutorial, 'lyricsProviders': instance.lyricsProviders, 'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease, diff --git a/lib/models/track.dart b/lib/models/track.dart index 244a7a65..e4d850c2 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -21,6 +21,7 @@ class Track { final ServiceAvailability? availability; final String? source; final String? albumType; + final int? totalTracks; final String? itemType; const Track({ @@ -41,10 +42,21 @@ class Track { this.availability, this.source, this.albumType, + this.totalTracks, this.itemType, }); - bool get isSingle => albumType == 'single' || albumType == 'ep'; + bool get isSingle { + switch (albumType?.toLowerCase()) { + case 'single': + return true; + case 'ep': + final count = totalTracks; + return count == null || count <= 1; + default: + return false; + } + } bool get isAlbumItem => itemType == 'album'; diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index f640cfe7..5e361ab6 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -28,6 +28,7 @@ Track _$TrackFromJson(Map json) => Track( ), source: json['source'] as String?, albumType: json['albumType'] as String?, + totalTracks: (json['totalTracks'] as num?)?.toInt(), itemType: json['itemType'] as String?, ); @@ -49,6 +50,7 @@ Map _$TrackToJson(Track instance) => { 'availability': instance.availability, 'source': instance.source, 'albumType': instance.albumType, + 'totalTracks': instance.totalTracks, 'itemType': instance.itemType, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 1e69a2fa..a457b3d2 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -122,7 +122,7 @@ class DownloadHistoryItem { artistName: json['artistName'] as String, albumName: json['albumName'] as String, albumArtist: normalizeOptionalString(json['albumArtist'] as String?), - coverUrl: json['coverUrl'] as String?, + coverUrl: normalizeCoverReference(json['coverUrl']?.toString()), filePath: json['filePath'] as String, storageMode: json['storageMode'] as String?, downloadTreeUri: json['downloadTreeUri'] as String?, @@ -176,7 +176,7 @@ class DownloadHistoryItem { artistName: artistName ?? this.artistName, albumName: albumName ?? this.albumName, albumArtist: albumArtist ?? this.albumArtist, - coverUrl: coverUrl ?? this.coverUrl, + coverUrl: normalizeCoverReference(coverUrl ?? this.coverUrl), filePath: filePath ?? this.filePath, storageMode: storageMode ?? this.storageMode, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, @@ -205,6 +205,7 @@ class DownloadHistoryState { final List items; final Map _bySpotifyId; final Map _byIsrc; + final Map _byTrackArtistKey; DownloadHistoryState({this.items = const []}) : _bySpotifyId = Map.fromEntries( @@ -218,8 +219,25 @@ class DownloadHistoryState { items .where((item) => item.isrc != null && item.isrc!.isNotEmpty) .map((item) => MapEntry(item.isrc!, item)), + ), + _byTrackArtistKey = Map.fromEntries( + items + .map( + (item) => MapEntry( + _trackArtistKey(item.trackName, item.artistName), + item, + ), + ) + .where((entry) => entry.key.isNotEmpty), ); + static String _trackArtistKey(String trackName, String artistName) { + final normalizedTrack = trackName.trim().toLowerCase(); + if (normalizedTrack.isEmpty) return ''; + final normalizedArtist = artistName.trim().toLowerCase(); + return '$normalizedTrack|$normalizedArtist'; + } + bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId); DownloadHistoryItem? getBySpotifyId(String spotifyId) => @@ -231,16 +249,9 @@ class DownloadHistoryState { String trackName, String artistName, ) { - final normalizedTrack = trackName.trim().toLowerCase(); - final normalizedArtist = artistName.trim().toLowerCase(); - if (normalizedTrack.isEmpty) return null; - for (final item in items) { - if (item.trackName.trim().toLowerCase() == normalizedTrack && - item.artistName.trim().toLowerCase() == normalizedArtist) { - return item; - } - } - return null; + final key = _trackArtistKey(trackName, artistName); + if (key.isEmpty) return null; + return _byTrackArtistKey[key]; } DownloadHistoryState copyWith({List? items}) { @@ -252,10 +263,12 @@ class DownloadHistoryNotifier extends Notifier { static const int _safRepairBatchSize = 20; static const int _safRepairMaxPerLaunch = 60; static const int _audioMetadataBackfillMaxPerLaunch = 24; + static const _startupMaintenanceDelay = Duration(seconds: 2); final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; bool _isSafRepairInProgress = false; bool _isAudioMetadataBackfillInProgress = false; + bool _startupMaintenanceScheduled = false; @override DownloadHistoryState build() { @@ -292,33 +305,45 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith(items: items); _historyLog.i('Loaded ${items.length} items from SQLite database'); - - if (Platform.isAndroid) { - Future.microtask(() async { - await _repairMissingSafEntries( - items, - maxItems: _safRepairMaxPerLaunch, - ); - await cleanupOrphanedDownloads(); - await _backfillAudioMetadata( - state.items, - maxItems: _audioMetadataBackfillMaxPerLaunch, - ); - }); - } else { - Future.microtask(() async { - await cleanupOrphanedDownloads(); - await _backfillAudioMetadata( - state.items, - maxItems: _audioMetadataBackfillMaxPerLaunch, - ); - }); - } + _scheduleStartupMaintenance(items); } catch (e, stack) { _historyLog.e('Failed to load history from database: $e', e, stack); } } + void _scheduleStartupMaintenance(List initialItems) { + if (_startupMaintenanceScheduled) { + return; + } + _startupMaintenanceScheduled = true; + + unawaited( + Future.delayed(_startupMaintenanceDelay, () async { + try { + if (Platform.isAndroid) { + await _repairMissingSafEntries( + initialItems, + maxItems: _safRepairMaxPerLaunch, + ); + } + + await cleanupOrphanedDownloads(); + + final currentItems = state.items; + if (currentItems.isNotEmpty) { + await _backfillAudioMetadata( + currentItems, + maxItems: _audioMetadataBackfillMaxPerLaunch, + ); + } + } catch (e, stack) { + _historyLog.w('Startup history maintenance failed: $e'); + _historyLog.d('$stack'); + } + }), + ); + } + String _fileNameFromUri(String uri) { try { final parsed = Uri.parse(uri); @@ -745,6 +770,37 @@ class DownloadHistoryNotifier extends Notifier { /// Remove history entries where the file no longer exists on disk /// Returns the number of orphaned entries removed + /// Audio file extensions that the app commonly produces or converts between. + static const _audioExtensions = [ + '.flac', + '.m4a', + '.mp3', + '.opus', + '.ogg', + '.wav', + '.aac', + ]; + + /// When the original file is missing, check whether a sibling with a + /// different audio extension exists (e.g. the user converted .flac → .opus). + /// Returns the path of the first match found, or `null` if none exist. + Future _findConvertedSibling(String originalPath) async { + // Strip the current extension to get the base path. + final dotIndex = originalPath.lastIndexOf('.'); + if (dotIndex < 0) return null; + final basePath = originalPath.substring(0, dotIndex); + final originalExt = originalPath.substring(dotIndex).toLowerCase(); + + for (final ext in _audioExtensions) { + if (ext == originalExt) continue; + final candidatePath = '$basePath$ext'; + try { + if (await fileExists(candidatePath)) return candidatePath; + } catch (_) {} + } + return null; + } + Future cleanupOrphanedDownloads() async { _historyLog.i('Starting orphaned downloads cleanup...'); @@ -766,7 +822,21 @@ class DownloadHistoryNotifier extends Notifier { if (filePath == null || filePath.isEmpty) return null; pathById[id] = filePath; try { - return MapEntry(id, await fileExists(filePath)); + if (await fileExists(filePath)) return MapEntry(id, true); + + // Original file missing -- check for a converted sibling. + final sibling = await _findConvertedSibling(filePath); + if (sibling != null) { + _historyLog.i( + 'Found converted sibling for $id: $filePath → $sibling', + ); + // Update the stored path so future checks succeed immediately. + await _db.updateFilePath(id, sibling); + pathById[id] = sibling; + return MapEntry(id, true); + } + + return MapEntry(id, false); } catch (e) { _historyLog.w('Error checking file existence for $id: $e'); return MapEntry(id, false); @@ -904,11 +974,12 @@ class DownloadQueueNotifier extends Notifier { StreamSubscription>? _progressStreamSub; int _downloadCount = 0; static const _cleanupInterval = 50; - static const _progressPollingInterval = Duration(milliseconds: 800); + static const _progressPollingInterval = Duration(milliseconds: 1200); static const _idleProgressPollEveryTicks = 3; static const _queueSchedulingInterval = Duration(milliseconds: 250); static const _queuePersistDebounceDuration = Duration(milliseconds: 350); static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. + static const _serviceProgressStepPercent = 2; final NotificationService _notificationService = NotificationService(); final AppStateDatabase _appStateDb = AppStateDatabase.instance; int _totalQueuedAtStart = 0; @@ -934,6 +1005,7 @@ class DownloadQueueNotifier extends Notifier { int _lastNotifPercent = -1; int _lastNotifQueueCount = -1; final Set _locallyCancelledItemIds = {}; + final Set _pausePendingItemIds = {}; double _normalizeProgressForUi(double value) { final clamped = value.clamp(0.0, 1.0).toDouble(); @@ -1253,6 +1325,10 @@ class DownloadQueueNotifier extends Notifier { if (localItem == null) { continue; } + if (_isPausePending(itemId)) { + PlatformBridge.clearItemProgress(itemId).catchError((_) {}); + continue; + } if (localItem.status == DownloadStatus.skipped) { PlatformBridge.clearItemProgress(itemId).catchError((_) {}); continue; @@ -1445,12 +1521,17 @@ class DownloadQueueNotifier extends Notifier { .round() .clamp(0, 100) .toInt(); + final progressBucket = progressPercent == 100 + ? 100 + : ((progressPercent ~/ _serviceProgressStepPercent) * + _serviceProgressStepPercent) + .clamp(0, 100); final didContentChange = trackName != _lastServiceTrackName || artistName != _lastServiceArtistName || queueCount != _lastServiceQueueCount || - progressPercent != _lastServicePercent; + progressBucket != _lastServicePercent; final allowHeartbeat = now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5); @@ -1460,7 +1541,7 @@ class DownloadQueueNotifier extends Notifier { _lastServiceTrackName = trackName; _lastServiceArtistName = artistName; - _lastServicePercent = progressPercent; + _lastServicePercent = progressBucket; _lastServiceQueueCount = queueCount; _lastServiceUpdateAt = now; @@ -1570,11 +1651,23 @@ class DownloadQueueNotifier extends Notifier { String folderOrganization, { bool separateSingles = false, String albumFolderStructure = 'artist_album', + bool createPlaylistFolder = false, bool useAlbumArtistForFolders = true, bool usePrimaryArtistOnly = false, bool filterContributingArtistsInAlbumArtist = false, + String? playlistName, }) async { String baseDir = state.outputDir; + if (createPlaylistFolder && + folderOrganization != 'playlist' && + playlistName != null && + playlistName.isNotEmpty) { + final playlistFolder = _sanitizeFolderName(playlistName); + if (playlistFolder.isNotEmpty) { + baseDir = '$baseDir${Platform.pathSeparator}$playlistFolder'; + await _ensureDirExists(baseDir, label: 'Playlist folder'); + } + } final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders ? normalizedAlbumArtist ?? track.artistName @@ -1647,6 +1740,11 @@ class DownloadQueueNotifier extends Notifier { String subPath = ''; switch (folderOrganization) { + case 'playlist': + if (playlistName != null && playlistName.isNotEmpty) { + subPath = _sanitizeFolderName(playlistName); + } + break; case 'artist': final artistName = _sanitizeFolderName(folderArtist); subPath = artistName; @@ -1722,10 +1820,19 @@ class DownloadQueueNotifier extends Notifier { String folderOrganization, { bool separateSingles = false, String albumFolderStructure = 'artist_album', + bool createPlaylistFolder = false, bool useAlbumArtistForFolders = true, bool usePrimaryArtistOnly = false, bool filterContributingArtistsInAlbumArtist = false, + String? playlistName, }) async { + final playlistPrefix = + createPlaylistFolder && + folderOrganization != 'playlist' && + playlistName != null && + playlistName.isNotEmpty + ? _sanitizeFolderName(playlistName) + : ''; final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders ? normalizedAlbumArtist ?? track.artistName @@ -1745,50 +1852,73 @@ class DownloadQueueNotifier extends Notifier { if (albumFolderStructure == 'artist_album_singles') { if (isSingle) { - return '$artistName/Singles'; + return _joinRelativePath(playlistPrefix, '$artistName/Singles'); } final albumName = _sanitizeFolderName(track.albumName); - return '$artistName/$albumName'; + return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); } if (isSingle) { - return 'Singles'; + return _joinRelativePath(playlistPrefix, 'Singles'); } final albumName = _sanitizeFolderName(track.albumName); final year = _extractYear(track.releaseDate); switch (albumFolderStructure) { case 'album_only': - return 'Albums/$albumName'; + return _joinRelativePath(playlistPrefix, 'Albums/$albumName'); case 'artist_year_album': final yearAlbum = year != null ? '[$year] $albumName' : albumName; - return 'Albums/$artistName/$yearAlbum'; + return _joinRelativePath( + playlistPrefix, + 'Albums/$artistName/$yearAlbum', + ); case 'year_album': final yearAlbum = year != null ? '[$year] $albumName' : albumName; - return 'Albums/$yearAlbum'; + return _joinRelativePath(playlistPrefix, 'Albums/$yearAlbum'); default: - return 'Albums/$artistName/$albumName'; + return _joinRelativePath( + playlistPrefix, + 'Albums/$artistName/$albumName', + ); } } if (folderOrganization == 'none') { - return ''; + return playlistPrefix; } switch (folderOrganization) { + case 'playlist': + if (playlistName != null && playlistName.isNotEmpty) { + return _sanitizeFolderName(playlistName); + } + return ''; case 'artist': - return _sanitizeFolderName(folderArtist); + return _joinRelativePath( + playlistPrefix, + _sanitizeFolderName(folderArtist), + ); case 'album': - return _sanitizeFolderName(track.albumName); + return _joinRelativePath( + playlistPrefix, + _sanitizeFolderName(track.albumName), + ); case 'artist_album': final artistName = _sanitizeFolderName(folderArtist); final albumName = _sanitizeFolderName(track.albumName); - return '$artistName/$albumName'; + return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); default: - return ''; + return playlistPrefix; } } + String _joinRelativePath(String prefix, String suffix) { + if (prefix.isEmpty) return suffix; + if (suffix.isEmpty) return prefix; + return '$prefix/$suffix'; + } + String _determineOutputExt(String quality, String service) { if (service.toLowerCase() == 'youtube') { if (quality.toLowerCase().contains('mp3')) { @@ -1900,7 +2030,12 @@ class DownloadQueueNotifier extends Notifier { ); } - String addToQueue(Track track, String service, {String? qualityOverride}) { + String addToQueue( + Track track, + String service, { + String? qualityOverride, + String? playlistName, + }) { final settings = ref.read(settingsProvider); updateSettings(settings); @@ -1912,6 +2047,7 @@ class DownloadQueueNotifier extends Notifier { service: service, createdAt: DateTime.now(), qualityOverride: qualityOverride, + playlistName: playlistName, ); state = state.copyWith(items: [...state.items, item]); @@ -1928,6 +2064,7 @@ class DownloadQueueNotifier extends Notifier { List tracks, String service, { String? qualityOverride, + String? playlistName, }) { final settings = ref.read(settingsProvider); updateSettings(settings); @@ -1942,6 +2079,7 @@ class DownloadQueueNotifier extends Notifier { service: service, createdAt: DateTime.now(), qualityOverride: qualityOverride, + playlistName: playlistName, ); }).toList(); @@ -2027,12 +2165,42 @@ class DownloadQueueNotifier extends Notifier { return resolved?.status == DownloadStatus.skipped; } + bool _isPausePending(String id) => _pausePendingItemIds.contains(id); + + void _requeueItemForPause(String id) { + final updatedItems = state.items + .map((item) { + if (item.id != id) return item; + if (item.status == DownloadStatus.completed || + item.status == DownloadStatus.failed || + item.status == DownloadStatus.skipped) { + return item; + } + return item.copyWith( + status: DownloadStatus.queued, + progress: 0, + speedMBps: 0, + bytesReceived: 0, + ); + }) + .toList(growable: false); + + final currentDownload = state.currentDownload?.id == id + ? null + : state.currentDownload; + state = state.copyWith( + items: updatedItems, + currentDownload: currentDownload, + ); + } + void _requestNativeCancel(String id) { PlatformBridge.cancelDownload(id).catchError((_) {}); PlatformBridge.clearItemProgress(id).catchError((_) {}); } void cancelItem(String id) { + _pausePendingItemIds.remove(id); _locallyCancelledItemIds.add(id); updateItemStatus(id, DownloadStatus.skipped); _requestNativeCancel(id); @@ -2065,6 +2233,7 @@ class DownloadQueueNotifier extends Notifier { .toList(growable: false); if (activeIds.isNotEmpty) { + _pausePendingItemIds.addAll(activeIds); _locallyCancelledItemIds.addAll(activeIds); for (final id in activeIds) { _requestNativeCancel(id); @@ -2077,11 +2246,29 @@ class DownloadQueueNotifier extends Notifier { if (!wasProcessing) { _locallyCancelledItemIds.clear(); } + _pausePendingItemIds.clear(); } void pauseQueue() { if (state.isProcessing && !state.isPaused) { - state = state.copyWith(isPaused: true); + final activeIds = state.items + .where( + (item) => + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, + ) + .map((item) => item.id) + .toSet(); + + if (activeIds.isNotEmpty) { + _pausePendingItemIds.addAll(activeIds); + for (final id in activeIds) { + _requestNativeCancel(id); + _requeueItemForPause(id); + } + } + + state = state.copyWith(isPaused: true, currentDownload: null); _notificationService.cancelDownloadNotification(); _log.i('Queue paused'); } @@ -2283,7 +2470,11 @@ class DownloadQueueNotifier extends Notifier { } } + /// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg + static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$'); + String _upgradeToMaxQualityCover(String coverUrl) { + // Spotify CDN upgrade (hash-based size identifiers) const spotifySize300 = 'ab67616d00001e02'; const spotifySize640 = 'ab67616d0000b273'; const spotifySizeMax = 'ab67616d000082c1'; @@ -2292,11 +2483,29 @@ class DownloadQueueNotifier extends Notifier { if (result.contains(spotifySize300)) { result = result.replaceFirst(spotifySize300, spotifySize640); } - if (result.contains(spotifySize640)) { result = result.replaceFirst(spotifySize640, spotifySizeMax); } + // Deezer CDN upgrade (1000x1000 → 1800x1800) + if (result.contains('cdn-images.dzcdn.net')) { + final upgraded = result.replaceFirst( + _deezerSizeRegex, + '/1800x1800-000000-80-0-0.jpg', + ); + if (upgraded != result) { + _log.d('Cover URL upgraded (Deezer): 1800x1800'); + result = upgraded; + } + } + + // Tidal CDN upgrade (1280x1280 → origin) + if (result.contains('resources.tidal.com') && + result.contains('/1280x1280.jpg')) { + result = result.replaceFirst('/1280x1280.jpg', '/origin.jpg'); + _log.d('Cover URL upgraded (Tidal): origin'); + } + return result; } @@ -2322,11 +2531,26 @@ class DownloadQueueNotifier extends Notifier { final backendAlbum = normalizeOptionalString( backendResult['album'] as String?, ); + final backendIsrc = normalizeOptionalString( + backendResult['isrc'] as String?, + ); + final backendCoverUrl = normalizeCoverReference( + backendResult['cover_url']?.toString(), + ); + final backendAlbumArtist = normalizeOptionalString( + backendResult['album_artist'] as String?, + ); - if (backendTrackNum == null && - backendDiscNum == null && - backendYear == null && - backendAlbum == null) { + final hasOverrides = + backendTrackNum != null || + backendDiscNum != null || + backendYear != null || + backendAlbum != null || + backendIsrc != null || + backendCoverUrl != null || + backendAlbumArtist != null; + + if (!hasOverrides) { return baseTrack; } @@ -2335,18 +2559,19 @@ class DownloadQueueNotifier extends Notifier { name: baseTrack.name, artistName: baseTrack.artistName, albumName: backendAlbum ?? baseTrack.albumName, - albumArtist: resolvedAlbumArtist, + albumArtist: backendAlbumArtist ?? resolvedAlbumArtist, artistId: baseTrack.artistId, albumId: baseTrack.albumId, - coverUrl: baseTrack.coverUrl, + coverUrl: backendCoverUrl ?? baseTrack.coverUrl, duration: baseTrack.duration, - isrc: baseTrack.isrc, + isrc: backendIsrc ?? baseTrack.isrc, trackNumber: backendTrackNum ?? baseTrack.trackNumber, discNumber: backendDiscNum ?? baseTrack.discNumber, releaseDate: backendYear ?? baseTrack.releaseDate, deezerId: baseTrack.deezerId, availability: baseTrack.availability, albumType: baseTrack.albumType, + totalTracks: baseTrack.totalTracks, source: baseTrack.source, ); } @@ -2366,7 +2591,7 @@ class DownloadQueueNotifier extends Notifier { } String? coverPath; - var coverUrl = track.coverUrl; + var coverUrl = normalizeRemoteHttpUrl(track.coverUrl); if (coverUrl != null && coverUrl.isNotEmpty) { try { if (settings.maxQualityCover) { @@ -2552,7 +2777,7 @@ class DownloadQueueNotifier extends Notifier { } String? coverPath; - var coverUrl = track.coverUrl; + var coverUrl = normalizeRemoteHttpUrl(track.coverUrl); if (coverUrl != null && coverUrl.isNotEmpty) { try { if (settings.maxQualityCover) { @@ -2720,7 +2945,7 @@ class DownloadQueueNotifier extends Notifier { } String? coverPath; - var coverUrl = track.coverUrl; + var coverUrl = normalizeRemoteHttpUrl(track.coverUrl); if (coverUrl != null && coverUrl.isNotEmpty) { try { if (settings.maxQualityCover) { @@ -3060,6 +3285,7 @@ class DownloadQueueNotifier extends Notifier { } _log.d('Concurrent downloads: ${state.concurrentDownloads}'); await _processQueueParallel(); + final stoppedWhilePaused = state.isPaused; _stopProgressPolling(); @@ -3085,7 +3311,7 @@ class DownloadQueueNotifier extends Notifier { _log.i( 'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart', ); - if (_totalQueuedAtStart > 0) { + if (!stoppedWhilePaused && _totalQueuedAtStart > 0) { await _notificationService.showQueueComplete( completedCount: _completedInSession, failedCount: _failedInSession, @@ -3100,13 +3326,17 @@ class DownloadQueueNotifier extends Notifier { } } - _log.i('Queue processing finished'); + if (stoppedWhilePaused) { + _log.i('Queue processing paused'); + } else { + _log.i('Queue processing finished'); + } state = state.copyWith(isProcessing: false, currentDownload: null); final hasQueuedItems = state.items.any( (item) => item.status == DownloadStatus.queued, ); - if (hasQueuedItems) { + if (hasQueuedItems && !state.isPaused) { _log.i( 'Found queued items after processing finished, restarting queue...', ); @@ -3122,8 +3352,15 @@ class DownloadQueueNotifier extends Notifier { while (true) { if (state.isPaused) { + if (activeDownloads.isEmpty) { + _log.d('Queue is paused and no active downloads remain'); + break; + } _log.d('Queue is paused, waiting for active downloads...'); - await Future.delayed(_queueSchedulingInterval); + await Future.any([ + Future.wait(activeDownloads.values), + Future.delayed(_queueSchedulingInterval), + ]); continue; } @@ -3134,7 +3371,11 @@ class DownloadQueueNotifier extends Notifier { } final queuedItems = state.items - .where((item) => item.status == DownloadStatus.queued) + .where( + (item) => + item.status == DownloadStatus.queued && + !_pausePendingItemIds.contains(item.id), + ) .toList(); if (queuedItems.isEmpty && activeDownloads.isEmpty) { @@ -3179,11 +3420,13 @@ class DownloadQueueNotifier extends Notifier { _stopProgressPolling(); final remainingIds = state.items.map((item) => item.id).toSet(); _locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id)); + _pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id)); } Future _downloadSingleItem(DownloadItem item) async { _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); + var pausedDuringThisRun = false; final currentItem = _findItemById(item.id) ?? item; if (_isLocallyCancelled(item.id, item: currentItem)) { @@ -3191,11 +3434,33 @@ class DownloadQueueNotifier extends Notifier { return; } + if (_isPausePending(item.id)) { + pausedDuringThisRun = true; + _requeueItemForPause(item.id); + _log.i('Download is pause-pending before start, skipping'); + return; + } + state = state.copyWith(currentDownload: item); updateItemStatus(item.id, DownloadStatus.downloading); try { + bool shouldAbortWork(String stage) { + final current = _findItemById(item.id); + if (_isLocallyCancelled(item.id, item: current)) { + _log.i('Download was cancelled $stage, skipping'); + return true; + } + if (_isPausePending(item.id)) { + pausedDuringThisRun = true; + _requeueItemForPause(item.id); + _log.i('Download pause requested $stage, re-queueing'); + return true; + } + return false; + } + final settings = ref.read(settingsProvider); final metadataEmbeddingEnabled = settings.embedMetadata; @@ -3259,6 +3524,8 @@ class DownloadQueueNotifier extends Notifier { albumType: (data['album_type'] as String?) ?? trackToDownload.albumType, + totalTracks: + data['total_tracks'] as int? ?? trackToDownload.totalTracks, source: trackToDownload.source, ); _log.d( @@ -3274,6 +3541,10 @@ class DownloadQueueNotifier extends Notifier { _log.w('Failed to enrich metadata: $e'); _log.w('Stack trace: $stack'); } + + if (shouldAbortWork('during metadata enrichment')) { + return; + } } _log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}'); @@ -3313,10 +3584,12 @@ class DownloadQueueNotifier extends Notifier { settings.folderOrganization, separateSingles: settings.separateSingles, albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, useAlbumArtistForFolders: settings.useAlbumArtistForFolders, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, filterContributingArtistsInAlbumArtist: settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, ) : ''; String? appOutputDir; @@ -3327,10 +3600,12 @@ class DownloadQueueNotifier extends Notifier { settings.folderOrganization, separateSingles: settings.separateSingles, albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, useAlbumArtistForFolders: settings.useAlbumArtistForFolders, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, filterContributingArtistsInAlbumArtist: settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, ); var effectiveOutputDir = initialOutputDir; var effectiveSafMode = isSafMode; @@ -3358,6 +3633,24 @@ class DownloadQueueNotifier extends Notifier { String? genre; String? label; String? copyright; + final extensionState = ref.read(extensionProvider); + final selectedExtensionDownloadProvider = + settings.useExtensionProviders && + extensionState.extensions.any( + (e) => + e.enabled && + e.hasDownloadProvider && + e.id.toLowerCase() == item.service.toLowerCase(), + ); + final trackSource = (trackToDownload.source ?? '').trim().toLowerCase(); + final shouldSkipExtensionSongLinkPrelookup = + trackSource.isNotEmpty && + extensionState.extensions.any( + (e) => + e.enabled && + e.hasMetadataProvider && + e.id.toLowerCase() == trackSource, + ); String? deezerTrackId = trackToDownload.deezerId; if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { @@ -3385,10 +3678,16 @@ class DownloadQueueNotifier extends Notifier { } catch (e) { _log.w('Failed to search Deezer by ISRC: $e'); } + + if (shouldAbortWork('during Deezer ISRC lookup')) { + return; + } } // Fallback: Use SongLink to convert Spotify ID to Deezer ID - if (deezerTrackId == null && + if (!selectedExtensionDownloadProvider && + deezerTrackId == null && + !shouldSkipExtensionSongLinkPrelookup && trackToDownload.id.isNotEmpty && !trackToDownload.id.startsWith('deezer:') && !trackToDownload.id.startsWith('extension:')) { @@ -3452,7 +3751,7 @@ class DownloadQueueNotifier extends Notifier { albumArtist: trackToDownload.albumArtist, artistId: trackToDownload.artistId, albumId: trackToDownload.albumId, - coverUrl: trackToDownload.coverUrl, + coverUrl: normalizeCoverReference(trackToDownload.coverUrl), duration: trackToDownload.duration, isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) ? deezerIsrc @@ -3471,6 +3770,7 @@ class DownloadQueueNotifier extends Notifier { deezerId: deezerTrackId, availability: trackToDownload.availability, albumType: trackToDownload.albumType, + totalTracks: trackToDownload.totalTracks, source: trackToDownload.source, ); _log.d( @@ -3484,6 +3784,19 @@ class DownloadQueueNotifier extends Notifier { } catch (e) { _log.w('Failed to convert Spotify to Deezer via SongLink: $e'); } + + if (shouldAbortWork('during SongLink availability lookup')) { + return; + } + } else if (selectedExtensionDownloadProvider && deezerTrackId == null) { + _log.d( + 'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}', + ); + } else if (shouldSkipExtensionSongLinkPrelookup && + deezerTrackId == null) { + _log.d( + 'Skipping Flutter SongLink Deezer prelookup for extension-sourced track; backend metadata enrichment will resolve identifiers first', + ); } if (deezerTrackId != null && deezerTrackId.isNotEmpty) { @@ -3503,11 +3816,14 @@ class DownloadQueueNotifier extends Notifier { } catch (e) { _log.w('Failed to fetch extended metadata from Deezer: $e'); } + + if (shouldAbortWork('during extended metadata lookup')) { + return; + } } Map result; - final extensionState = ref.read(extensionProvider); final hasActiveExtensions = extensionState.extensions.any( (e) => e.enabled, ); @@ -3541,6 +3857,11 @@ class DownloadQueueNotifier extends Notifier { 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', ); } + + if (!useSaf) { + await _ensureDirExists(outputDir, label: 'Output folder'); + } + _log.d('Output dir: $outputDir'); final normalizedTrackNumber = @@ -3554,10 +3875,26 @@ class DownloadQueueNotifier extends Notifier { ? trackToDownload.discNumber! : 1; + String payloadSpotifyId = trackToDownload.id; + String payloadQobuzId = ''; + String payloadTidalId = ''; + if (trackToDownload.id.startsWith('qobuz:')) { + payloadQobuzId = trackToDownload.id.substring(6); + if (item.service == 'qobuz') { + payloadSpotifyId = ''; + } + } + if (trackToDownload.id.startsWith('tidal:')) { + payloadTidalId = trackToDownload.id.substring(6); + if (item.service == 'tidal') { + payloadSpotifyId = ''; + } + } + final payload = DownloadRequestPayload( isrc: trackToDownload.isrc ?? '', service: item.service, - spotifyId: trackToDownload.id, + spotifyId: payloadSpotifyId, trackName: trackToDownload.name, artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, @@ -3581,6 +3918,8 @@ class DownloadQueueNotifier extends Notifier { genre: genre ?? '', label: label ?? '', copyright: copyright ?? '', + qobuzId: payloadQobuzId, + tidalId: payloadTidalId, deezerId: deezerTrackId ?? '', lyricsMode: settings.lyricsMode, storageMode: storageMode, @@ -3598,8 +3937,7 @@ class DownloadQueueNotifier extends Notifier { ); } - if (_isLocallyCancelled(item.id)) { - _log.i('Download was cancelled before native download start, skipping'); + if (shouldAbortWork('before native download start')) { return; } @@ -3621,10 +3959,12 @@ class DownloadQueueNotifier extends Notifier { settings.folderOrganization, separateSingles: settings.separateSingles, albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, useAlbumArtistForFolders: settings.useAlbumArtistForFolders, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, filterContributingArtistsInAlbumArtist: settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, ); final fallbackResult = await runDownload( useSaf: false, @@ -3641,10 +3981,8 @@ class DownloadQueueNotifier extends Notifier { _log.d('Result: $result'); final itemAfterResult = _findItemById(item.id); - final cancelledAfterResult = - itemAfterResult == null || - _isLocallyCancelled(item.id, item: itemAfterResult); - if (cancelledAfterResult) { + if (itemAfterResult == null || + _isLocallyCancelled(item.id, item: itemAfterResult)) { _log.i('Download was cancelled, skipping result processing'); final filePath = result['file_path'] as String?; if (filePath != null && result['success'] == true) { @@ -3654,6 +3992,18 @@ class DownloadQueueNotifier extends Notifier { return; } + if (_isPausePending(item.id)) { + pausedDuringThisRun = true; + final filePath = result['file_path'] as String?; + if (filePath != null && result['success'] == true) { + await deleteFile(filePath); + _log.d('Deleted paused download file: $filePath'); + } + _requeueItemForPause(item.id); + _log.i('Download pause requested after result, re-queueing'); + return; + } + if (result['success'] == true) { var filePath = result['file_path'] as String?; final reportedFileName = result['file_name'] as String?; @@ -3691,6 +4041,14 @@ class DownloadQueueNotifier extends Notifier { item.service.toLowerCase(); final decryptionKey = (result['decryption_key'] as String?)?.trim() ?? ''; + trackToDownload = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + _log.d( + 'Track coverUrl after download result: ${trackToDownload.coverUrl}', + ); if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) { _log.i('Encrypted stream detected, decrypting via FFmpeg...'); @@ -3814,7 +4172,6 @@ class DownloadQueueNotifier extends Notifier { isContentUriPath && effectiveSafMode && actualService == 'tidal' && - quality != 'HIGH' && filePath.endsWith('.flac') && (mimeType == null || mimeType.contains('flac')); @@ -3924,7 +4281,6 @@ class DownloadQueueNotifier extends Notifier { _log.w('SAF M4A conversion failed: $e'); actualQuality = 'AAC 320kbps'; } finally { - // Clean up temp files try { await File(tempPath).delete(); } catch (_) {} @@ -4002,7 +4358,6 @@ class DownloadQueueNotifier extends Notifier { } catch (e) { _log.w('SAF M4A->FLAC conversion failed: $e'); } finally { - // Clean up temp files try { await File(tempPath).delete(); } catch (_) {} @@ -4378,6 +4733,19 @@ class DownloadQueueNotifier extends Notifier { return; } + if (_isPausePending(item.id)) { + pausedDuringThisRun = true; + if (filePath != null) { + await deleteFile(filePath); + _log.d( + 'Deleted paused download file during finalization: $filePath', + ); + } + _requeueItemForPause(item.id); + _log.i('Download pause requested during finalization, re-queueing'); + return; + } + // SAF downloads should end with content URI. If we still have a // transient FD path, recover URI from SAF metadata to keep history // dedup/exclusion stable. @@ -4599,7 +4967,7 @@ class DownloadQueueNotifier extends Notifier { ? backendAlbum : trackToDownload.albumName, albumArtist: historyAlbumArtist, - coverUrl: trackToDownload.coverUrl, + coverUrl: normalizeCoverReference(trackToDownload.coverUrl), filePath: filePath, storageMode: effectiveSafMode ? 'saf' : 'app', downloadTreeUri: effectiveSafMode @@ -4645,11 +5013,26 @@ class DownloadQueueNotifier extends Notifier { return; } + if (_isPausePending(item.id)) { + pausedDuringThisRun = true; + _requeueItemForPause(item.id); + _log.i('Download pause requested after backend failure, re-queueing'); + return; + } + final errorMsg = result['error'] as String? ?? 'Download failed'; final errorTypeStr = result['error_type'] as String? ?? 'unknown'; if (errorTypeStr == 'cancelled') { - _log.i('Download was cancelled by backend, skipping error handling'); - updateItemStatus(item.id, DownloadStatus.skipped); + if (_isPausePending(item.id)) { + pausedDuringThisRun = true; + _requeueItemForPause(item.id); + _log.i('Download was paused by backend cancellation, re-queueing'); + } else { + _log.i( + 'Download was cancelled by backend, skipping error handling', + ); + updateItemStatus(item.id, DownloadStatus.skipped); + } return; } @@ -4708,6 +5091,13 @@ class DownloadQueueNotifier extends Notifier { return; } + if (_isPausePending(item.id)) { + pausedDuringThisRun = true; + _requeueItemForPause(item.id); + _log.i('Download pause requested after exception, re-queueing'); + return; + } + _log.e('Exception: $e', e, stackTrace); String errorMsg = e.toString(); @@ -4733,6 +5123,10 @@ class DownloadQueueNotifier extends Notifier { } catch (cleanupErr) { _log.e('Post-exception connection cleanup failed: $cleanupErr'); } + } finally { + if (pausedDuringThisRun) { + _pausePendingItemIds.remove(item.id); + } } } } diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index cd2b01a7..e4ffa5f7 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; final _log = AppLogger('ExploreProvider'); @@ -202,9 +203,7 @@ class ExploreNotifier extends Notifier { Future _saveToCache(List sections) async { try { final prefs = await SharedPreferences.getInstance(); - final data = { - 'sections': sections.map((s) => s.toJson()).toList(), - }; + final data = {'sections': sections.map((s) => s.toJson()).toList()}; await prefs.setString(_cacheKey, jsonEncode(data)); await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch); _log.d('Saved ${sections.length} explore sections to cache'); @@ -216,16 +215,16 @@ class ExploreNotifier extends Notifier { /// Fetch home feed from spotify-web extension Future fetchHomeFeed({bool forceRefresh = false}) async { _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); - + // If we have cached content and it's fresh enough, skip network fetch - if (!forceRefresh && - state.hasContent && + if (!forceRefresh && + state.hasContent && state.lastFetched != null && DateTime.now().difference(state.lastFetched!).inMinutes < 5) { _log.d('Using cached home feed (fresh enough)'); return; } - + if (state.isLoading) { _log.d('Home feed fetch already in progress'); return; @@ -237,21 +236,33 @@ class ExploreNotifier extends Notifier { try { final extState = ref.read(extensionProvider); - _log.d('Extensions count: ${extState.extensions.length}'); - + final settings = ref.read(settingsProvider); + final preferredId = settings.homeFeedProvider; + _log.d( + 'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId', + ); + Extension? targetExt; for (final extension in extState.extensions) { if (!extension.enabled || !extension.hasHomeFeed) { continue; } + // If user has a preference, use that + if (preferredId != null && + preferredId.isNotEmpty && + extension.id == preferredId) { + targetExt = extension; + break; + } + // Otherwise take the first available (fallback to spotify-web if found) if (targetExt == null || extension.id == 'spotify-web') { targetExt = extension; - if (extension.id == 'spotify-web') { + if (preferredId == null && extension.id == 'spotify-web') { break; } } } - + if (targetExt == null) { _log.w('No extension with homeFeed capability found'); state = state.copyWith( @@ -260,7 +271,7 @@ class ExploreNotifier extends Notifier { ); return; } - + _log.i('Fetching home feed from ${targetExt.id}...'); final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id); @@ -276,10 +287,7 @@ class ExploreNotifier extends Notifier { _log.d('getExtensionHomeFeed success=$success'); if (!success) { final error = result['error'] as String? ?? 'Unknown error'; - state = state.copyWith( - isLoading: false, - error: error, - ); + state = state.copyWith(isLoading: false, error: error); return; } @@ -291,10 +299,12 @@ class ExploreNotifier extends Notifier { .toList(); _log.i('Fetched ${sections.length} sections'); - + if (sections.isNotEmpty && sections.first.items.isNotEmpty) { final firstItem = sections.first.items.first; - _log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}'); + _log.d( + 'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}', + ); } final localGreeting = _getLocalGreeting(); @@ -311,10 +321,7 @@ class ExploreNotifier extends Notifier { _saveToCache(sections); } catch (e, stack) { _log.e('Error fetching home feed: $e', e, stack); - state = state.copyWith( - isLoading: false, - error: e.toString(), - ); + state = state.copyWith(isLoading: false, error: e.toString()); } } @@ -325,7 +332,6 @@ class ExploreNotifier extends Notifier { Future refresh() => fetchHomeFeed(forceRefresh: true); } - final exploreProvider = NotifierProvider(() { return ExploreNotifier(); }); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index f53aca0c..43f25997 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -504,6 +504,11 @@ class ExtensionNotifier extends Notifier { } Future _cleanupExtensions({required String reason}) async { + if (!PlatformBridge.supportsExtensionSystem) { + _cleanupInFlight = false; + return; + } + try { await PlatformBridge.cleanupExtensions(); _log.d('Extensions cleaned up ($reason)'); @@ -519,6 +524,17 @@ class ExtensionNotifier extends Notifier { state = state.copyWith(isLoading: true, error: null); + if (!PlatformBridge.supportsExtensionSystem) { + state = state.copyWith( + isInitialized: true, + isLoading: false, + extensions: const [], + error: null, + ); + _log.i('Extension system disabled on this platform'); + return; + } + try { await PlatformBridge.initExtensionSystem(extensionsDir, dataDir); await loadExtensions(extensionsDir); @@ -823,11 +839,19 @@ class ExtensionNotifier extends Notifier { List priority; if (savedJson != null) { final saved = jsonDecode(savedJson) as List; - priority = saved.map((e) => e as String).toList(); + priority = _sanitizeMetadataProviderPriority( + saved.map((e) => e as String).toList(), + ); _log.d('Loaded metadata provider priority from prefs: $priority'); + await prefs.setString( + _metadataProviderPriorityKey, + jsonEncode(priority), + ); await PlatformBridge.setMetadataProviderPriority(priority); } else { - priority = await PlatformBridge.getMetadataProviderPriority(); + priority = _sanitizeMetadataProviderPriority( + await PlatformBridge.getMetadataProviderPriority(), + ); _log.d('Using default metadata provider priority: $priority'); } @@ -840,11 +864,15 @@ class ExtensionNotifier extends Notifier { Future setMetadataProviderPriority(List priority) async { try { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority)); + final sanitized = _sanitizeMetadataProviderPriority(priority); + await prefs.setString( + _metadataProviderPriorityKey, + jsonEncode(sanitized), + ); - await PlatformBridge.setMetadataProviderPriority(priority); - state = state.copyWith(metadataProviderPriority: priority); - _log.d('Saved metadata provider priority: $priority'); + await PlatformBridge.setMetadataProviderPriority(sanitized); + state = state.copyWith(metadataProviderPriority: sanitized); + _log.d('Saved metadata provider priority: $sanitized'); } catch (e) { _log.e('Failed to set metadata provider priority: $e'); state = state.copyWith(error: e.toString()); @@ -880,7 +908,7 @@ class ExtensionNotifier extends Notifier { } List getAllMetadataProviders() { - final providers = ['deezer', 'spotify']; + final providers = ['deezer', 'qobuz', 'tidal']; for (final ext in state.extensions) { if (ext.enabled && ext.hasMetadataProvider) { providers.add(ext.id); @@ -889,6 +917,25 @@ class ExtensionNotifier extends Notifier { return providers; } + List _sanitizeMetadataProviderPriority(List input) { + final allowed = getAllMetadataProviders().toSet(); + final result = []; + + for (final provider in input) { + if (allowed.contains(provider) && !result.contains(provider)) { + result.add(provider); + } + } + + for (final provider in const ['deezer', 'qobuz', 'tidal']) { + if (!result.contains(provider)) { + result.add(provider); + } + } + + return result; + } + List get searchProviders { return state.extensions .where((ext) => ext.enabled && ext.hasCustomSearch) diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index fe9f21ad..ab5416c7 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -9,10 +9,11 @@ import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/local_library_scan_prefs.dart'; +import 'package:spotiflac_android/utils/path_match_keys.dart'; final _log = AppLogger('LocalLibrary'); -const _lastScannedAtKey = 'local_library_last_scanned_at'; const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count'; final _prefs = SharedPreferences.getInstance(); @@ -119,7 +120,7 @@ class LocalLibraryNotifier extends Notifier { final LibraryDatabase _db = LibraryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance; final NotificationService _notificationService = NotificationService(); - static const _progressPollingInterval = Duration(milliseconds: 800); + static const _progressPollingInterval = Duration(milliseconds: 1200); Timer? _progressTimer; Timer? _progressStreamBootstrapTimer; StreamSubscription>? _progressStreamSub; @@ -164,10 +165,7 @@ class LocalLibraryNotifier extends Notifier { var excludedDownloadedCount = 0; try { final prefs = await prefsFuture; - final lastScannedAtStr = prefs.getString(_lastScannedAtKey); - if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) { - lastScannedAt = DateTime.tryParse(lastScannedAtStr); - } + lastScannedAt = readLocalLibraryLastScannedAt(prefs); excludedDownloadedCount = prefs.getInt(_excludedDownloadedCountKey) ?? 0; } catch (e) { @@ -193,74 +191,11 @@ class LocalLibraryNotifier extends Notifier { await _loadFromDatabase(); } - Set _buildPathMatchKeys(String? filePath) { - final raw = filePath?.trim() ?? ''; - if (raw.isEmpty) return const {}; - - final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw; - final keys = {}; - - void addNormalized(String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) return; - keys.add(trimmed); - keys.add(trimmed.toLowerCase()); - if (trimmed.contains('\\')) { - final slash = trimmed.replaceAll('\\', '/'); - keys.add(slash); - keys.add(slash.toLowerCase()); - } - if (trimmed.contains('%')) { - try { - final decoded = Uri.decodeFull(trimmed); - keys.add(decoded); - keys.add(decoded.toLowerCase()); - } catch (_) {} - } - - Uri? parsed; - try { - parsed = Uri.parse(trimmed); - } catch (_) {} - - if (parsed != null && parsed.hasScheme) { - final noQueryOrFragment = parsed.replace(query: null, fragment: null); - keys.add(noQueryOrFragment.toString()); - keys.add(noQueryOrFragment.toString().toLowerCase()); - - if (parsed.scheme == 'file') { - try { - final fileOnly = parsed.toFilePath(); - if (fileOnly.isNotEmpty) { - keys.add(fileOnly); - keys.add(fileOnly.toLowerCase()); - if (fileOnly.contains('\\')) { - final slash = fileOnly.replaceAll('\\', '/'); - keys.add(slash); - keys.add(slash.toLowerCase()); - } - } - } catch (_) {} - } - } else if (trimmed.startsWith('/')) { - try { - final asFileUri = Uri.file(trimmed).toString(); - keys.add(asFileUri); - keys.add(asFileUri.toLowerCase()); - } catch (_) {} - } - } - - addNormalized(cleaned); - - return keys; - } - bool _isDownloadedPath(String? filePath, Set downloadedPathKeys) { if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) { return false; } - final candidateKeys = _buildPathMatchKeys(filePath); + final candidateKeys = buildPathMatchKeys(filePath); for (final key in candidateKeys) { if (downloadedPathKeys.contains(key)) { return true; @@ -322,8 +257,9 @@ class LocalLibraryNotifier extends Notifier { String? resolvedPath; bool didStartSecurityAccess = false; if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { - resolvedPath = - await PlatformBridge.startAccessingIosBookmark(iosBookmark); + resolvedPath = await PlatformBridge.startAccessingIosBookmark( + iosBookmark, + ); if (resolvedPath != null) { didStartSecurityAccess = true; _log.i('Started iOS security-scoped access: $resolvedPath'); @@ -354,7 +290,7 @@ class LocalLibraryNotifier extends Notifier { }; final downloadedPathKeys = {}; for (final path in allHistoryPaths) { - downloadedPathKeys.addAll(_buildPathMatchKeys(path)); + downloadedPathKeys.addAll(buildPathMatchKeys(path)); } _log.i( 'Excluding ${allHistoryPaths.length} downloaded files from library scan ' @@ -376,7 +312,6 @@ class LocalLibraryNotifier extends Notifier { int skippedDownloads = 0; for (final json in results) { final filePath = json['filePath'] as String?; - // Skip files that are already in download history if (_isDownloadedPath(filePath, downloadedPathKeys)) { skippedDownloads++; continue; @@ -394,11 +329,16 @@ class LocalLibraryNotifier extends Notifier { if (items.isNotEmpty) { await _db.upsertBatch(items.map((e) => e.toJson()).toList()); } + final persistedItems = + (await _db.getAll()) + .map(LocalLibraryItem.fromJson) + .toList(growable: false) + ..sort(_compareLibraryItems); final now = DateTime.now(); try { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_lastScannedAtKey, now.toIso8601String()); + await writeLocalLibraryLastScannedAt(prefs, now); await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads); _log.d('Saved lastScannedAt: $now'); } catch (e) { @@ -406,7 +346,7 @@ class LocalLibraryNotifier extends Notifier { } state = state.copyWith( - items: items, + items: persistedItems, isScanning: false, scanProgress: 100, lastScannedAt: now, @@ -415,11 +355,11 @@ class LocalLibraryNotifier extends Notifier { ); _log.i( - 'Full scan complete: ${items.length} tracks found, ' + 'Full scan complete: ${persistedItems.length} tracks found, ' '$skippedDownloads already in downloads', ); await _showScanCompleteNotification( - totalTracks: items.length, + totalTracks: persistedItems.length, excludedDownloadedCount: skippedDownloads, errorCount: state.scanErrorCount, ); @@ -440,18 +380,41 @@ class LocalLibraryNotifier extends Notifier { _log.i('Backfilled ${backfilledModTimes.length} legacy mod times'); } - // Use appropriate incremental scan method based on SAF or not - final Map result; - if (isSaf) { - result = await PlatformBridge.scanSafTreeIncremental( - effectiveFolderPath, - existingFiles, - ); - } else { - result = await PlatformBridge.scanLibraryFolderIncremental( - effectiveFolderPath, - existingFiles, - ); + final useSnapshotBridge = + Platform.isAndroid && existingFiles.isNotEmpty; + final snapshotPath = useSnapshotBridge + ? await _db.writeFileModTimesSnapshot() + : null; + + Map result; + try { + if (isSaf) { + result = useSnapshotBridge && snapshotPath != null + ? await PlatformBridge.scanSafTreeIncrementalFromSnapshot( + effectiveFolderPath, + snapshotPath, + ) + : await PlatformBridge.scanSafTreeIncremental( + effectiveFolderPath, + existingFiles, + ); + } else { + result = useSnapshotBridge && snapshotPath != null + ? await PlatformBridge.scanLibraryFolderIncrementalFromSnapshot( + effectiveFolderPath, + snapshotPath, + ) + : await PlatformBridge.scanLibraryFolderIncremental( + effectiveFolderPath, + existingFiles, + ); + } + } finally { + if (snapshotPath != null) { + try { + await File(snapshotPath).delete(); + } catch (_) {} + } } if (_scanCancelRequested) { @@ -460,7 +423,6 @@ class LocalLibraryNotifier extends Notifier { return; } - // Parse incremental scan result // SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths' final scannedList = (result['files'] as List?) ?? @@ -482,8 +444,14 @@ class LocalLibraryNotifier extends Notifier { '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', ); + // Build the incremental merge base from SQLite, not the current + // provider state. Startup auto-scan can fire before `state.items` has + // finished loading, which would otherwise drop unchanged rows from the + // in-memory library until a manual full rescan. + final existingJson = await _db.getAll(); final currentByPath = { - for (final item in state.items) item.filePath: item, + for (final item in existingJson.map(LocalLibraryItem.fromJson)) + item.filePath: item, }; final existingDownloadedPaths = []; currentByPath.removeWhere((path, _) { @@ -526,7 +494,6 @@ class LocalLibraryNotifier extends Notifier { } } - // Delete removed items if (deletedPaths.isNotEmpty) { final deleteCount = await _db.deleteByPaths(deletedPaths); for (final path in deletedPaths) { @@ -535,13 +502,16 @@ class LocalLibraryNotifier extends Notifier { _log.i('Deleted $deleteCount items from database'); } - final items = currentByPath.values.toList(growable: false) - ..sort(_compareLibraryItems); + final items = + (await _db.getAll()) + .map(LocalLibraryItem.fromJson) + .toList(growable: false) + ..sort(_compareLibraryItems); final now = DateTime.now(); try { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_lastScannedAtKey, now.toIso8601String()); + await writeLocalLibraryLastScannedAt(prefs, now); await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads); _log.d('Saved lastScannedAt: $now'); } catch (e) { @@ -834,8 +804,9 @@ class LocalLibraryNotifier extends Notifier { Future cleanupMissingFiles({String? iosBookmark}) async { bool didStartSecurityAccess = false; if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { - final resolved = - await PlatformBridge.startAccessingIosBookmark(iosBookmark); + final resolved = await PlatformBridge.startAccessingIosBookmark( + iosBookmark, + ); if (resolved != null) { didStartSecurityAccess = true; } @@ -858,7 +829,7 @@ class LocalLibraryNotifier extends Notifier { try { final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_lastScannedAtKey); + await clearLocalLibraryLastScannedAt(prefs); await prefs.remove(_excludedDownloadedCountKey); } catch (e) { _log.w('Failed to clear lastScannedAt: $e'); diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index c35f6a00..bcaa14c0 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -24,6 +24,9 @@ class PlaybackController extends Notifier { String coverUrl = '', Track? track, }) async { + if (isCueVirtualPath(path)) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } _log.d('Opening external player for "$title" by $artist: $path'); await openFile(path); } @@ -32,11 +35,16 @@ class PlaybackController extends Notifier { if (tracks.isEmpty) return; final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex); + var skippedCueVirtualTrack = false; for (final track in orderedTracks) { final resolvedPath = await _resolveTrackPath(track); if (resolvedPath == null) { continue; } + if (isCueVirtualPath(resolvedPath)) { + skippedCueVirtualTrack = true; + continue; + } _log.d( 'Opening first available external track for list playback: ' @@ -46,6 +54,10 @@ class PlaybackController extends Notifier { return; } + if (skippedCueVirtualTrack) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } + throw Exception( 'No local audio file is available to open. Download the track first.', ); diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index 8a2ba671..7b9cc15a 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -70,7 +70,7 @@ class RecentAccessItem { /// State for recent access history class RecentAccessState { final List items; - final Set hiddenDownloadIds; // IDs of downloads hidden from recents + final Set hiddenDownloadIds; final bool isLoaded; const RecentAccessState({ diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index a7858319..f2309d5d 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,20 +1,22 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 4; +const _currentMigrationVersion = 6; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { - static const List _youtubeOpusSupportedBitrates = [128, 256]; + static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); @@ -37,13 +39,12 @@ class SettingsNotifier extends Notifier { state = AppSettings.fromJson(jsonDecode(json)); await _runMigrations(prefs); + await _normalizeIosDownloadDirectoryIfNeeded(); await _normalizeYouTubeBitratesIfNeeded(); await _normalizeSongLinkRegionIfNeeded(); } - await _loadSpotifyClientSecret(prefs); - - _applySpotifyCredentials(); + await _retireBuiltInSpotifyProvider(); LogBuffer.loggingEnabled = state.enableLogging; @@ -52,6 +53,8 @@ class SettingsNotifier extends Notifier { } void _syncLyricsSettingsToBackend() { + if (!PlatformBridge.supportsCoreBackend) return; + PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) { _log.w('Failed to sync lyrics providers to backend: $e'); }); @@ -67,6 +70,8 @@ class SettingsNotifier extends Notifier { } void _syncNetworkCompatibilitySettingsToBackend() { + if (!PlatformBridge.supportsCoreBackend) return; + final compatibilityMode = state.networkCompatibilityMode; PlatformBridge.setNetworkCompatibilityOptions( allowHttp: compatibilityMode, @@ -105,6 +110,17 @@ class SettingsNotifier extends Notifier { } state = state.copyWith(lyricsProviders: updatedProviders); } + if (state.metadataSource != 'deezer' || + state.spotifyClientId.isNotEmpty || + state.spotifyClientSecret.isNotEmpty || + state.useCustomSpotifyCredentials) { + state = state.copyWith( + metadataSource: 'deezer', + spotifyClientId: '', + spotifyClientSecret: '', + useCustomSpotifyCredentials: false, + ); + } state = state.copyWith(lastSeenVersion: AppInfo.version); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); @@ -180,6 +196,20 @@ class SettingsNotifier extends Notifier { await _saveSettings(); } + Future _normalizeIosDownloadDirectoryIfNeeded() async { + if (!Platform.isIOS) return; + + final currentDir = state.downloadDirectory.trim(); + if (currentDir.isEmpty) return; + + final normalizedDir = await validateOrFixIosPath(currentDir); + if (normalizedDir == currentDir) return; + + _log.i('Normalized iOS download directory: $currentDir -> $normalizedDir'); + state = state.copyWith(downloadDirectory: normalizedDir); + await _saveSettings(); + } + String _normalizeSongLinkRegion(String region) { final normalized = region.trim().toUpperCase(); if (_isoRegionPattern.hasMatch(normalized)) return normalized; @@ -193,49 +223,28 @@ class SettingsNotifier extends Notifier { await _saveSettings(); } - Future _loadSpotifyClientSecret(SharedPreferences prefs) async { + Future _retireBuiltInSpotifyProvider() async { final storedSecret = await _secureStorage.read( key: _spotifyClientSecretKey, ); - final prefsSecret = state.spotifyClientSecret; - - if ((storedSecret == null || storedSecret.isEmpty) && - prefsSecret.isNotEmpty) { - await _secureStorage.write( - key: _spotifyClientSecretKey, - value: prefsSecret, - ); - } - - final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty) - ? storedSecret - : (prefsSecret.isNotEmpty ? prefsSecret : ''); - - if (effectiveSecret != state.spotifyClientSecret) { - state = state.copyWith(spotifyClientSecret: effectiveSecret); - } - - if (prefsSecret.isNotEmpty) { - await _saveSettings(); - } - } - - Future _storeSpotifyClientSecret(String secret) async { - if (secret.isEmpty) { + if (storedSecret != null && storedSecret.isNotEmpty) { await _secureStorage.delete(key: _spotifyClientSecretKey); - } else { - await _secureStorage.write(key: _spotifyClientSecretKey, value: secret); } - } - Future _applySpotifyCredentials() async { - if (state.spotifyClientId.isNotEmpty && - state.spotifyClientSecret.isNotEmpty) { - await PlatformBridge.setSpotifyCredentials( - state.spotifyClientId, - state.spotifyClientSecret, - ); + if (state.metadataSource == 'deezer' && + state.spotifyClientId.isEmpty && + state.spotifyClientSecret.isEmpty && + !state.useCustomSpotifyCredentials) { + return; } + + state = state.copyWith( + metadataSource: 'deezer', + spotifyClientId: '', + spotifyClientSecret: '', + useCustomSpotifyCredentials: false, + ); + await _saveSettings(); } void setDefaultService(String service) { @@ -366,6 +375,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setCreatePlaylistFolder(bool enabled) { + state = state.copyWith(createPlaylistFolder: enabled); + _saveSettings(); + } + void setUseAlbumArtistForFolders(bool enabled) { state = state.copyWith(useAlbumArtistForFolders: enabled); _saveSettings(); @@ -396,43 +410,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setSpotifyClientId(String clientId) { - state = state.copyWith(spotifyClientId: clientId); - _saveSettings(); - } - - Future setSpotifyClientSecret(String clientSecret) async { - state = state.copyWith(spotifyClientSecret: clientSecret); - await _storeSpotifyClientSecret(clientSecret); - _saveSettings(); - } - - Future setSpotifyCredentials( - String clientId, - String clientSecret, - ) async { - state = state.copyWith( - spotifyClientId: clientId, - spotifyClientSecret: clientSecret, - ); - await _storeSpotifyClientSecret(clientSecret); - _saveSettings(); - _applySpotifyCredentials(); - } - - Future clearSpotifyCredentials() async { - state = state.copyWith(spotifyClientId: '', spotifyClientSecret: ''); - await _storeSpotifyClientSecret(''); - _saveSettings(); - _applySpotifyCredentials(); - } - - void setUseCustomSpotifyCredentials(bool enabled) { - state = state.copyWith(useCustomSpotifyCredentials: enabled); - _saveSettings(); - _applySpotifyCredentials(); - } - void setMetadataSource(String source) { state = state.copyWith(metadataSource: source); _saveSettings(); @@ -447,6 +424,15 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setHomeFeedProvider(String? provider) { + if (provider == null || provider.isEmpty) { + state = state.copyWith(clearHomeFeedProvider: true); + } else { + state = state.copyWith(homeFeedProvider: provider); + } + _saveSettings(); + } + void setEnableLogging(bool enabled) { state = state.copyWith(enableLogging: enabled); _saveSettings(); @@ -550,6 +536,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setLocalLibraryAutoScan(String mode) { + state = state.copyWith(localLibraryAutoScan: mode); + _saveSettings(); + } + void setTutorialComplete() { state = state.copyWith(hasCompletedTutorial: true); _saveSettings(); diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index e967825a..bac55c5c 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -6,6 +7,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; final _log = AppLogger('StoreProvider'); final RegExp _leadingVersionPrefix = RegExp(r'^v'); +const _registryUrlPrefKey = 'store_registry_url'; int compareVersions(String v1, String v2) { final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.'); @@ -125,6 +127,7 @@ class StoreState { final String? downloadingId; final String? error; final bool isInitialized; + final String registryUrl; const StoreState({ this.extensions = const [], @@ -135,8 +138,12 @@ class StoreState { this.downloadingId, this.error, this.isInitialized = false, + this.registryUrl = '', }); + /// Whether a registry URL has been configured by the user. + bool get hasRegistryUrl => registryUrl.isNotEmpty; + StoreState copyWith({ List? extensions, String? selectedCategory, @@ -149,6 +156,7 @@ class StoreState { String? error, bool clearError = false, bool? isInitialized, + String? registryUrl, }) { return StoreState( extensions: extensions ?? this.extensions, @@ -159,6 +167,7 @@ class StoreState { downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId), error: clearError ? null : (error ?? this.error), isInitialized: isInitialized ?? this.isInitialized, + registryUrl: registryUrl ?? this.registryUrl, ); } @@ -201,15 +210,84 @@ class StoreNotifier extends Notifier { try { await PlatformBridge.initExtensionStore(cacheDir); - await refresh(); + + // Load saved registry URL from SharedPreferences + final prefs = await SharedPreferences.getInstance(); + final savedUrl = prefs.getString(_registryUrlPrefKey) ?? ''; + + if (savedUrl.isNotEmpty) { + await PlatformBridge.setStoreRegistryUrl(savedUrl); + state = state.copyWith(registryUrl: savedUrl); + await refresh(); + } + state = state.copyWith(isInitialized: true, isLoading: false); - _log.i('Extension store initialized'); + _log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})'); } catch (e) { _log.e('Failed to initialize store: $e'); state = state.copyWith(isLoading: false, error: e.toString()); } } + /// Sets the registry URL, saves it, and refreshes the store. + /// The Go backend handles URL normalisation (GitHub repo → raw URL, branch detection). + Future setRegistryUrl(String url) async { + final trimmed = url.trim(); + if (trimmed.isEmpty) { + state = state.copyWith(error: 'Please enter a valid URL'); + return; + } + + state = state.copyWith(isLoading: true, clearError: true); + + try { + // Go backend resolves GitHub URLs (detects default branch) and validates HTTPS. + await PlatformBridge.setStoreRegistryUrl(trimmed); + + // Read back the resolved URL (may differ from input after normalisation). + final resolvedUrl = await PlatformBridge.getStoreRegistryUrl(); + + // Persist to SharedPreferences + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_registryUrlPrefKey, resolvedUrl); + + state = state.copyWith( + registryUrl: resolvedUrl, + extensions: const [], // Clear old extensions + ); + + _log.i('Registry URL set to: $resolvedUrl'); + await refresh(forceRefresh: true); + } catch (e) { + _log.e('Failed to set registry URL: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Removes the saved registry URL and fully detaches the repo from backend. + Future removeRegistryUrl() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_registryUrlPrefKey); + + // Reset the URL in Go backend memory AND clear its cache + await PlatformBridge.clearStoreRegistryUrl(); + + state = state.copyWith( + registryUrl: '', + extensions: const [], + clearCategory: true, + searchQuery: '', + clearError: true, + ); + + _log.i('Registry URL removed'); + } catch (e) { + _log.e('Failed to remove registry URL: $e'); + state = state.copyWith(error: e.toString()); + } + } + Future refresh({bool forceRefresh = false}) async { state = state.copyWith(isLoading: true, clearError: true); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 271f22f7..5de7d16b 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -18,7 +19,7 @@ class TrackState { final String? artistName; final String? coverUrl; final String? headerImageUrl; // Artist header image for background - final int? monthlyListeners; // Artist monthly listeners + final int? monthlyListeners; final List? artistAlbums; // For artist page final List? artistTopTracks; // Artist's popular tracks final List? searchArtists; // For search results @@ -30,6 +31,8 @@ class TrackState { searchExtensionId; // Extension ID used for current search results final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist") + final String? + searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz") const TrackState({ this.tracks = const [], @@ -52,6 +55,7 @@ class TrackState { this.isShowingRecentAccess = false, this.searchExtensionId, this.selectedSearchFilter, + this.searchSource, }); bool get hasContent => @@ -83,6 +87,8 @@ class TrackState { String? searchExtensionId, String? selectedSearchFilter, bool clearSelectedSearchFilter = false, + String? searchSource, + bool clearSearchSource = false, }) { return TrackState( tracks: tracks ?? this.tracks, @@ -108,6 +114,9 @@ class TrackState { selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter), + searchSource: clearSearchSource + ? null + : (searchSource ?? this.searchSource), ); } } @@ -278,7 +287,9 @@ class TrackNotifier extends Notifier { playlistName: type == 'playlist' ? result['name'] as String? : null, - coverUrl: result['cover_url'] as String?, + coverUrl: normalizeCoverReference( + result['cover_url']?.toString(), + ), searchExtensionId: extensionId, ); return; @@ -305,10 +316,12 @@ class TrackNotifier extends Notifier { isLoading: false, artistId: artistData['id'] as String?, artistName: artistData['name'] as String?, - coverUrl: - artistData['image_url'] as String? ?? - artistData['images'] as String?, - headerImageUrl: artistData['header_image'] as String?, + coverUrl: normalizeRemoteHttpUrl( + (artistData['image_url'] ?? artistData['images'])?.toString(), + ), + headerImageUrl: normalizeRemoteHttpUrl( + artistData['header_image']?.toString(), + ), monthlyListeners: artistData['listeners'] as int?, artistAlbums: albums, artistTopTracks: topTracks.isNotEmpty ? topTracks : null, @@ -349,7 +362,7 @@ class TrackNotifier extends Notifier { isLoading: false, albumId: id, albumName: albumInfo['name'] as String?, - coverUrl: albumInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), ); _preWarmCacheForTracks(tracks); } else if (type == 'playlist') { @@ -363,7 +376,9 @@ class TrackNotifier extends Notifier { tracks: tracks, isLoading: false, playlistName: playlistInfo['name'] as String?, - coverUrl: playlistInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl( + playlistInfo['images']?.toString(), + ), ); _preWarmCacheForTracks(tracks); } else if (type == 'artist') { @@ -377,7 +392,78 @@ class TrackNotifier extends Notifier { isLoading: false, artistId: artistInfo['id'] as String?, artistName: artistInfo['name'] as String?, - coverUrl: artistInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), + artistAlbums: albums, + ); + } + return; + } + + if (url.contains('qobuz.com') || url.startsWith('qobuzapp://')) { + _log.i('Detected Qobuz URL, parsing...'); + final parsed = await PlatformBridge.parseQobuzUrl(url); + if (!_isRequestValid(requestId)) return; + + final type = parsed['type'] as String; + final id = parsed['id'] as String; + + final metadata = await PlatformBridge.getQobuzMetadata(type, id); + if (!_isRequestValid(requestId)) return; + + if (type == 'track') { + final trackData = metadata['track'] as Map; + final track = _parseTrack(trackData); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + ); + } else if (type == 'album') { + final albumInfo = metadata['album_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + state = TrackState( + tracks: tracks, + isLoading: false, + albumId: 'qobuz:$id', + albumName: albumInfo['name'] as String?, + coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'playlist') { + final playlistInfo = + metadata['playlist_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + final owner = playlistInfo['owner'] as Map?; + final playlistName = + (playlistInfo['name'] ?? owner?['name']) as String?; + final coverUrl = normalizeRemoteHttpUrl( + (playlistInfo['images'] ?? owner?['images'])?.toString(), + ); + state = TrackState( + tracks: tracks, + isLoading: false, + playlistName: playlistName, + coverUrl: coverUrl, + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'artist') { + final artistInfo = metadata['artist_info'] as Map; + final albumsList = metadata['albums'] as List; + final albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + state = TrackState( + tracks: [], + isLoading: false, + artistId: artistInfo['id'] as String?, + artistName: artistInfo['name'] as String?, + coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), artistAlbums: albums, ); } @@ -392,66 +478,78 @@ class TrackNotifier extends Notifier { final type = parsed['type'] as String; final id = parsed['id'] as String; - _log.i('Tidal URL parsed: type=$type, id=$id'); + final metadata = await PlatformBridge.getTidalMetadata(type, id); + if (!_isRequestValid(requestId)) return; - // For track URLs, convert to Spotify/Deezer and fetch metadata from there if (type == 'track') { - try { - _log.i('Converting Tidal track to Spotify/Deezer via SongLink...'); - final conversion = await PlatformBridge.convertTidalToSpotifyDeezer( - url, - ); - if (!_isRequestValid(requestId)) return; - - final spotifyUrl = conversion['spotify_url'] as String?; - final deezerUrl = conversion['deezer_url'] as String?; - - if (spotifyUrl != null && spotifyUrl.isNotEmpty) { - _log.i('Found Spotify URL: $spotifyUrl, fetching metadata...'); - final metadata = - await PlatformBridge.getSpotifyMetadataWithFallback( - spotifyUrl, - ); - if (!_isRequestValid(requestId)) return; - - final trackData = metadata['track'] as Map; - final track = _parseTrack(trackData); - state = TrackState( - tracks: [track], - isLoading: false, - coverUrl: track.coverUrl, - ); - return; - } else if (deezerUrl != null && deezerUrl.isNotEmpty) { - _log.i('Found Deezer URL: $deezerUrl, fetching metadata...'); - final deezerParsed = await PlatformBridge.parseDeezerUrl( - deezerUrl, - ); - final metadata = await PlatformBridge.getDeezerMetadata( - 'track', - deezerParsed['id'] as String, - ); - if (!_isRequestValid(requestId)) return; - - final trackData = metadata['track'] as Map; - final track = _parseTrack(trackData); - state = TrackState( - tracks: [track], - isLoading: false, - coverUrl: track.coverUrl, - ); - return; - } - } catch (e) { - _log.w('Failed to convert Tidal URL via SongLink: $e'); - } + final trackData = metadata['track'] as Map; + final track = _parseTrack(trackData); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + ); + } else if (type == 'album') { + final albumInfo = metadata['album_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + state = TrackState( + tracks: tracks, + isLoading: false, + albumId: 'tidal:$id', + albumName: albumInfo['name'] as String?, + coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'playlist') { + final playlistInfo = + metadata['playlist_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + final owner = playlistInfo['owner'] as Map?; + final playlistName = + (playlistInfo['name'] ?? owner?['name']) as String?; + final coverUrl = normalizeRemoteHttpUrl( + (playlistInfo['images'] ?? owner?['images'])?.toString(), + ); + state = TrackState( + tracks: tracks, + isLoading: false, + playlistName: playlistName, + coverUrl: coverUrl, + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'artist') { + final artistInfo = metadata['artist_info'] as Map; + final albumsList = metadata['albums'] as List; + final albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + state = TrackState( + tracks: [], + isLoading: false, + artistId: artistInfo['id'] as String?, + artistName: artistInfo['name'] as String?, + coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), + artistAlbums: albums, + ); } + return; + } - // For album/artist/playlist, not yet supported + // If URL doesn't match any known service, it's unrecognized + final isSpotifyUrl = + url.contains('open.spotify.com') || + url.contains('spotify.link') || + url.startsWith('spotify:'); + if (!isSpotifyUrl) { state = TrackState( isLoading: false, - error: - 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.', + error: 'url_not_recognized', hasSearchText: state.hasSearchText, ); return; @@ -491,7 +589,7 @@ class TrackNotifier extends Notifier { isLoading: false, albumId: parsed['id'] as String?, albumName: albumInfo['name'] as String?, - coverUrl: albumInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), ); _preWarmCacheForTracks(tracks); } else if (type == 'playlist') { @@ -501,11 +599,16 @@ class TrackNotifier extends Notifier { .map((t) => _parseTrack(t as Map)) .toList(); final owner = playlistInfo['owner'] as Map?; + final playlistName = + (playlistInfo['name'] ?? owner?['name']) as String?; + final coverUrl = normalizeRemoteHttpUrl( + (playlistInfo['images'] ?? owner?['images'])?.toString(), + ); state = TrackState( tracks: tracks, isLoading: false, - playlistName: owner?['name'] as String?, - coverUrl: owner?['images'] as String?, + playlistName: playlistName, + coverUrl: coverUrl, ); _preWarmCacheForTracks(tracks); } else if (type == 'artist') { @@ -519,7 +622,7 @@ class TrackNotifier extends Notifier { isLoading: false, artistId: artistInfo['id'] as String?, artistName: artistInfo['name'] as String?, - coverUrl: artistInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), artistAlbums: albums, ); } @@ -535,8 +638,8 @@ class TrackNotifier extends Notifier { Future search( String query, { - String? metadataSource, String? filterOverride, + String? builtInSearchProvider, }) async { final requestId = ++_currentRequestId; @@ -556,65 +659,72 @@ class TrackNotifier extends Notifier { final hasActiveMetadataExtensions = extensionState.extensions.any( (e) => e.enabled && e.hasMetadataProvider, ); - final searchProvider = settings.searchProvider; - final useExtensions = - settings.useExtensionProviders && - hasActiveMetadataExtensions && - searchProvider != null && - searchProvider.isNotEmpty; + final includeExtensions = + settings.useExtensionProviders && hasActiveMetadataExtensions; - final source = metadataSource ?? 'deezer'; + // Determine the effective search provider + final effectiveProvider = builtInSearchProvider ?? 'deezer'; _log.i( - 'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter', + 'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter', ); Map results; - List extensionTracks = []; + List> metadataTrackResults = []; - if (useExtensions) { + // Only use metadata providers for Deezer search (default behavior) + if (effectiveProvider == 'deezer') { try { - _log.d('Calling extension search API...'); - final extResults = await PlatformBridge.searchTracksWithExtensions( - query, - limit: 20, + _log.d('Calling metadata provider search API...'); + metadataTrackResults = + await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 20, + includeExtensions: includeExtensions, + ); + _log.i( + 'Metadata providers returned ${metadataTrackResults.length} tracks', ); - _log.i('Extensions returned ${extResults.length} tracks'); - - for (final t in extResults) { - try { - extensionTracks.add(_parseSearchTrack(t)); - } catch (e) { - _log.e('Failed to parse extension track: $e', e); - } - } } catch (e) { - _log.w('Extension search failed, falling back to built-in: $e'); + _log.w( + 'Metadata provider search failed, falling back to Deezer tracks: $e', + ); } } - if (source == 'deezer') { - _log.d('Calling Deezer search API...'); - 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: 2, - ); - _log.i( - 'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists', - ); + // Call the appropriate search API + switch (effectiveProvider) { + case 'tidal': + _log.d('Calling Tidal search API...'); + results = await PlatformBridge.searchTidalAll( + query, + trackLimit: 20, + artistLimit: 2, + filter: currentFilter, + ); + break; + case 'qobuz': + _log.d('Calling Qobuz search API...'); + results = await PlatformBridge.searchQobuzAll( + query, + trackLimit: 20, + artistLimit: 2, + filter: currentFilter, + ); + break; + default: + _log.d('Calling Deezer search API...'); + results = await PlatformBridge.searchDeezerAll( + query, + trackLimit: 20, + artistLimit: 2, + filter: currentFilter, + ); + break; } + _log.i( + '$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums', + ); if (!_isRequestValid(requestId)) { _log.w('Search request cancelled (requestId=$requestId)'); @@ -624,32 +734,20 @@ class TrackNotifier extends Notifier { final trackList = results['tracks'] as List? ?? []; final artistList = results['artists'] as List? ?? []; final albumList = results['albums'] as List? ?? []; + final trackSearchResults = metadataTrackResults.isNotEmpty + ? metadataTrackResults + : trackList.whereType>().toList(); _log.d( - 'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums', + 'Raw results: ${trackSearchResults.length} tracks, ${artistList.length} artists, ${albumList.length} albums', ); final tracks = []; - tracks.addAll(extensionTracks); - - final existingIsrcs = extensionTracks - .where((t) => t.isrc != null && t.isrc!.isNotEmpty) - .map((t) => t.isrc!) - .toSet(); - - for (int i = 0; i < trackList.length; i++) { - final t = trackList[i]; + for (int i = 0; i < trackSearchResults.length; i++) { + final t = trackSearchResults[i]; try { - if (t is Map) { - final track = _parseSearchTrack(t); - if (track.isrc != null && existingIsrcs.contains(track.isrc)) { - continue; - } - tracks.add(track); - } else { - _log.w('Track[$i] is not a Map: ${t.runtimeType}'); - } + tracks.add(_parseSearchTrack(t)); } catch (e) { _log.e('Failed to parse track[$i]: $e', e); } @@ -699,7 +797,7 @@ class TrackNotifier extends Notifier { } _log.i( - 'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully', + 'Search complete: ${tracks.length} tracks, ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully', ); state = TrackState( @@ -711,6 +809,8 @@ class TrackNotifier extends Notifier { hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, // Preserve filter in results + searchSource: + effectiveProvider, // Track which service was used for search ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; @@ -818,6 +918,7 @@ class TrackNotifier extends Notifier { discNumber: track.discNumber, releaseDate: track.releaseDate, albumType: track.albumType, + totalTracks: track.totalTracks, source: track.source, availability: ServiceAvailability( tidal: availability['tidal'] as bool? ?? false, @@ -885,20 +986,24 @@ class TrackNotifier extends Notifier { Track _parseTrack(Map data) { final durationMs = _extractDurationMs(data); + final spotifyId = (data['spotify_id'] ?? '').toString(); + final nativeId = (data['id'] ?? '').toString(); return Track( - id: data['spotify_id'] as String? ?? '', + id: spotifyId.isNotEmpty ? spotifyId : nativeId, name: data['name'] as String? ?? '', artistName: data['artists'] as String? ?? '', albumName: data['album_name'] as String? ?? '', albumArtist: data['album_artist'] as String?, artistId: (data['artist_id'] ?? data['artistId'])?.toString(), albumId: data['album_id']?.toString(), - coverUrl: data['images'] as String?, + coverUrl: normalizeCoverReference(data['images']?.toString()), isrc: data['isrc'] as String?, duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, + albumType: data['album_type'] as String?, + totalTracks: data['total_tracks'] as int?, ); } @@ -906,25 +1011,32 @@ class TrackNotifier extends Notifier { final durationMs = _extractDurationMs(data); final itemType = data['item_type']?.toString(); + final effectiveSource = + source ?? data['source']?.toString() ?? data['provider_id']?.toString(); + final spotifyId = (data['spotify_id'] ?? '').toString(); + final nativeId = (data['id'] ?? '').toString(); + final preferredId = effectiveSource != null && effectiveSource.isNotEmpty + ? (nativeId.isNotEmpty ? nativeId : spotifyId) + : (spotifyId.isNotEmpty ? spotifyId : nativeId); return Track( - id: (data['spotify_id'] ?? data['id'] ?? '').toString(), + id: preferredId, name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(), albumId: data['album_id']?.toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + coverUrl: normalizeCoverReference( + (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(), - source: - source ?? - data['source']?.toString() ?? - data['provider_id']?.toString(), + totalTracks: data['total_tracks'] as int?, + source: effectiveSource, albumType: data['album_type']?.toString(), itemType: itemType, ); @@ -962,7 +1074,9 @@ class TrackNotifier extends Notifier { name: data['name'] as String? ?? '', releaseDate: data['release_date'] as String? ?? '', totalTracks: data['total_tracks'] as int? ?? 0, - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'])?.toString(), + ), albumType: data['album_type'] as String? ?? 'album', artists: data['artists'] as String? ?? '', providerId: data['provider_id']?.toString(), @@ -973,7 +1087,7 @@ class TrackNotifier extends Notifier { return SearchArtist( id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', - imageUrl: data['images'] as String?, + imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()), followers: data['followers'] as int? ?? 0, popularity: data['popularity'] as int? ?? 0, ); @@ -984,7 +1098,7 @@ class TrackNotifier extends Notifier { id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', artists: data['artists'] as String? ?? '', - imageUrl: data['images'] as String?, + imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()), releaseDate: data['release_date'] as String?, totalTracks: data['total_tracks'] as int? ?? 0, albumType: data['album_type'] as String? ?? 'album', @@ -996,7 +1110,7 @@ class TrackNotifier extends Notifier { id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', owner: data['owner'] as String? ?? '', - imageUrl: data['images'] as String?, + imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()), totalTracks: data['total_tracks'] as int? ?? 0, ); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index ba8d9d1d..3f020953 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; @@ -81,16 +82,23 @@ class _AlbumScreenState extends ConsumerState { _scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - // Use extensionId if available, otherwise detect from albumId prefix final providerId = widget.extensionId ?? - (widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'); + (() { + if (widget.albumId.startsWith('deezer:')) return 'deezer'; + if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; + if (widget.albumId.startsWith('tidal:')) return 'tidal'; + return 'spotify'; + })(); ref .read(recentAccessProvider.notifier) .recordAlbumAccess( id: widget.albumId, name: widget.albumName, - artistName: widget.tracks?.firstOrNull?.artistName, + artistName: + widget.artistName ?? + widget.tracks?.firstOrNull?.albumArtist ?? + widget.tracks?.firstOrNull?.artistName, imageUrl: widget.coverUrl, providerId: providerId, ); @@ -129,9 +137,7 @@ class _AlbumScreenState extends ConsumerState { return (mediaSize.height * 0.55).clamp(360.0, 520.0); } - /// Upgrade cover URL to a reasonable resolution for full-screen display. - /// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate). - /// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800). + /// Upgrade cover URL to a higher resolution for full-screen display. String? _highResCoverUrl(String? url) { if (url == null) return null; // Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000) @@ -175,6 +181,12 @@ class _AlbumScreenState extends ConsumerState { 'album', deezerAlbumId, ); + } else if (widget.albumId.startsWith('qobuz:')) { + final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', ''); + metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId); + } else if (widget.albumId.startsWith('tidal:')) { + final tidalAlbumId = widget.albumId.replaceFirst('tidal:', ''); + metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId); } else { final url = 'https://open.spotify.com/album/${widget.albumId}'; metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); @@ -218,12 +230,14 @@ class _AlbumScreenState extends ConsumerState { artistId: (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, albumId: data['album_id']?.toString() ?? widget.albumId, - coverUrl: data['images'] as String?, + coverUrl: normalizeCoverReference(data['images']?.toString()), isrc: data['isrc'] as String?, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, + albumType: data['album_type'] as String?, + totalTracks: data['total_tracks'] as int?, ); } @@ -270,7 +284,11 @@ class _AlbumScreenState extends ConsumerState { ) { final expandedHeight = _calculateExpandedHeight(context); final tracks = _tracks ?? []; - final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; + final artistName = + widget.artistName ?? + (tracks.isNotEmpty + ? (tracks.first.albumArtist ?? tracks.first.artistName) + : null); final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; return SliverAppBar( @@ -503,7 +521,6 @@ class _AlbumScreenState extends ConsumerState { } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - // Info is now displayed in the full-screen cover overlay return const SliverToBoxAdapter(child: SizedBox.shrink()); } @@ -558,37 +575,82 @@ class _AlbumScreenState extends ConsumerState { void _downloadAll(BuildContext context) { final tracks = _tracks; if (tracks == null || tracks.isEmpty) return; + + // Skip already-downloaded tracks + final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); + final localLibState = + (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) + ? ref.read(localLibraryProvider) + : null; + final tracksToQueue = []; + int skippedCount = 0; + + for (final track in tracks) { + final isInHistory = + historyState.isDownloaded(track.id) || + (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || + historyState.findByTrackAndArtist(track.name, track.artistName) != + null; + final isInLocal = + localLibState?.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ) ?? + false; + + if (isInHistory || isInLocal) { + skippedCount++; + } else { + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.discographySkippedDownloaded(0, skippedCount), + ), + ), + ); + return; + } + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '${tracks.length} tracks', + trackName: '${tracksToQueue.length} tracks', artistName: widget.albumName, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToQueue(tracks.length), - ), - ), - ); + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: quality, + ); + _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); }, ); } else { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), - ), - ); + .addMultipleToQueue(tracksToQueue, settings.defaultService); + _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); } } + void _showQueuedSnackbar(BuildContext context, int added, int skipped) { + final message = skipped > 0 + ? context.l10n.discographySkippedDownloaded(added, skipped) + : context.l10n.snackbarAddedTracksToQueue(added); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + Widget _buildLoveAllButton() { final collectionsState = ref.watch(libraryCollectionsProvider); final tracks = _tracks; @@ -617,7 +679,9 @@ class _AlbumScreenState extends ConsumerState { size: 22, color: allLoved ? Colors.redAccent : Colors.white, ), - tooltip: allLoved ? 'Remove from Loved' : 'Love All', + tooltip: allLoved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.tooltipLoveAll, padding: EdgeInsets.zero, ), ); @@ -640,7 +704,7 @@ class _AlbumScreenState extends ConsumerState { ? null : () => showAddTracksToPlaylistSheet(context, ref, _tracks!), icon: const Icon(Icons.add, size: 22, color: Colors.white), - tooltip: 'Add to Playlist', + tooltip: context.l10n.tooltipAddToPlaylist, padding: EdgeInsets.zero, ), ); @@ -658,7 +722,11 @@ class _AlbumScreenState extends ConsumerState { } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')), + SnackBar( + content: Text( + context.l10n.snackbarRemovedTracksFromLoved(tracks.length), + ), + ), ); } } else { @@ -671,7 +739,9 @@ class _AlbumScreenState extends ConsumerState { } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added $addedCount tracks to Loved')), + SnackBar( + content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)), + ), ); } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index a1c37333..7aefcd64 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; @@ -38,12 +39,14 @@ class _ArtistCache { static void set( String artistId, { required List albums, + List? releases, List? topTracks, String? headerImageUrl, int? monthlyListeners, }) { _cache[artistId] = _CacheEntry( albums: albums, + releases: releases, topTracks: topTracks, headerImageUrl: headerImageUrl, monthlyListeners: monthlyListeners, @@ -54,6 +57,7 @@ class _ArtistCache { class _CacheEntry { final List albums; + final List? releases; final List? topTracks; final String? headerImageUrl; final int? monthlyListeners; @@ -61,6 +65,7 @@ class _CacheEntry { _CacheEntry({ required this.albums, + this.releases, this.topTracks, this.headerImageUrl, this.monthlyListeners, @@ -97,6 +102,7 @@ class ArtistScreen extends ConsumerStatefulWidget { class _ArtistScreenState extends ConsumerState { bool _isLoadingDiscography = false; List? _albums; + List? _releases; List? _topTracks; String? _headerImageUrl; int? _monthlyListeners; @@ -104,6 +110,8 @@ class _ArtistScreenState extends ConsumerState { bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); + final PageController _popularPageController = PageController(); + int _popularCurrentPage = 0; bool _isSelectionMode = false; final Set _selectedAlbumIds = {}; @@ -153,7 +161,12 @@ class _ArtistScreenState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { final providerId = widget.extensionId ?? - (widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify'); + (() { + if (widget.artistId.startsWith('deezer:')) return 'deezer'; + if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; + if (widget.artistId.startsWith('tidal:')) return 'tidal'; + return 'spotify'; + })(); ref .read(recentAccessProvider.notifier) .recordArtistAccess( @@ -169,6 +182,11 @@ class _ArtistScreenState extends ConsumerState { _topTracks = widget.topTracks; _headerImageUrl = widget.headerImageUrl; _monthlyListeners = widget.monthlyListeners; + + if ((_albums == null || _albums!.isEmpty) || + (_topTracks == null || _topTracks!.isEmpty)) { + _fetchDiscography(); + } return; } @@ -185,6 +203,7 @@ class _ArtistScreenState extends ConsumerState { } } else if (cached != null) { _albums = cached.albums; + _releases = cached.releases; _topTracks = cached.topTracks; _headerImageUrl = cached.headerImageUrl; _monthlyListeners = cached.monthlyListeners; @@ -209,6 +228,7 @@ class _ArtistScreenState extends ConsumerState { void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); + _popularPageController.dispose(); super.dispose(); } @@ -216,6 +236,7 @@ class _ArtistScreenState extends ConsumerState { setState(() => _isLoadingDiscography = true); try { List albums; + List? releases; List? topTracks; String? headerImage; int? listeners; @@ -230,6 +251,65 @@ class _ArtistScreenState extends ConsumerState { albums = albumsList .map((a) => _parseArtistAlbum(a as Map)) .toList(); + } else if (widget.artistId.startsWith('qobuz:')) { + final qobuzArtistId = widget.artistId.replaceFirst('qobuz:', ''); + final metadata = await PlatformBridge.getQobuzMetadata( + 'artist', + qobuzArtistId, + ); + final albumsList = metadata['albums'] as List; + albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + final artistInfo = metadata['artist_info'] as Map?; + headerImage = artistInfo?['images'] as String?; + } else if (widget.artistId.startsWith('tidal:')) { + final tidalArtistId = widget.artistId.replaceFirst('tidal:', ''); + final metadata = await PlatformBridge.getTidalMetadata( + 'artist', + tidalArtistId, + ); + final albumsList = metadata['albums'] as List; + albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + final artistInfo = metadata['artist_info'] as Map?; + headerImage = artistInfo?['images'] as String?; + } else if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { + final result = await PlatformBridge.getArtistWithExtension( + widget.extensionId!, + widget.artistId, + ); + + if (result == null) { + throw Exception('Failed to load artist from extension'); + } + + final artistData = result; + final albumsList = artistData['albums'] as List? ?? []; + albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + + final releasesList = artistData['releases'] as List? ?? []; + if (releasesList.isNotEmpty) { + releases = releasesList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + } + + final topTracksList = artistData['top_tracks'] as List? ?? []; + if (topTracksList.isNotEmpty) { + topTracks = topTracksList + .map((t) => _parseTrack(t as Map)) + .toList(); + } + + headerImage = + artistData['header_image'] as String? ?? + artistData['cover_url'] as String? ?? + artistData['image_url'] as String?; + listeners = artistData['listeners'] as int?; } else { final url = 'https://open.spotify.com/artist/${widget.artistId}'; final result = await PlatformBridge.handleURLWithExtension(url); @@ -270,6 +350,7 @@ class _ArtistScreenState extends ConsumerState { _ArtistCache.set( widget.artistId, albums: albums, + releases: releases, topTracks: topTracks, headerImageUrl: finalHeaderImage, monthlyListeners: finalListeners, @@ -278,6 +359,7 @@ class _ArtistScreenState extends ConsumerState { if (mounted) { setState(() { _albums = albums; + _releases = releases; _topTracks = topTracks; _headerImageUrl = finalHeaderImage; _monthlyListeners = finalListeners; @@ -294,7 +376,7 @@ class _ArtistScreenState extends ConsumerState { } } - Track _parseTrack(Map data) { + Track _parseTrack(Map data, {ArtistAlbum? album}) { int durationMs = 0; final durationValue = data['duration_ms']; if (durationValue is int) { @@ -303,36 +385,52 @@ class _ArtistScreenState extends ConsumerState { durationMs = durationValue.toInt(); } + final spotifyId = (data['spotify_id'] ?? '').toString(); + final nativeId = (data['id'] ?? '').toString(); + return Track( - id: (data['spotify_id'] ?? data['id'] ?? '').toString(), + id: spotifyId.isNotEmpty ? spotifyId : nativeId, name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), - albumName: (data['album_name'] ?? data['album'] ?? '').toString(), - albumArtist: data['album_artist']?.toString(), + albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '') + .toString(), + albumArtist: data['album_artist']?.toString() ?? widget.artistName, artistId: (data['artist_id'] ?? data['artistId'])?.toString() ?? widget.artistId, - albumId: data['album_id']?.toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + albumId: data['album_id']?.toString() ?? album?.id, + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.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(), - source: data['provider_id']?.toString(), + albumType: data['album_type']?.toString() ?? album?.albumType, + totalTracks: data['total_tracks'] as int? ?? album?.totalTracks, + source: data['provider_id']?.toString() ?? widget.extensionId, ); } ArtistAlbum _parseArtistAlbum(Map data) { + final totalTracksValue = data['total_tracks']; + final totalTracks = totalTracksValue is int + ? totalTracksValue + : int.tryParse(totalTracksValue?.toString() ?? '') ?? 0; + return ArtistAlbum( id: data['id'] as String? ?? '', - name: data['name'] as String? ?? '', - releaseDate: data['release_date'] as String? ?? '', - totalTracks: data['total_tracks'] as int? ?? 0, - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), - albumType: data['album_type'] as String? ?? 'album', - artists: data['artists'] as String? ?? '', - providerId: data['provider_id']?.toString(), + name: (data['name'] ?? data['title'] ?? '').toString(), + releaseDate: (data['release_date'] ?? '').toString(), + totalTracks: totalTracks, + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(), + ), + albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(), + artists: (data['artists'] ?? data['artist'] ?? widget.artistName) + .toString(), + providerId: data['provider_id']?.toString() ?? widget.extensionId, ); } @@ -343,7 +441,7 @@ class _ArtistScreenState extends ConsumerState { .where((a) => a.albumType == 'album') .toList(growable: false); _singlesBucket = albums - .where((a) => a.albumType == 'single') + .where((a) => a.albumType == 'single' || a.albumType == 'ep') .toList(growable: false); _compilationsBucket = albums .where((a) => a.albumType == 'compilation') @@ -355,6 +453,7 @@ class _ArtistScreenState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final albums = _albums ?? []; _ensureAlbumBuckets(albums); + final releases = _releases ?? const []; final albumsOnly = _albumsOnlyBucket; final singles = _singlesBucket; final compilations = _compilationsBucket; @@ -400,6 +499,14 @@ class _ArtistScreenState extends ConsumerState { SliverToBoxAdapter( child: _buildPopularSection(colorScheme), ), + if (releases.isNotEmpty) + SliverToBoxAdapter( + child: _buildAlbumSection( + 'Releases', + releases, + colorScheme, + ), + ), if (albumsOnly.isNotEmpty) SliverToBoxAdapter( child: _buildAlbumSection( @@ -414,6 +521,7 @@ class _ArtistScreenState extends ConsumerState { context.l10n.artistSingles, singles, colorScheme, + showTypeBadge: true, ), ), if (compilations.isNotEmpty) @@ -668,7 +776,9 @@ class _ArtistScreenState extends ConsumerState { List albums, ) { final albumsOnly = albums.where((a) => a.albumType == 'album').toList(); - final singles = albums.where((a) => a.albumType == 'single').toList(); + final singles = albums + .where((a) => a.albumType == 'single' || a.albumType == 'ep') + .toList(); final totalTracks = albums.fold(0, (sum, a) => sum + a.totalTracks); final albumTracks = albumsOnly.fold( @@ -939,7 +1049,7 @@ class _ArtistScreenState extends ConsumerState { if (result != null && result['tracks'] != null) { final tracksList = result['tracks'] as List; return tracksList - .map((t) => _parseTrack(t as Map)) + .map((t) => _parseTrack(t as Map, album: album)) .toList(); } } else if (album.id.startsWith('deezer:')) { @@ -954,13 +1064,31 @@ class _ArtistScreenState extends ConsumerState { .map((t) => _parseTrackFromDeezer(t as Map, album)) .toList(); } + } else if (album.id.startsWith('qobuz:')) { + final qobuzId = album.id.replaceFirst('qobuz:', ''); + final metadata = await PlatformBridge.getQobuzMetadata('album', qobuzId); + if (metadata['track_list'] != null) { + final tracksList = metadata['track_list'] as List; + return tracksList + .map((t) => _parseTrack(t as Map, album: album)) + .toList(); + } + } else if (album.id.startsWith('tidal:')) { + final tidalId = album.id.replaceFirst('tidal:', ''); + final metadata = await PlatformBridge.getTidalMetadata('album', tidalId); + if (metadata['track_list'] != null) { + final tracksList = metadata['track_list'] as List; + return tracksList + .map((t) => _parseTrack(t as Map, album: album)) + .toList(); + } } else { final url = 'https://open.spotify.com/album/${album.id}'; final result = await PlatformBridge.handleURLWithExtension(url); if (result != null && result['tracks'] != null) { final tracksList = result['tracks'] as List; return tracksList - .map((t) => _parseTrack(t as Map)) + .map((t) => _parseTrack(t as Map, album: album)) .toList(); } @@ -969,7 +1097,7 @@ class _ArtistScreenState extends ConsumerState { if (metadata['tracks'] != null) { final tracksList = metadata['tracks'] as List; return tracksList - .map((t) => _parseTrack(t as Map)) + .map((t) => _parseTrack(t as Map, album: album)) .toList(); } } @@ -1003,6 +1131,7 @@ class _ArtistScreenState extends ConsumerState { discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?, releaseDate: album.releaseDate, albumType: album.albumType, + totalTracks: album.totalTracks, ); } @@ -1203,7 +1332,9 @@ class _ArtistScreenState extends ConsumerState { return const SizedBox.shrink(); } - final tracks = _topTracks!.take(5).toList(); + final tracks = _topTracks!; + const tracksPerPage = 5; + final pageCount = (tracks.length / tracksPerPage).ceil(); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1217,11 +1348,60 @@ class _ArtistScreenState extends ConsumerState { ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), - ...tracks.asMap().entries.map((entry) { - final index = entry.key; - final track = entry.value; - return _buildPopularTrackItem(index + 1, track, colorScheme); - }), + SizedBox( + height: tracksPerPage * 64.0, + child: PageView.builder( + controller: _popularPageController, + itemCount: pageCount, + onPageChanged: (page) { + setState(() { + _popularCurrentPage = page; + }); + }, + itemBuilder: (context, pageIndex) { + final startIndex = pageIndex * tracksPerPage; + final endIndex = (startIndex + tracksPerPage).clamp( + 0, + tracks.length, + ); + final pageTracks = tracks.sublist(startIndex, endIndex); + + return Column( + children: pageTracks.asMap().entries.map((entry) { + final globalIndex = startIndex + entry.key; + return _buildPopularTrackItem( + globalIndex + 1, + entry.value, + colorScheme, + ); + }).toList(), + ); + }, + ), + ), + if (pageCount > 1) + Center( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(pageCount, (index) { + final isActive = _popularCurrentPage == index; + return Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + width: isActive ? 8 : 6, + height: isActive ? 8 : 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? colorScheme.primary + : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + ), + ); + }), + ), + ), + ), ], ); } @@ -1523,8 +1703,9 @@ class _ArtistScreenState extends ConsumerState { Widget _buildAlbumSection( String title, List albums, - ColorScheme colorScheme, - ) { + ColorScheme colorScheme, { + bool showTypeBadge = false, + }) { final sectionHeight = _artistAlbumSectionHeight(); final tileSize = _artistAlbumTileSize(); @@ -1555,6 +1736,7 @@ class _ArtistScreenState extends ConsumerState { colorScheme, tileSize: tileSize, sectionHeight: sectionHeight, + showTypeBadge: showTypeBadge, ), ); }, @@ -1569,6 +1751,7 @@ class _ArtistScreenState extends ConsumerState { ColorScheme colorScheme, { required double tileSize, required double sectionHeight, + bool showTypeBadge = false, }) { final isSelected = _selectedAlbumIds.contains(album.id); @@ -1681,6 +1864,29 @@ class _ArtistScreenState extends ConsumerState { : null, ), ), + if (showTypeBadge) + Positioned( + left: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + album.albumType == 'ep' ? 'EP' : 'Single', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ), ], ), const SizedBox(height: 8), diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 468d7aff..0d40173a 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -125,7 +125,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { (item.albumArtist != null && item.albumArtist!.isNotEmpty) ? item.albumArtist! : item.artistName; - // Use lowercase for case-insensitive matching final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; return itemKey == _albumLookupKey; @@ -363,7 +362,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (tracks.isEmpty) { return Scaffold( appBar: AppBar(title: Text(widget.albumName)), - body: Center(child: Text('No tracks found for this album')), + body: Center(child: Text(context.l10n.noTracksFoundForAlbum)), ); } @@ -911,8 +910,45 @@ class _DownloadedAlbumScreenState extends ConsumerState { BuildContext context, List allTracks, ) { - String selectedFormat = 'MP3'; - String selectedBitrate = '320k'; + final tracksById = {for (final t in allTracks) t.id: t}; + final sourceFormats = {}; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + final nameToCheck = + (item.safFileName != null && item.safFileName!.isNotEmpty) + ? item.safFileName!.toLowerCase() + : item.filePath.toLowerCase(); + final ext = nameToCheck.endsWith('.flac') + ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' + : nameToCheck.endsWith('.mp3') + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' + : null; + if (ext != null) sourceFormats.add(ext); + } + + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { + return sourceFormats.any((src) { + if (src == target) return false; + final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; + final isLosslessSource = src == 'FLAC' || src == 'M4A'; + if (isLosslessTarget && !isLosslessSource) return false; + return true; + }); + }).toList(); + + if (formats.isEmpty) return; + + String selectedFormat = formats.first; + bool isLosslessTarget = + selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + String selectedBitrate = isLosslessTarget + ? '320k' + : (selectedFormat == 'Opus' ? '128k' : '320k'); showModalBottomSheet( context: context, @@ -924,7 +960,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( @@ -961,51 +996,73 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; + } + }); } }, ); }).toList(), ), + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + if (isLosslessTarget) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.primary), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1058,12 +1115,19 @@ class _DownloadedAlbumScreenState extends ConsumerState { : item.filePath.toLowerCase(); final ext = nameToCheck.endsWith('.flac') ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' : nameToCheck.endsWith('.mp3') ? 'MP3' : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' : null; - if (ext != null && ext != targetFormat) selected.add(item); + if (ext == null || ext == targetFormat) continue; + // Skip lossy sources when target is lossless (pointless re-encoding) + final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; + if (isLosslessTarget && !isLosslessSource) continue; + selected.add(item); } if (selected.isEmpty) { @@ -1075,16 +1139,22 @@ class _DownloadedAlbumScreenState extends ConsumerState { return; } + final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - context.l10n.selectionBatchConvertConfirmMessage( - selected.length, - targetFormat, - bitrate, - ), + isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selected.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selected.length, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -1105,7 +1175,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { final total = selected.length; final historyDb = HistoryDatabase.instance; final newQuality = - '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + (targetFormat.toUpperCase() == 'ALAC' || + targetFormat.toUpperCase() == 'FLAC') + ? '${targetFormat.toUpperCase()} Lossless' + : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; @@ -1133,12 +1206,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { try { final result = await PlatformBridge.readFileMetadata(item.filePath); if (result['error'] == null) { - result.forEach((key, value) { - if (key == 'error' || value == null) return; - final v = value.toString().trim(); - if (v.isEmpty) return; - metadata[key.toUpperCase()] = v; - }); + mergePlatformMetadataForTagEmbed(target: metadata, source: result); } } catch (_) {} await ensureLyricsMetadataForConversion( @@ -1208,13 +1276,27 @@ class _DownloadedAlbumScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' - ? '.opus' - : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index f1c3f807..ca022604 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -22,6 +23,7 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -81,6 +83,37 @@ class _SearchResultBuckets { }); } +const _homeHistoryPreviewLimit = 48; + +class _HomeHistoryPreview { + final List items; + + const _HomeHistoryPreview(this.items); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _HomeHistoryPreview && listEquals(items, other.items); + + @override + int get hashCode => Object.hashAll(items); +} + +final _homeHistoryPreviewProvider = Provider>((ref) { + final preview = ref.watch( + downloadHistoryProvider.select((s) { + final items = s.items; + if (items.length <= _homeHistoryPreviewLimit) { + return _HomeHistoryPreview(items); + } + return _HomeHistoryPreview( + items.take(_homeHistoryPreviewLimit).toList(growable: false), + ); + }), + ); + return preview.items; +}); + _RecentAccessView _buildRecentAccessViewData( List items, List historyItems, @@ -164,9 +197,7 @@ _RecentAccessView _buildRecentAccessViewData( } final recentAccessViewProvider = Provider<_RecentAccessView>((ref) { - final historyItems = ref.watch( - downloadHistoryProvider.select((s) => s.items), - ); + final historyItems = ref.watch(_homeHistoryPreviewProvider); final recentAccessItems = ref.watch( recentAccessProvider.select((s) => s.items), ); @@ -459,6 +490,9 @@ class _HomeTabState extends ConsumerState if (searchProvider == null || searchProvider.isEmpty) return false; + // Built-in providers (tidal, qobuz) also support live search + if (_builtInSearchProviders.contains(searchProvider)) return true; + final extension = extState.extensions .where((e) => e.id == searchProvider && e.enabled) .firstOrNull; @@ -516,6 +550,9 @@ class _HomeTabState extends ConsumerState } } + /// Built-in search providers that are not extensions + static const _builtInSearchProviders = {'tidal', 'qobuz'}; + Future _performSearch(String query, {String? filterOverride}) async { final settings = ref.read(settingsProvider); final extState = ref.read(extensionProvider); @@ -528,9 +565,14 @@ class _HomeTabState extends ConsumerState if (_lastSearchQuery == searchKey) return; _lastSearchQuery = searchKey; + final isBuiltInProvider = + searchProvider != null && + _builtInSearchProviders.contains(searchProvider); + final isExtensionEnabled = searchProvider != null && searchProvider.isNotEmpty && + !isBuiltInProvider && extState.extensions.any((e) => e.id == searchProvider && e.enabled); if (isExtensionEnabled) { @@ -541,19 +583,25 @@ class _HomeTabState extends ConsumerState await ref .read(trackProvider.notifier) .customSearch(searchProvider, query, options: options); - } else { - if (searchProvider != null && - searchProvider.isNotEmpty && - !isExtensionEnabled) { - ref.read(settingsProvider.notifier).setSearchProvider(null); - } + } else if (isBuiltInProvider) { + // Use built-in Tidal or Qobuz search await ref .read(trackProvider.notifier) .search( query, - metadataSource: settings.metadataSource, filterOverride: selectedFilter, + builtInSearchProvider: searchProvider, ); + } else { + if (searchProvider != null && + searchProvider.isNotEmpty && + !isExtensionEnabled && + !isBuiltInProvider) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + } + await ref + .read(trackProvider.notifier) + .search(query, filterOverride: selectedFilter); } ref.read(settingsProvider.notifier).setHasSearchedBefore(); } @@ -583,12 +631,28 @@ class _HomeTabState extends ConsumerState if (url.isEmpty) return; if (url.startsWith('http') || url.startsWith('spotify:')) { await ref.read(trackProvider.notifier).fetchFromUrl(url); - _navigateToDetailIfNeeded(); + final trackState = ref.read(trackProvider); + if (trackState.error != null && mounted) { + final l10n = context.l10n; + final errorMsg = trackState.error!; + final isRateLimit = + errorMsg.contains('429') || + errorMsg.toLowerCase().contains('rate limit') || + errorMsg.toLowerCase().contains('too many requests'); + final displayMessage = errorMsg == 'url_not_recognized' + ? l10n.errorUrlNotRecognizedMessage + : isRateLimit + ? l10n.errorRateLimitedMessage + : l10n.errorUrlFetchFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(displayMessage))); + ref.read(trackProvider.notifier).clear(); + } else { + _navigateToDetailIfNeeded(); + } } else { - final settings = ref.read(settingsProvider); - await ref - .read(trackProvider.notifier) - .search(url, metadataSource: settings.metadataSource); + await ref.read(trackProvider.notifier).search(url); } ref.read(settingsProvider.notifier).setHasSearchedBefore(); } @@ -676,6 +740,7 @@ class _HomeTabState extends ConsumerState trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, + recommendedService: trackState.searchSource, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) @@ -804,7 +869,7 @@ class _HomeTabState extends ConsumerState const SizedBox(height: 12), CheckboxListTile( contentPadding: EdgeInsets.zero, - title: const Text('Skip already downloaded songs'), + title: Text(l10n.homeSkipAlreadyDownloaded), value: skipDownloaded, onChanged: (value) { setDialogState(() { @@ -975,9 +1040,7 @@ class _HomeTabState extends ConsumerState final mediaQuery = MediaQuery.of(context); final screenHeight = mediaQuery.size.height; final topPadding = normalizedHeaderTopPadding(context); - final historyItems = ref.watch( - downloadHistoryProvider.select((s) => s.items), - ); + final historyItems = ref.watch(_homeHistoryPreviewProvider); final recentModeRequested = isShowingRecentAccess || isSearchFocused; final showRecentAccess = @@ -1262,7 +1325,8 @@ class _HomeTabState extends ConsumerState (searchArtists != null && searchArtists.isNotEmpty) || (searchAlbums != null && searchAlbums.isNotEmpty) || (searchPlaylists != null && searchPlaylists.isNotEmpty) || - isLoading; + isLoading || + error != null; return SliverMainAxisGroup( slivers: _buildSearchResults( @@ -1737,7 +1801,7 @@ class _HomeTabState extends ConsumerState ), ListTile( leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant), - title: const Text('Go to Album'), + title: Text(context.l10n.homeGoToAlbum), onTap: () { Navigator.pop(context); _navigateToTrackAlbum(item); @@ -1809,9 +1873,9 @@ class _HomeTabState extends ConsumerState ), ); } else { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Album info not available'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.homeAlbumInfoUnavailable)), + ); } } @@ -2224,11 +2288,14 @@ class _HomeTabState extends ConsumerState } Widget _buildErrorWidget(String error, ColorScheme colorScheme) { + final l10n = context.l10n; final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || error.toLowerCase().contains('too many requests'); + final isUrlNotRecognized = error == 'url_not_recognized'; + if (isRateLimit) { return Card( elevation: 0, @@ -2245,7 +2312,7 @@ class _HomeTabState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Rate Limited', + l10n.errorRateLimited, style: TextStyle( color: colorScheme.onErrorContainer, fontWeight: FontWeight.bold, @@ -2253,7 +2320,7 @@ class _HomeTabState extends ConsumerState ), const SizedBox(height: 4), Text( - 'Too many requests. Please wait a moment before searching again.', + l10n.errorRateLimitedMessage, style: TextStyle( color: colorScheme.onErrorContainer, fontSize: 12, @@ -2268,6 +2335,42 @@ class _HomeTabState extends ConsumerState ); } + if (isUrlNotRecognized) { + return Card( + elevation: 0, + color: colorScheme.errorContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.link_off, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.errorUrlNotRecognized, + style: TextStyle( + color: colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + l10n.errorUrlNotRecognizedMessage, + style: TextStyle(color: colorScheme.error, fontSize: 12), + ), + ], + ), + ), + ], + ), + ), + ); + } + return Card( elevation: 0, color: colorScheme.errorContainer.withValues(alpha: 0.5), @@ -2279,7 +2382,10 @@ class _HomeTabState extends ConsumerState Icon(Icons.error_outline, color: colorScheme.error), const SizedBox(width: 12), Expanded( - child: Text(error, style: TextStyle(color: colorScheme.error)), + child: Text( + l10n.errorUrlFetchFailed, + style: TextStyle(color: colorScheme.error), + ), ), ], ), @@ -2687,6 +2793,14 @@ class _HomeTabState extends ConsumerState } if (searchProvider != null && searchProvider.isNotEmpty) { + // Check built-in providers first + if (searchProvider == 'tidal') { + return 'Search with Tidal...'; + } + if (searchProvider == 'qobuz') { + return 'Search with Qobuz...'; + } + final ext = extState.extensions .where((e) => e.id == searchProvider) .firstOrNull; @@ -2907,9 +3021,6 @@ class _SearchProviderDropdown extends ConsumerWidget { final currentProvider = ref.watch( settingsProvider.select((s) => s.searchProvider), ); - final metadataSource = ref.watch( - settingsProvider.select((s) => s.metadataSource), - ); final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); final colorScheme = Theme.of(context).colorScheme; @@ -2924,6 +3035,11 @@ class _SearchProviderDropdown extends ConsumerWidget { .firstOrNull; } + // Check if current provider is a built-in provider (tidal/qobuz) + const builtInProviders = {'tidal', 'qobuz'}; + final isBuiltInProvider = + currentProvider != null && builtInProviders.contains(currentProvider); + IconData displayIcon = Icons.search; String? iconPath; if (currentExt != null) { @@ -2931,10 +3047,8 @@ class _SearchProviderDropdown extends ConsumerWidget { if (currentExt.searchBehavior?.icon != null) { displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); } - } - - if (searchProviders.isEmpty) { - return const Icon(Icons.search); + } else if (isBuiltInProvider) { + displayIcon = Icons.music_note; } return Padding( @@ -2987,7 +3101,7 @@ class _SearchProviderDropdown extends ConsumerWidget { const SizedBox(width: 12), Expanded( child: Text( - metadataSource == 'spotify' ? 'Spotify' : 'Deezer', + 'Deezer', style: TextStyle( fontWeight: currentProvider == null || currentProvider.isEmpty @@ -3001,6 +3115,62 @@ class _SearchProviderDropdown extends ConsumerWidget { ], ), ), + // Built-in Tidal search option + PopupMenuItem( + value: 'tidal', + child: Row( + children: [ + Icon( + Icons.music_note, + size: 20, + color: currentProvider == 'tidal' + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Tidal', + style: TextStyle( + fontWeight: currentProvider == 'tidal' + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == 'tidal') + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + ), + // Built-in Qobuz search option + PopupMenuItem( + value: 'qobuz', + child: Row( + children: [ + Icon( + Icons.music_note, + size: 20, + color: currentProvider == 'qobuz' + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Qobuz', + style: TextStyle( + fontWeight: currentProvider == 'qobuz' + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == 'qobuz') + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + ), if (searchProviders.isNotEmpty) const PopupMenuDivider(), ...searchProviders.map( (ext) => PopupMenuItem( @@ -3829,6 +3999,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? widget.albumName).toString(), + albumArtist: (data['album_artist'] ?? _artistName)?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, albumId: data['album_id']?.toString() ?? widget.albumId, @@ -4136,7 +4307,7 @@ class _ExtensionArtistScreenState extends ConsumerState { artists: (data['artists'] ?? '').toString(), releaseDate: (data['release_date'] ?? '').toString(), totalTracks: data['total_tracks'] as int? ?? 0, - coverUrl: data['cover_url']?.toString(), + coverUrl: normalizeCoverReference(data['cover_url']?.toString()), albumType: (data['album_type'] ?? 'album').toString(), providerId: (data['provider_id'] ?? widget.extensionId).toString(), ); @@ -4161,7 +4332,9 @@ class _ExtensionArtistScreenState extends ConsumerState { (data['artist_id'] ?? data['artistId'])?.toString() ?? widget.artistId, albumId: data['album_id']?.toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'])?.toString(), + ), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index d6d1fc5e..2b792b0b 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -39,6 +39,7 @@ class _LibraryTracksFolderScreenState bool _isSelectionMode = false; final Set _selectedKeys = {}; + UserPlaylistCollection? playlist; @override void initState() { @@ -243,7 +244,6 @@ class _LibraryTracksFolderScreenState final colorScheme = Theme.of(context).colorScheme; ref.watch(localLibraryProvider.select((s) => s.items)); final localState = ref.read(localLibraryProvider); - final UserPlaylistCollection? playlist; final List entries; switch (widget.mode) { @@ -850,8 +850,8 @@ class _LibraryTracksFolderScreenState final colorScheme = Theme.of(dialogContext).colorScheme; return AlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, - title: const Text('Download All'), - content: Text('Download ${tracks.length} tracks?'), + title: Text(context.l10n.dialogDownloadAllTitle), + content: Text(context.l10n.dialogDownloadAllMessage(tracks.length)), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), @@ -862,7 +862,7 @@ class _LibraryTracksFolderScreenState Navigator.pop(dialogContext); _downloadAll(tracks); }, - child: const Text('Download'), + child: Text(context.l10n.dialogDownload), ), ], ); @@ -872,11 +872,54 @@ class _LibraryTracksFolderScreenState void _downloadAll(List tracks) { if (tracks.isEmpty) return; + final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); + final localLibState = + (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) + ? ref.read(localLibraryProvider) + : null; + final playlistName = widget.mode == LibraryTracksFolderMode.playlist + ? playlist?.name ?? context.l10n.collectionPlaylist + : null; + final tracksToQueue = []; + var skippedCount = 0; + + for (final track in tracks) { + final isInHistory = + historyState.isDownloaded(track.id) || + (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || + historyState.findByTrackAndArtist(track.name, track.artistName) != + null; + final isInLocal = + localLibState?.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ) ?? + false; + + if (isInHistory || isInLocal) { + skippedCount++; + } else { + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.discographySkippedDownloaded(0, skippedCount), + ), + ), + ); + return; + } + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '${tracks.length} tracks', + trackName: '${tracksToQueue.length} tracks', artistName: switch (widget.mode) { LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, LibraryTracksFolderMode.loved => context.l10n.collectionLoved, @@ -885,12 +928,24 @@ class _LibraryTracksFolderScreenState onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, service, qualityOverride: quality); + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: quality, + playlistName: playlistName, + ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.snackbarAddedTracksToQueue(tracks.length), + skippedCount > 0 + ? context.l10n.discographySkippedDownloaded( + tracksToQueue.length, + skippedCount, + ) + : context.l10n.snackbarAddedTracksToQueue( + tracksToQueue.length, + ), ), ), ); @@ -899,10 +954,21 @@ class _LibraryTracksFolderScreenState } else { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, settings.defaultService); + .addMultipleToQueue( + tracksToQueue, + settings.defaultService, + playlistName: playlistName, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), + content: Text( + skippedCount > 0 + ? context.l10n.discographySkippedDownloaded( + tracksToQueue.length, + skippedCount, + ) + : context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length), + ), ), ); } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 79eca05b..5c3c8ea3 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -4,11 +4,15 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; @@ -38,6 +42,13 @@ class _LocalAlbumScreenState extends ConsumerState { final ScrollController _scrollController = ScrollController(); late List _sortedTracksCache; late Map> _discGroupsCache; + + void _showCueVirtualTrackSnackBar() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)), + ); + } + late List _sortedDiscNumbersCache; late bool _hasMultipleDiscsCache; String? _commonQualityCache; @@ -178,9 +189,11 @@ class _LocalAlbumScreenState extends ConsumerState { for (final id in idsToDelete) { final item = tracksById[id]; if (item != null) { - try { - await deleteFile(item.filePath); - } catch (_) {} + if (!isCueVirtualPath(item.filePath)) { + try { + await deleteFile(item.filePath); + } catch (_) {} + } await libraryNotifier.removeItem(id); deletedCount++; } @@ -203,6 +216,10 @@ class _LocalAlbumScreenState extends ConsumerState { } Future _openFile(LocalLibraryItem track) async { + if (isCueVirtualPath(track.filePath)) { + _showCueVirtualTrackSnackBar(); + return; + } try { await ref .read(playbackProvider.notifier) @@ -233,7 +250,7 @@ class _LocalAlbumScreenState extends ConsumerState { if (tracks.isEmpty) { return Scaffold( appBar: AppBar(title: Text(widget.albumName)), - body: const Center(child: Text('No tracks found for this album')), + body: Center(child: Text(context.l10n.noTracksFoundForAlbum)), ); } @@ -609,11 +626,13 @@ class _LocalAlbumScreenState extends ConsumerState { slivers.add( SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => - _buildTrackItem(context, colorScheme, discTracks[index]), - childCount: discTracks.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final track = discTracks[index]; + return KeyedSubtree( + key: ValueKey(track.id), + child: _buildTrackItem(context, colorScheme, track), + ); + }, childCount: discTracks.length), ), ); } @@ -801,6 +820,11 @@ class _LocalAlbumScreenState extends ConsumerState { final format = item.format?.toLowerCase(); final lowerPath = item.filePath.toLowerCase(); final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isM4A = + format == 'm4a' || + format == 'aac' || + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.aac'); final isOpus = format == 'opus' || format == 'ogg' || @@ -814,6 +838,12 @@ class _LocalAlbumScreenState extends ConsumerState { coverPath: effectiveCoverPath, metadata: metadata, ); + } else if (isM4A) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); } else if (isOpus) { ffmpegResult = await FFmpegService.embedMetadataToOpus( opusPath: ffmpegTarget, @@ -883,6 +913,128 @@ class _LocalAlbumScreenState extends ConsumerState { return false; } + List _selectedFlacEligibleItems( + List allTracks, + ) { + final tracksById = {for (final t in allTracks) t.id: t}; + return _selectedIds + .map((id) => tracksById[id]) + .whereType() + .where(LocalTrackRedownloadService.isFlacUpgradeEligible) + .toList(growable: false); + } + + Future _queueSelectedAsFlac(List allTracks) async { + final selected = _selectedFlacEligibleItems(allTracks); + + if (selected.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.queueFlacAction), + content: Text(context.l10n.queueFlacConfirmMessage(selected.length)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.queueFlacAction), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + final includeExtensions = + settings.useExtensionProviders && + extensionState.extensions.any( + (ext) => ext.enabled && ext.hasMetadataProvider, + ); + final targetService = LocalTrackRedownloadService.preferredFlacService( + settings, + ); + final targetQuality = + LocalTrackRedownloadService.preferredFlacQualityForService( + targetService, + ); + + final matchedTracks = []; + var skippedCount = 0; + final total = selected.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)), + duration: const Duration(seconds: 30), + ), + ); + + try { + final resolution = await LocalTrackRedownloadService.resolveBestMatch( + selected[i], + includeExtensions: includeExtensions, + ); + if (resolution.canQueue && resolution.match != null) { + matchedTracks.add(resolution.match!); + } else { + skippedCount++; + } + } catch (_) { + skippedCount++; + } + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + + if (matchedTracks.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), + ); + return; + } + + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + matchedTracks, + targetService, + qualityOverride: targetQuality, + ); + + final summary = skippedCount == 0 + ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) + : context.l10n.queueFlacQueuedWithSkipped( + matchedTracks.length, + skippedCount, + ); + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); + setState(() { + _selectedIds.clear(); + _isSelectionMode = false; + }); + } + Future _reEnrichSelected(List allTracks) async { final tracksById = {for (final t in allTracks) t.id: t}; final selected = []; @@ -991,8 +1143,57 @@ class _LocalAlbumScreenState extends ConsumerState { BuildContext context, List allTracks, ) { - String selectedFormat = 'MP3'; - String selectedBitrate = '320k'; + final tracksById = {for (final t in allTracks) t.id: t}; + final sourceFormats = {}; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + String? ext; + if (item.format != null && item.format!.isNotEmpty) { + final fmt = item.format!.toLowerCase(); + if (fmt == 'flac') { + ext = 'FLAC'; + } else if (fmt == 'm4a') { + ext = 'M4A'; + } else if (fmt == 'mp3') { + ext = 'MP3'; + } else if (fmt == 'opus' || fmt == 'ogg') { + ext = 'Opus'; + } + } + if (ext == null) { + final lower = item.filePath.toLowerCase(); + if (lower.endsWith('.flac')) { + ext = 'FLAC'; + } else if (lower.endsWith('.m4a')) { + ext = 'M4A'; + } else if (lower.endsWith('.mp3')) { + ext = 'MP3'; + } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { + ext = 'Opus'; + } + } + if (ext != null) sourceFormats.add(ext); + } + + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { + return sourceFormats.any((src) { + if (src == target) return false; + final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; + final isLosslessSource = src == 'FLAC' || src == 'M4A'; + if (isLosslessTarget && !isLosslessSource) return false; + return true; + }); + }).toList(); + + if (formats.isEmpty) return; + + String selectedFormat = formats.first; + bool isLosslessTarget = + selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + String selectedBitrate = isLosslessTarget + ? '320k' + : (selectedFormat == 'Opus' ? '128k' : '320k'); showModalBottomSheet( context: context, @@ -1004,7 +1205,6 @@ class _LocalAlbumScreenState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( @@ -1041,51 +1241,73 @@ class _LocalAlbumScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; + } + }); } }, ); }).toList(), ), + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + if (isLosslessTarget) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.primary), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1138,6 +1360,8 @@ class _LocalAlbumScreenState extends ConsumerState { final fmt = item.format!.toLowerCase(); if (fmt == 'flac') { currentFormat = 'FLAC'; + } else if (fmt == 'm4a') { + currentFormat = 'M4A'; } else if (fmt == 'mp3') { currentFormat = 'MP3'; } else if (fmt == 'opus' || fmt == 'ogg') { @@ -1149,15 +1373,21 @@ class _LocalAlbumScreenState extends ConsumerState { final lower = item.filePath.toLowerCase(); if (lower.endsWith('.flac')) { currentFormat = 'FLAC'; + } else if (lower.endsWith('.m4a')) { + currentFormat = 'M4A'; } else if (lower.endsWith('.mp3')) { currentFormat = 'MP3'; } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { currentFormat = 'Opus'; } } - if (currentFormat != null && currentFormat != targetFormat) { - selected.add(item); - } + if (currentFormat == null || currentFormat == targetFormat) continue; + // Skip lossy sources when target is lossless (pointless re-encoding) + final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLosslessSource = + currentFormat == 'FLAC' || currentFormat == 'M4A'; + if (isLosslessTarget && !isLosslessSource) continue; + selected.add(item); } if (selected.isEmpty) { @@ -1169,16 +1399,22 @@ class _LocalAlbumScreenState extends ConsumerState { return; } + final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - context.l10n.selectionBatchConvertConfirmMessage( - selected.length, - targetFormat, - bitrate, - ), + isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selected.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selected.length, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -1225,12 +1461,7 @@ class _LocalAlbumScreenState extends ConsumerState { try { final result = await PlatformBridge.readFileMetadata(item.filePath); if (result['error'] == null) { - result.forEach((key, value) { - if (key == 'error' || value == null) return; - final v = value.toString().trim(); - if (v.isEmpty) return; - metadata[key.toUpperCase()] = v; - }); + mergePlatformMetadataForTagEmbed(target: metadata, source: result); } } catch (_) {} await ensureLyricsMetadataForConversion( @@ -1343,13 +1574,27 @@ class _LocalAlbumScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' - ? '.opus' - : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -1420,6 +1665,7 @@ class _LocalAlbumScreenState extends ConsumerState { double bottomPadding, ) { final selectedCount = _selectedIds.length; + final flacEligibleCount = _selectedFlacEligibleItems(tracks).length; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; return Container( @@ -1511,6 +1757,18 @@ class _LocalAlbumScreenState extends ConsumerState { Row( children: [ + if (flacEligibleCount > 0) ...[ + Expanded( + child: _LocalAlbumSelectionActionButton( + icon: Icons.download_for_offline_outlined, + label: + '${context.l10n.queueFlacAction} ($flacEligibleCount)', + onPressed: () => _queueSelectedAsFlac(tracks), + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), + ], Expanded( child: _LocalAlbumSelectionActionButton( icon: Icons.auto_fix_high_outlined, diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index e103c6a1..0440a08c 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -33,7 +33,7 @@ class MainShell extends ConsumerStatefulWidget { class _MainShellState extends ConsumerState { int _currentIndex = 0; - late PageController _pageController; + late final PageController _pageController; bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; @@ -83,7 +83,6 @@ class _MainShellState extends ConsumerState { final extState = ref.read(extensionProvider); if (!extState.isInitialized) { _log.d('Waiting for extensions to initialize before handling URL...'); - // Wait up to 5 seconds for extensions to initialize for (int i = 0; i < 50; i++) { await Future.delayed(const Duration(milliseconds: 100)); if (!mounted) return; @@ -102,13 +101,30 @@ class _MainShellState extends ConsumerState { if (_currentIndex != 0) { _onNavTap(0); } - ref.read(trackProvider.notifier).fetchFromUrl(url); ref.read(settingsProvider.notifier).setHasSearchedBefore(); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink))); } + await ref.read(trackProvider.notifier).fetchFromUrl(url); + final trackState = ref.read(trackProvider); + if (trackState.error != null && mounted) { + final l10n = context.l10n; + final errorMsg = trackState.error!; + final isRateLimit = + errorMsg.contains('429') || + errorMsg.toLowerCase().contains('rate limit') || + errorMsg.toLowerCase().contains('too many requests'); + final displayMessage = errorMsg == 'url_not_recognized' + ? l10n.errorUrlNotRecognizedMessage + : isRateLimit + ? l10n.errorRateLimitedMessage + : l10n.errorUrlFetchFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(displayMessage))); + } } Future _checkForUpdates() async { @@ -142,12 +158,10 @@ class _MainShellState extends ConsumerState { if (settings.storageMode == 'saf') return; if (settings.downloadDirectory.isEmpty) return; - // Check Android version final deviceInfo = DeviceInfoPlugin(); final androidInfo = await deviceInfo.androidInfo; if (androidInfo.version.sdkInt < 29) return; - // Only show once final prefs = await SharedPreferences.getInstance(); if (prefs.getBool(_safMigrationShownKey) == true) return; await prefs.setBool(_safMigrationShownKey, true); @@ -165,25 +179,20 @@ class _MainShellState extends ConsumerState { size: 32, color: colorScheme.primary, ), - title: const Text('Storage Update Required'), - content: const Column( + title: Text(context.l10n.safMigrationTitle), + content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. ' - 'This fixes "permission denied" errors on Android 10+.', - ), - SizedBox(height: 12), - Text( - 'Please select your download folder again to switch to the new storage system.', - ), + Text(context.l10n.safMigrationMessage1), + const SizedBox(height: 12), + Text(context.l10n.safMigrationMessage2), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('Later'), + child: Text(context.l10n.updateLater), ), FilledButton( onPressed: () async { @@ -203,15 +212,13 @@ class _MainShellState extends ConsumerState { ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Download folder updated to SAF mode'), - ), + SnackBar(content: Text(context.l10n.safMigrationSuccess)), ); } } } }, - child: const Text('Select Folder'), + child: Text(context.l10n.setupSelectFolder), ), ], ), @@ -244,6 +251,7 @@ class _MainShellState extends ConsumerState { } if (_currentIndex != index) { + final shouldResetHome = index == 0; HapticFeedback.selectionClick(); setState(() => _currentIndex = index); final showStore = ref.read( @@ -253,6 +261,10 @@ class _MainShellState extends ConsumerState { currentTabIndex: _currentIndex, showStoreTab: showStore, ); + FocusManager.instance.primaryFocus?.unfocus(); + if (shouldResetHome) { + _resetHomeToMain(); + } _pageController.animateToPage( index, duration: const Duration(milliseconds: 250), @@ -492,11 +504,15 @@ class _MainShellState extends ConsumerState { return true; }, child: Scaffold( - body: PageView( + body: PageView.builder( controller: _pageController, + itemCount: tabs.length, onPageChanged: _onPageChanged, physics: const NeverScrollableScrollPhysics(), - children: tabs, + itemBuilder: (context, index) => _KeepAliveTabPage( + key: ValueKey('page-$index'), + child: tabs[index], + ), ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), @@ -557,6 +573,27 @@ class _LibraryTabRoot extends ConsumerWidget { } } +class _KeepAliveTabPage extends StatefulWidget { + final Widget child; + + const _KeepAliveTabPage({super.key, required this.child}); + + @override + State<_KeepAliveTabPage> createState() => _KeepAliveTabPageState(); +} + +class _KeepAliveTabPageState extends State<_KeepAliveTabPage> + with AutomaticKeepAliveClientMixin<_KeepAliveTabPage> { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} + class BouncingIcon extends StatefulWidget { final Widget child; const BouncingIcon({super.key, required this.child}); diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 7ffbd18e..fb4818ea 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; @@ -39,8 +40,12 @@ class _PlaylistScreenState extends ConsumerState { List? _fetchedTracks; bool _isLoading = false; String? _error; + String? _resolvedPlaylistName; + String? _resolvedCoverUrl; List get _tracks => _fetchedTracks ?? widget.tracks; + String get _playlistName => _resolvedPlaylistName ?? widget.playlistName; + String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl; @override void initState() { @@ -65,18 +70,25 @@ class _PlaylistScreenState extends ConsumerState { }); try { - // Extract numeric ID from "deezer:123" format String playlistId = widget.playlistId!; + late final Map result; if (playlistId.startsWith('deezer:')) { playlistId = playlistId.substring(7); + result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); + } else if (playlistId.startsWith('qobuz:')) { + playlistId = playlistId.substring(6); + result = await PlatformBridge.getQobuzMetadata('playlist', playlistId); + } else if (playlistId.startsWith('tidal:')) { + playlistId = playlistId.substring(6); + result = await PlatformBridge.getTidalMetadata('playlist', playlistId); + } else { + result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); } - - final result = await PlatformBridge.getDeezerMetadata( - 'playlist', - playlistId, - ); if (!mounted) return; + final playlistInfo = result['playlist_info'] as Map?; + final owner = playlistInfo?['owner'] as Map?; + // Go backend returns 'track_list' not 'tracks' final trackList = result['track_list'] as List? ?? []; final tracks = trackList @@ -85,6 +97,10 @@ class _PlaylistScreenState extends ConsumerState { setState(() { _fetchedTracks = tracks; + _resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name']) + ?.toString(); + _resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images']) + ?.toString(); _isLoading = false; }); } catch (e) { @@ -113,7 +129,9 @@ class _PlaylistScreenState extends ConsumerState { albumArtist: data['album_artist']?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(), albumId: data['album_id']?.toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'])?.toString(), + ), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, @@ -184,7 +202,7 @@ class _PlaylistScreenState extends ConsumerState { duration: const Duration(milliseconds: 200), opacity: _showTitleInAppBar ? 1.0 : 0.0, child: Text( - widget.playlistName, + _playlistName, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, @@ -206,10 +224,9 @@ class _PlaylistScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - if (widget.coverUrl != null) + if (_coverUrl != null) CachedNetworkImage( - imageUrl: - _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, + imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -256,7 +273,7 @@ class _PlaylistScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - widget.playlistName, + _playlistName, style: const TextStyle( color: Colors.white, fontSize: 24, @@ -336,7 +353,6 @@ class _PlaylistScreenState extends ConsumerState { } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - // Info is now displayed in the full-screen cover overlay return const SliverToBoxAdapter(child: SizedBox.shrink()); } @@ -416,7 +432,12 @@ class _PlaylistScreenState extends ConsumerState { onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addToQueue(track, service, qualityOverride: quality); + .addToQueue( + track, + service, + qualityOverride: quality, + playlistName: _playlistName, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.snackbarAddedToQueue(track.name)), @@ -427,7 +448,11 @@ class _PlaylistScreenState extends ConsumerState { } else { ref .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); + .addToQueue( + track, + settings.defaultService, + playlistName: _playlistName, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); @@ -482,7 +507,9 @@ class _PlaylistScreenState extends ConsumerState { size: 22, color: allLoved ? Colors.redAccent : Colors.white, ), - tooltip: allLoved ? 'Remove from Loved' : 'Love All', + tooltip: allLoved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.tooltipLoveAll, padding: EdgeInsets.zero, ), ); @@ -505,10 +532,15 @@ class _PlaylistScreenState extends ConsumerState { Widget _buildAddToPlaylistButton(BuildContext context) { return _buildCircleButton( icon: Icons.playlist_add, - tooltip: 'Add to Playlist', + tooltip: context.l10n.tooltipAddToPlaylist, onPressed: _tracks.isEmpty ? null - : () => showAddTracksToPlaylistSheet(context, ref, _tracks), + : () => showAddTracksToPlaylistSheet( + context, + ref, + _tracks, + playlistNamePrefill: widget.playlistName, + ), ); } @@ -520,8 +552,8 @@ class _PlaylistScreenState extends ConsumerState { final colorScheme = Theme.of(dialogContext).colorScheme; return AlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, - title: const Text('Download All'), - content: Text('Download ${_tracks.length} tracks?'), + title: Text(context.l10n.dialogDownloadAllTitle), + content: Text(context.l10n.dialogDownloadAllMessage(_tracks.length)), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), @@ -532,7 +564,7 @@ class _PlaylistScreenState extends ConsumerState { Navigator.pop(dialogContext); _downloadAll(context); }, - child: const Text('Download'), + child: Text(context.l10n.dialogDownload), ), ], ); @@ -552,7 +584,11 @@ class _PlaylistScreenState extends ConsumerState { } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')), + SnackBar( + content: Text( + context.l10n.snackbarRemovedTracksFromLoved(tracks.length), + ), + ), ); } } else { @@ -565,7 +601,9 @@ class _PlaylistScreenState extends ConsumerState { } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added $addedCount tracks to Loved')), + SnackBar( + content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)), + ), ); } } @@ -577,36 +615,86 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTracks(BuildContext context, List tracks) { if (tracks.isEmpty) return; + + // Skip already-downloaded tracks + final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); + final localLibState = + (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) + ? ref.read(localLibraryProvider) + : null; + final tracksToQueue = []; + int skippedCount = 0; + + for (final track in tracks) { + final isInHistory = + historyState.isDownloaded(track.id) || + (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || + historyState.findByTrackAndArtist(track.name, track.artistName) != + null; + final isInLocal = + localLibState?.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ) ?? + false; + + if (isInHistory || isInLocal) { + skippedCount++; + } else { + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.discographySkippedDownloaded(0, skippedCount), + ), + ), + ); + return; + } + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '${tracks.length} tracks', - artistName: widget.playlistName, + trackName: '${tracksToQueue.length} tracks', + artistName: _playlistName, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToQueue(tracks.length), - ), - ), - ); + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: quality, + playlistName: _playlistName, + ); + _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); }, ); } else { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), - ), - ); + .addMultipleToQueue( + tracksToQueue, + settings.defaultService, + playlistName: _playlistName, + ); + _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); } } + + void _showQueuedSnackbar(BuildContext context, int added, int skipped) { + final message = skipped > 0 + ? context.l10n.discographySkippedDownloaded(added, skipped) + : context.l10n.snackbarAddedTracksToQueue(added); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } } /// Separate Consumer widget for each track - only rebuilds when this specific track's status changes diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 1aa3bad3..0e632411 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -17,11 +17,13 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -29,7 +31,9 @@ import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; +import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; enum LibraryItemSource { downloaded, local } @@ -104,7 +108,7 @@ class UnifiedLibraryItem { artistName: item.artistName, albumName: item.albumName, coverUrl: null, // Local library doesn't have cover URLs - localCoverPath: item.coverPath, // Use extracted cover path + localCoverPath: item.coverPath, filePath: item.filePath, quality: quality, addedAt: item.fileModTime != null @@ -313,6 +317,348 @@ class _QueueItemIdsSnapshot { int get hashCode => Object.hashAll(ids); } +class _QueueGroupedAlbumFilterRequest { + final String searchQuery; + final String? filterSource; + final String? filterQuality; + final String? filterFormat; + final String sortMode; + + const _QueueGroupedAlbumFilterRequest({ + required this.searchQuery, + required this.filterSource, + required this.filterQuality, + required this.filterFormat, + required this.sortMode, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _QueueGroupedAlbumFilterRequest && + searchQuery == other.searchQuery && + filterSource == other.filterSource && + filterQuality == other.filterQuality && + filterFormat == other.filterFormat && + sortMode == other.sortMode; + + @override + int get hashCode => Object.hash( + searchQuery, + filterSource, + filterQuality, + filterFormat, + sortMode, + ); +} + +String _queueFileExtLower(String filePath) { + final slashIndex = filePath.lastIndexOf('/'); + final dotIndex = filePath.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex < slashIndex + 1) { + return ''; + } + return filePath.substring(dotIndex + 1).toLowerCase(); +} + +String? _queueLocalQualityLabel(LocalLibraryItem item) { + if (item.bitrate != null && item.bitrate! > 0) { + return '${item.bitrate}kbps'; + } + if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { + return null; + } + return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; +} + +bool _queuePassesQualityFilter(String? filterQuality, String? quality) { + if (filterQuality == null) return true; + if (quality == null) return filterQuality == 'lossy'; + final normalized = quality.toLowerCase(); + switch (filterQuality) { + case 'hires': + return normalized.startsWith('24'); + case 'cd': + return normalized.startsWith('16'); + case 'lossy': + return !normalized.startsWith('24') && !normalized.startsWith('16'); + default: + return true; + } +} + +bool _queuePassesFormatFilter(String? filterFormat, String filePath) { + if (filterFormat == null) return true; + return _queueFileExtLower(filePath) == filterFormat; +} + +_HistoryStats _buildQueueHistoryStats( + List items, [ + List localItems = const [], +]) { + final albumCounts = {}; + final albumMap = >{}; + for (final item in items) { + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + albumCounts[key] = (albumCounts[key] ?? 0) + 1; + albumMap.putIfAbsent(key, () => []).add(item); + } + + var singleTracks = 0; + for (final item in items) { + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + if ((albumCounts[key] ?? 0) <= 1) { + singleTracks++; + } + } + + final groupedAlbums = <_GroupedAlbum>[]; + albumMap.forEach((_, tracks) { + if (tracks.length <= 1) return; + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + groupedAlbums.add( + _GroupedAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverUrl: tracks.first.coverUrl, + sampleFilePath: tracks.first.filePath, + tracks: tracks, + latestDownload: tracks + .map((t) => t.downloadedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ), + ); + }); + groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); + + var albumCount = 0; + for (final count in albumCounts.values) { + if (count > 1) albumCount++; + } + + final downloadedPathKeys = {}; + for (final item in items) { + downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); + } + + final dedupedLocalItems = localItems + .where((item) { + final localPathKeys = buildPathMatchKeys(item.filePath); + return !localPathKeys.any(downloadedPathKeys.contains); + }) + .toList(growable: false); + + final localAlbumCounts = {}; + final localAlbumMap = >{}; + for (final item in dedupedLocalItems) { + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; + localAlbumMap.putIfAbsent(key, () => []).add(item); + } + + var localAlbumCount = 0; + var localSingleTracks = 0; + for (final count in localAlbumCounts.values) { + if (count > 1) { + localAlbumCount++; + } else { + localSingleTracks++; + } + } + + final groupedLocalAlbums = <_GroupedLocalAlbum>[]; + localAlbumMap.forEach((_, tracks) { + if (tracks.length <= 1) return; + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + groupedLocalAlbums.add( + _GroupedLocalAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverPath: tracks + .firstWhere( + (t) => t.coverPath != null && t.coverPath!.isNotEmpty, + orElse: () => tracks.first, + ) + .coverPath, + tracks: tracks, + latestScanned: tracks + .map((t) => t.scannedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ), + ); + }); + groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned)); + + return _HistoryStats( + albumCounts: albumCounts, + localAlbumCounts: localAlbumCounts, + groupedAlbums: groupedAlbums, + groupedLocalAlbums: groupedLocalAlbums, + albumCount: albumCount, + singleTracks: singleTracks, + localAlbumCount: localAlbumCount, + localSingleTracks: localSingleTracks, + ); +} + +List<_GroupedAlbum> _queueFilterGroupedAlbums( + List<_GroupedAlbum> albums, + _QueueGroupedAlbumFilterRequest request, +) { + if (request.filterSource == 'local') return const []; + if (request.filterSource == null && + request.filterQuality == null && + request.filterFormat == null && + request.searchQuery.isEmpty && + request.sortMode == 'latest') { + return albums; + } + + final result = <_GroupedAlbum>[]; + for (final album in albums) { + if (request.searchQuery.isNotEmpty && + !album.searchKey.contains(request.searchQuery)) { + continue; + } + + if (request.filterQuality != null || request.filterFormat != null) { + var hasMatchingTrack = false; + for (final track in album.tracks) { + if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) { + continue; + } + if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { + continue; + } + hasMatchingTrack = true; + break; + } + if (!hasMatchingTrack) continue; + } + + result.add(album); + } + + switch (request.sortMode) { + case 'oldest': + result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload)); + case 'a-z': + result.sort( + (a, b) => + a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), + ); + case 'z-a': + result.sort( + (a, b) => + b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), + ); + default: + break; + } + return result; +} + +List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( + List<_GroupedLocalAlbum> albums, + _QueueGroupedAlbumFilterRequest request, +) { + if (request.filterSource == 'downloaded') return const []; + if (request.filterSource == null && + request.filterQuality == null && + request.filterFormat == null && + request.searchQuery.isEmpty && + request.sortMode == 'latest') { + return albums; + } + + final result = <_GroupedLocalAlbum>[]; + for (final album in albums) { + if (request.searchQuery.isNotEmpty && + !album.searchKey.contains(request.searchQuery)) { + continue; + } + + if (request.filterQuality != null || request.filterFormat != null) { + var hasMatchingTrack = false; + for (final track in album.tracks) { + if (!_queuePassesQualityFilter( + request.filterQuality, + _queueLocalQualityLabel(track), + )) { + continue; + } + if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { + continue; + } + hasMatchingTrack = true; + break; + } + if (!hasMatchingTrack) continue; + } + + result.add(album); + } + + switch (request.sortMode) { + case 'oldest': + result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned)); + case 'a-z': + result.sort( + (a, b) => + a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), + ); + case 'z-a': + result.sort( + (a, b) => + b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), + ); + default: + break; + } + return result; +} + +final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) { + final historyItems = ref.watch( + downloadHistoryProvider.select((s) => s.items), + ); + final localLibraryEnabled = ref.watch( + settingsProvider.select((s) => s.localLibraryEnabled), + ); + final localItems = localLibraryEnabled + ? ref.watch(localLibraryProvider.select((s) => s.items)) + : const []; + return _buildQueueHistoryStats(historyItems, localItems); +}); + +final _queueFilteredAlbumsProvider = + Provider.family< + ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}), + _QueueGroupedAlbumFilterRequest + >((ref, request) { + final historyStats = ref.watch(_queueHistoryStatsProvider); + return ( + albums: _queueFilterGroupedAlbums(historyStats.groupedAlbums, request), + localAlbums: _queueFilterGroupedLocalAlbums( + historyStats.groupedLocalAlbums, + request, + ), + ); + }); + Map> _filterHistoryInIsolate(Map payload) { final entries = (payload['entries'] as List).cast(); final albumCounts = (payload['albumCounts'] as Map).cast(); @@ -430,14 +776,6 @@ class _QueueTabState extends ConsumerState { String? _filterCacheQuality; String? _filterCacheFormat; String _filterCacheSortMode = 'latest'; - _HistoryStats? _groupedAlbumFilterHistoryStatsCache; - String _groupedAlbumFilterSearchQuery = ''; - String? _groupedAlbumFilterSource; - String? _groupedAlbumFilterQuality; - String? _groupedAlbumFilterFormat; - String _groupedAlbumFilterSortMode = 'latest'; - List<_GroupedAlbum> _filteredGroupedAlbumsCache = const []; - List<_GroupedLocalAlbum> _filteredGroupedLocalAlbumsCache = const []; // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' @@ -548,6 +886,7 @@ class _QueueTabState extends ConsumerState { void _ensureHistoryCaches( List items, List localItems, + _HistoryStats historyStats, ) { final historyChanged = !identical(items, _historyItemsCache); final localChanged = !identical(localItems, _localLibraryItemsCache); @@ -556,7 +895,7 @@ class _QueueTabState extends ConsumerState { _historyItemsCache = items; _localLibraryItemsCache = localItems; - _historyStatsCache = _buildHistoryStats(items, localItems); + _historyStatsCache = historyStats; if (historyChanged) { _searchIndexCache.clear(); } @@ -978,6 +1317,94 @@ class _QueueTabState extends ConsumerState { }); } + Future _downloadAllSelectedPlaylists(BuildContext context) async { + final collectionsState = ref.read(libraryCollectionsProvider); + final selectedPlaylists = collectionsState.playlists + .where((p) => _selectedPlaylistIds.contains(p.id)) + .toList(); + + final totalTracks = selectedPlaylists.fold( + 0, + (sum, p) => sum + p.tracks.length, + ); + + if (totalTracks == 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarSelectedPlaylistsEmpty)), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(ctx.l10n.dialogDownloadAllTitle), + content: Text( + ctx.l10n.dialogDownloadPlaylistsMessage( + totalTracks, + selectedPlaylists.length, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(ctx.l10n.dialogDownload), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + final settings = ref.read(settingsProvider); + final queueNotifier = ref.read(downloadQueueProvider.notifier); + + void enqueueAll({String? qualityOverride, String? service}) { + final svc = service ?? settings.defaultService; + for (final playlist in selectedPlaylists) { + final tracks = playlist.tracks.map((e) => e.track).toList(); + queueNotifier.addMultipleToQueue( + tracks, + svc, + qualityOverride: qualityOverride, + playlistName: playlist.name, + ); + } + } + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: context.l10n.tracksCount(totalTracks), + artistName: context.l10n.playlistsCount(selectedPlaylists.length), + onSelect: (quality, service) { + enqueueAll(qualityOverride: quality, service: service); + if (!mounted) return; + _exitPlaylistSelectionMode(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(totalTracks), + ), + ), + ); + }, + ); + } else { + enqueueAll(); + _exitPlaylistSelectionMode(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedTracksToQueue(totalTracks)), + ), + ); + } + } + Future _deleteSelectedPlaylists(BuildContext context) async { final count = _selectedPlaylistIds.length; final confirmed = await showDialog( @@ -1116,6 +1543,37 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _downloadAllSelectedPlaylists(context) + : null, + icon: const Icon(Icons.download_rounded), + label: Text( + selectedCount > 0 + ? context.l10n.bulkDownloadPlaylistsButton( + selectedCount, + ) + : context.l10n.bulkDownloadSelectPlaylists, + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.primary + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + + const SizedBox(height: 8), + SizedBox( width: double.infinity, child: FilledButton.icon( @@ -1360,18 +1818,6 @@ class _QueueTabState extends ConsumerState { return filePath.substring(dotIndex + 1).toLowerCase(); } - String? _localQualityLabel(LocalLibraryItem item) { - if (item.bitrate != null && item.bitrate! > 0) { - return '${item.bitrate}kbps'; - } - if (item.bitDepth == null || - item.bitDepth == 0 || - item.sampleRate == null) { - return null; - } - return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; - } - List _applyAdvancedFilters( List items, ) { @@ -1444,179 +1890,6 @@ class _QueueTabState extends ConsumerState { return sorted; } - bool _passesQualityFilter(String? quality) { - if (_filterQuality == null) return true; - if (quality == null) return _filterQuality == 'lossy'; - final q = quality.toLowerCase(); - switch (_filterQuality) { - case 'hires': - return q.startsWith('24'); - case 'cd': - return q.startsWith('16'); - case 'lossy': - return !q.startsWith('24') && !q.startsWith('16'); - default: - return true; - } - } - - bool _passesFormatFilter(String filePath) { - if (_filterFormat == null) return true; - return _fileExtLower(filePath) == _filterFormat; - } - - List<_GroupedAlbum> _filterGroupedAlbums( - List<_GroupedAlbum> albums, - String searchQuery, - ) { - if (_activeFilterCount == 0 && - searchQuery.isEmpty && - _sortMode == 'latest') { - return albums; - } - - // Source filter: if filtering local only, hide all download albums - if (_filterSource == 'local') return const []; - - final result = <_GroupedAlbum>[]; - for (final album in albums) { - if (searchQuery.isNotEmpty && !album.searchKey.contains(searchQuery)) { - continue; - } - - // Filter tracks within the album by advanced filters - if (_filterQuality != null || _filterFormat != null) { - var hasMatchingTrack = false; - for (final track in album.tracks) { - if (!_passesQualityFilter(track.quality)) continue; - if (!_passesFormatFilter(track.filePath)) continue; - hasMatchingTrack = true; - break; - } - - if (!hasMatchingTrack) continue; - } - - result.add(album); - } - - // Apply sorting to albums - switch (_sortMode) { - case 'oldest': - result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload)); - case 'a-z': - result.sort( - (a, b) => - a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), - ); - case 'z-a': - result.sort( - (a, b) => - b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), - ); - default: // 'latest' - already sorted - break; - } - - return result; - } - - List<_GroupedLocalAlbum> _filterGroupedLocalAlbums( - List<_GroupedLocalAlbum> albums, - String searchQuery, - ) { - if (_activeFilterCount == 0 && - searchQuery.isEmpty && - _sortMode == 'latest') { - return albums; - } - - // Source filter: if filtering downloaded only, hide all local albums - if (_filterSource == 'downloaded') return const []; - - final result = <_GroupedLocalAlbum>[]; - for (final album in albums) { - if (searchQuery.isNotEmpty && !album.searchKey.contains(searchQuery)) { - continue; - } - - // Filter tracks within the album by advanced filters - if (_filterQuality != null || _filterFormat != null) { - var hasMatchingTrack = false; - for (final track in album.tracks) { - if (!_passesQualityFilter(_localQualityLabel(track))) continue; - if (!_passesFormatFilter(track.filePath)) continue; - hasMatchingTrack = true; - break; - } - - if (!hasMatchingTrack) continue; - } - - result.add(album); - } - - // Apply sorting to local albums - switch (_sortMode) { - case 'oldest': - result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned)); - case 'a-z': - result.sort( - (a, b) => - a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), - ); - case 'z-a': - result.sort( - (a, b) => - b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), - ); - default: // 'latest' - already sorted - break; - } - - return result; - } - - ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}) - _resolveFilteredGroupedAlbums(_HistoryStats historyStats) { - final cacheValid = - identical(_groupedAlbumFilterHistoryStatsCache, historyStats) && - _groupedAlbumFilterSearchQuery == _searchQuery && - _groupedAlbumFilterSource == _filterSource && - _groupedAlbumFilterQuality == _filterQuality && - _groupedAlbumFilterFormat == _filterFormat && - _groupedAlbumFilterSortMode == _sortMode; - - if (cacheValid) { - return ( - albums: _filteredGroupedAlbumsCache, - localAlbums: _filteredGroupedLocalAlbumsCache, - ); - } - - final filteredGroupedAlbums = _filterGroupedAlbums( - historyStats.groupedAlbums, - _searchQuery, - ); - final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( - historyStats.groupedLocalAlbums, - _searchQuery, - ); - - _groupedAlbumFilterHistoryStatsCache = historyStats; - _groupedAlbumFilterSearchQuery = _searchQuery; - _groupedAlbumFilterSource = _filterSource; - _groupedAlbumFilterQuality = _filterQuality; - _groupedAlbumFilterFormat = _filterFormat; - _groupedAlbumFilterSortMode = _sortMode; - _filteredGroupedAlbumsCache = filteredGroupedAlbums; - _filteredGroupedLocalAlbumsCache = filteredGroupedLocalAlbums; - return ( - albums: filteredGroupedAlbums, - localAlbums: filteredGroupedLocalAlbums, - ); - } - Set _getAvailableFormats(List items) { final formats = {}; for (final item in items) { @@ -2059,123 +2332,6 @@ class _QueueTabState extends ConsumerState { } } - _HistoryStats _buildHistoryStats( - List items, [ - List localItems = const [], - ]) { - final albumCounts = {}; - final albumMap = >{}; - for (final item in items) { - // Use lowercase key for case-insensitive grouping - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - albumCounts[key] = (albumCounts[key] ?? 0) + 1; - albumMap.putIfAbsent(key, () => []).add(item); - } - - int singleTracks = 0; - for (final item in items) { - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - if ((albumCounts[key] ?? 0) <= 1) { - singleTracks++; - } - } - - final groupedAlbums = <_GroupedAlbum>[]; - albumMap.forEach((_, tracks) { - if (tracks.length <= 1) return; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - groupedAlbums.add( - _GroupedAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverUrl: tracks.first.coverUrl, - sampleFilePath: tracks.first.filePath, - tracks: tracks, - latestDownload: tracks - .map((t) => t.downloadedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ), - ); - }); - - groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); - - int albumCount = 0; - for (final count in albumCounts.values) { - if (count > 1) albumCount++; - } - - // Calculate local library stats - final localAlbumCounts = {}; - final localAlbumMap = >{}; - for (final item in localItems) { - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; - localAlbumMap.putIfAbsent(key, () => []).add(item); - } - - int localAlbumCount = 0; - int localSingleTracks = 0; - for (final count in localAlbumCounts.values) { - if (count > 1) { - localAlbumCount++; - } else { - localSingleTracks++; - } - } - - // Build grouped local albums - final groupedLocalAlbums = <_GroupedLocalAlbum>[]; - localAlbumMap.forEach((_, tracks) { - if (tracks.length <= 1) return; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - groupedLocalAlbums.add( - _GroupedLocalAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverPath: tracks - .firstWhere( - (t) => t.coverPath != null && t.coverPath!.isNotEmpty, - orElse: () => tracks.first, - ) - .coverPath, - tracks: tracks, - latestScanned: tracks - .map((t) => t.scannedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ), - ); - }); - - groupedLocalAlbums.sort( - (a, b) => b.latestScanned.compareTo(a.latestScanned), - ); - - return _HistoryStats( - albumCounts: albumCounts, - localAlbumCounts: localAlbumCounts, - groupedAlbums: groupedAlbums, - groupedLocalAlbums: groupedLocalAlbums, - albumCount: albumCount, - singleTracks: singleTracks, - localAlbumCount: localAlbumCount, - localSingleTracks: localSingleTracks, - ); - } - void _navigateToDownloadedAlbum(_GroupedAlbum album) { _searchFocusNode.unfocus(); Navigator.push( @@ -2529,8 +2685,19 @@ class _QueueTabState extends ConsumerState { ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; final collectionState = ref.watch(libraryCollectionsProvider); - - _ensureHistoryCaches(allHistoryItems, localLibraryItems); + final historyStats = ref.watch(_queueHistoryStatsProvider); + final filteredGrouped = ref.watch( + _queueFilteredAlbumsProvider( + _QueueGroupedAlbumFilterRequest( + searchQuery: _searchQuery, + filterSource: _filterSource, + filterQuality: _filterQuality, + filterFormat: _filterFormat, + sortMode: _sortMode, + ), + ), + ); + _ensureHistoryCaches(allHistoryItems, localLibraryItems, historyStats); final historyViewMode = ref.watch( settingsProvider.select((s) => s.historyViewMode), ); @@ -2539,11 +2706,6 @@ class _QueueTabState extends ConsumerState { ); final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); - - final historyStats = - _historyStatsCache ?? - _buildHistoryStats(allHistoryItems, localLibraryItems); - final filteredGrouped = _resolveFilteredGroupedAlbums(historyStats); final filteredGroupedAlbums = filteredGrouped.albums; final filteredGroupedLocalAlbums = filteredGrouped.localAlbums; final albumCount = historyStats.totalAlbumCount; @@ -2841,8 +3003,24 @@ class _QueueTabState extends ConsumerState { .map((item) => UnifiedLibraryItem.fromLocalLibrary(item)) .toList(growable: false); - final merged = [...unifiedDownloaded, ...unifiedLocal] - ..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + final downloadedPathKeys = {}; + for (final item in unifiedDownloaded) { + downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); + } + + final dedupedUnifiedLocal = []; + for (final item in unifiedLocal) { + final localPathKeys = buildPathMatchKeys(item.filePath); + final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains); + if (!overlapsDownloaded) { + dedupedUnifiedLocal.add(item); + } + } + + final merged = [ + ...unifiedDownloaded, + ...dedupedUnifiedLocal, + ]..sort((a, b) => b.addedAt.compareTo(a.addedAt)); _unifiedItemsCache[filterMode] = _UnifiedCacheEntry( historyItems: historyItems, @@ -4222,6 +4400,11 @@ class _QueueTabState extends ConsumerState { final format = item.format?.toLowerCase(); final lowerPath = item.filePath.toLowerCase(); final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isM4A = + format == 'm4a' || + format == 'aac' || + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.aac'); final isOpus = format == 'opus' || format == 'ogg' || @@ -4235,6 +4418,12 @@ class _QueueTabState extends ConsumerState { coverPath: effectiveCoverPath, metadata: metadata, ); + } else if (isM4A) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); } else if (isOpus) { ffmpegResult = await FFmpegService.embedMetadataToOpus( opusPath: ffmpegTarget, @@ -4306,6 +4495,132 @@ class _QueueTabState extends ConsumerState { return false; } + List _selectedFlacEligibleLocalItems( + List allItems, + ) { + final selectedItems = _selectedItemsFromAll(allItems); + return selectedItems + .map((item) => item.localItem) + .whereType() + .where(LocalTrackRedownloadService.isFlacUpgradeEligible) + .toList(growable: false); + } + + Future _queueSelectedLocalAsFlac( + List allItems, + ) async { + final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems); + + if (selectedLocalItems.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.queueFlacAction), + content: Text( + context.l10n.queueFlacConfirmMessage(selectedLocalItems.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.queueFlacAction), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + final includeExtensions = + settings.useExtensionProviders && + extensionState.extensions.any( + (ext) => ext.enabled && ext.hasMetadataProvider, + ); + final targetService = LocalTrackRedownloadService.preferredFlacService( + settings, + ); + final targetQuality = + LocalTrackRedownloadService.preferredFlacQualityForService( + targetService, + ); + + final matchedTracks = []; + var skippedCount = 0; + final total = selectedLocalItems.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)), + duration: const Duration(seconds: 30), + ), + ); + + try { + final resolution = await LocalTrackRedownloadService.resolveBestMatch( + selectedLocalItems[i], + includeExtensions: includeExtensions, + ); + if (resolution.canQueue && resolution.match != null) { + matchedTracks.add(resolution.match!); + } else { + skippedCount++; + } + } catch (_) { + skippedCount++; + } + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + + if (matchedTracks.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), + ); + return; + } + + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + matchedTracks, + targetService, + qualityOverride: targetQuality, + ); + + final summary = skippedCount == 0 + ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) + : context.l10n.queueFlacQueuedWithSkipped( + matchedTracks.length, + skippedCount, + ); + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); + setState(() { + _selectedIds.clear(); + _isSelectionMode = false; + }); + } + Future _reEnrichSelectedLocalFromQueue( List allItems, ) async { @@ -4456,8 +4771,51 @@ class _QueueTabState extends ConsumerState { BuildContext context, List allItems, ) async { - String selectedFormat = 'MP3'; - String selectedBitrate = '320k'; + final itemsById = {for (final item in allItems) item.id: item}; + final sourceFormats = {}; + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + String nameToCheck; + if (item.historyItem?.safFileName != null && + item.historyItem!.safFileName!.isNotEmpty) { + nameToCheck = item.historyItem!.safFileName!.toLowerCase(); + } else if (item.localItem?.format != null && + item.localItem!.format!.isNotEmpty) { + nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; + } else { + nameToCheck = item.filePath.toLowerCase(); + } + final ext = nameToCheck.endsWith('.flac') + ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' + : nameToCheck.endsWith('.mp3') + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' + : null; + if (ext != null) sourceFormats.add(ext); + } + + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { + return sourceFormats.any((src) { + if (src == target) return false; + final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; + final isLosslessSource = src == 'FLAC' || src == 'M4A'; + if (isLosslessTarget && !isLosslessSource) return false; + return true; + }); + }).toList(); + + if (formats.isEmpty) return; + + String selectedFormat = formats.first; + bool isLosslessTarget = + selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + String selectedBitrate = isLosslessTarget + ? '320k' + : (selectedFormat == 'Opus' ? '128k' : '320k'); var didStartConversion = false; _hideSelectionOverlay(); @@ -4473,7 +4831,6 @@ class _QueueTabState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( @@ -4510,51 +4867,73 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; + } + }); } }, ); }).toList(), ), + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + if (isLosslessTarget) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.primary), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -4630,14 +5009,19 @@ class _QueueTabState extends ConsumerState { } final ext = nameToCheck.endsWith('.flac') ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' : nameToCheck.endsWith('.mp3') ? 'MP3' : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' : null; - if (ext != null && ext != targetFormat) { - selectedItems.add(item); - } + if (ext == null || ext == targetFormat) continue; + // Skip lossy sources when target is lossless (pointless re-encoding) + final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; + if (isLosslessTarget && !isLosslessSource) continue; + selectedItems.add(item); } if (selectedItems.isEmpty) { @@ -4650,16 +5034,22 @@ class _QueueTabState extends ConsumerState { } // Confirm + final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - context.l10n.selectionBatchConvertConfirmMessage( - selectedItems.length, - targetFormat, - bitrate, - ), + isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selectedItems.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selectedItems.length, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -4680,7 +5070,10 @@ class _QueueTabState extends ConsumerState { final total = selectedItems.length; final historyDb = HistoryDatabase.instance; final newQuality = - '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + (targetFormat.toUpperCase() == 'ALAC' || + targetFormat.toUpperCase() == 'FLAC') + ? '${targetFormat.toUpperCase()} Lossless' + : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; @@ -4700,7 +5093,6 @@ class _QueueTabState extends ConsumerState { ); try { - // Read metadata from file final metadata = { 'TITLE': item.trackName, 'ARTIST': item.artistName, @@ -4709,12 +5101,7 @@ class _QueueTabState extends ConsumerState { try { final result = await PlatformBridge.readFileMetadata(item.filePath); if (result['error'] == null) { - result.forEach((key, value) { - if (key == 'error' || value == null) return; - final v = value.toString().trim(); - if (v.isEmpty) return; - metadata[key.toUpperCase()] = v; - }); + mergePlatformMetadataForTagEmbed(target: metadata, source: result); } } catch (_) {} await ensureLyricsMetadataForConversion( @@ -4729,7 +5116,6 @@ class _QueueTabState extends ConsumerState { 1000, ); - // Extract cover art String? coverPath; try { final tempDir = await getTemporaryDirectory(); @@ -4744,7 +5130,6 @@ class _QueueTabState extends ConsumerState { } } catch (_) {} - // Handle SAF vs regular file String workingPath = item.filePath; final isSaf = isContentUri(item.filePath); String? safTempPath; @@ -4757,7 +5142,6 @@ class _QueueTabState extends ConsumerState { workingPath = safTempPath; } - // Convert final newPath = await FFmpegService.convertAudioFormat( inputPath: workingPath, targetFormat: targetFormat.toLowerCase(), @@ -4767,7 +5151,6 @@ class _QueueTabState extends ConsumerState { deleteOriginal: !isSaf, ); - // Cleanup cover temp if (coverPath != null) { try { await File(coverPath).delete(); @@ -4794,13 +5177,27 @@ class _QueueTabState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' - ? '.opus' - : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -4984,6 +5381,9 @@ class _QueueTabState extends ConsumerState { final allSelected = selectedCount == unifiedItems.length && unifiedItems.isNotEmpty; final localOnlySelection = _isLocalOnlySelection(unifiedItems); + final flacEligibleCount = _selectedFlacEligibleLocalItems( + unifiedItems, + ).length; return Container( decoration: BoxDecoration( @@ -5073,6 +5473,19 @@ class _QueueTabState extends ConsumerState { // Action buttons row: Share/Re-enrich, Convert, Delete Row( children: [ + if (localOnlySelection && flacEligibleCount > 0) ...[ + Expanded( + child: _SelectionActionButton( + icon: Icons.download_for_offline_outlined, + label: + '${context.l10n.queueFlacAction} ($flacEligibleCount)', + onPressed: () => + _queueSelectedLocalAsFlac(unifiedItems), + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), + ], Expanded( child: _SelectionActionButton( icon: localOnlySelection diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 4523fbee..42118769 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; @@ -27,10 +28,7 @@ class _SearchScreenState extends ConsumerState { _searchController = TextEditingController(text: widget.query); if (widget.query.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { - final settings = ref.read(settingsProvider); - ref - .read(trackProvider.notifier) - .search(widget.query, metadataSource: settings.metadataSource); + ref.read(trackProvider.notifier).search(widget.query); }); } } @@ -44,10 +42,7 @@ class _SearchScreenState extends ConsumerState { void _search() { final query = _searchController.text.trim(); if (query.isNotEmpty) { - final settings = ref.read(settingsProvider); - ref - .read(trackProvider.notifier) - .search(query, metadataSource: settings.metadataSource); + ref.read(trackProvider.notifier).search(query); } } @@ -58,7 +53,7 @@ class _SearchScreenState extends ConsumerState { .addToQueue(track, settings.defaultService); ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); } @override diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 81fd1aa5..14cdef2b 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -234,7 +234,7 @@ class AboutPage extends StatelessWidget { icon: Icons.info_outline, title: context.l10n.aboutVersion, subtitle: - 'v${AppInfo.version} (build ${AppInfo.buildNumber})', + 'v${AppInfo.displayVersion} (build ${AppInfo.buildNumber})', showDivider: false, ), ], @@ -341,7 +341,7 @@ class _AppHeaderCard extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Text( - 'v${AppInfo.version}', + 'v${AppInfo.displayVersion}', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, @@ -511,6 +511,30 @@ class _TranslatorsSection extends StatelessWidget { language: 'Japanese', flag: '🇯🇵', ), + _Translator( + name: 'unkn0wn', + crowdinUsername: 'rdclvi', + language: 'Indonesian', + flag: '🇮🇩', + ), + _Translator( + name: 'lunching1272', + crowdinUsername: 'lunching1272', + language: 'Chinese Simplified', + flag: '🇨🇳', + ), + _Translator( + name: 'Сергей Ильченко', + crowdinUsername: 'Sega_Mostky', + language: 'Russian', + flag: '🇷🇺', + ), + _Translator( + name: 'Girl-lass', + crowdinUsername: 'Girl-lass', + language: 'Chinese Simplified', + flag: '🇨🇳', + ), _Translator( name: 'Kaan', crowdinUsername: 'glai', diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index edb3eed3..e8b43677 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -56,7 +56,7 @@ class _CacheManagementPageState extends ConsumerState { setState(() => _isLoading = false); ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); + ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); } } @@ -282,7 +282,7 @@ class _CacheManagementPageState extends ConsumerState { if (!mounted) return; ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); + ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); } finally { if (mounted) { setState(() => _busyAction = null); @@ -394,7 +394,7 @@ class _CacheManagementPageState extends ConsumerState { ), actions: [ IconButton( - tooltip: 'Refresh', + tooltip: context.l10n.cacheRefresh, onPressed: _isBusy ? null : _refreshOverview, icon: const Icon(Icons.refresh), ), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 175b0793..5ba2fcce 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -164,7 +164,13 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - const donorNames = []; + const donorNames = [ + 'McNuggets Jimmy', + 'zcc09', + 'micahRichie', + 'a fan', + 'CJBGR', + ]; // Match SettingsGroup color logic final cardColor = isDark @@ -479,32 +485,78 @@ int _cr(String v) { return r; } -// Highlighted supporters (hashes of names): none for now. -const _cv = {}; +// Highlighted supporters (hashes of names). +const _cv = {1211573191, 1003219236}; -class _SupporterChip extends StatelessWidget { +// Diamond tier supporters ($50+ donors). +const _dv = {560908930}; + +enum _SupporterTier { normal, gold, diamond } + +_SupporterTier _tierOf(String name) { + final h = _cr(name); + if (_dv.contains(h)) return _SupporterTier.diamond; + if (_cv.contains(h)) return _SupporterTier.gold; + return _SupporterTier.normal; +} + +class _SupporterChip extends StatefulWidget { final String name; final ColorScheme colorScheme; const _SupporterChip({required this.name, required this.colorScheme}); + @override + State<_SupporterChip> createState() => _SupporterChipState(); +} + +class _SupporterChipState extends State<_SupporterChip> + with SingleTickerProviderStateMixin { + late final _SupporterTier _tier; + AnimationController? _shimmerController; + + @override + void initState() { + super.initState(); + _tier = _tierOf(widget.name); + if (_tier == _SupporterTier.diamond) { + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2400), + )..repeat(); + } + } + + @override + void dispose() { + _shimmerController?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final e = _cv.contains(_cr(name)); + final isDark = Theme.of(context).brightness == Brightness.dark; + + if (_tier == _SupporterTier.diamond) { + return _buildDiamondChip(isDark); + } + + final isGold = _tier == _SupporterTier.gold; const goldChipColor = Color(0xFFFFF8DC); const goldAccentColor = Color(0xFFB8860B); const goldDarkChipColor = Color(0xFF3A3000); - final chipColor = e ? goldChipColor : colorScheme.secondaryContainer; - final accentColor = e ? goldAccentColor : colorScheme.primary; - final isDark = Theme.of(context).brightness == Brightness.dark; - final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor; + final chipColor = isGold + ? goldChipColor + : widget.colorScheme.secondaryContainer; + final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary; + final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor; return Material( color: effectiveChipColor, borderRadius: BorderRadius.circular(20), child: Container( - decoration: e + decoration: isGold ? BoxDecoration( borderRadius: BorderRadius.circular(20), border: Border.all( @@ -520,10 +572,12 @@ class _SupporterChip extends StatelessWidget { CircleAvatar( radius: 10, backgroundColor: accentColor.withValues(alpha: 0.2), - child: e + child: isGold ? Icon(Icons.star_rounded, size: 12, color: accentColor) : Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', + widget.name.isNotEmpty + ? widget.name[0].toUpperCase() + : '?', style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, @@ -533,10 +587,12 @@ class _SupporterChip extends StatelessWidget { ), const SizedBox(width: 8), Text( - name, + widget.name, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: e ? accentColor : colorScheme.onSecondaryContainer, - fontWeight: e ? FontWeight.w600 : FontWeight.w500, + color: isGold + ? accentColor + : widget.colorScheme.onSecondaryContainer, + fontWeight: isGold ? FontWeight.w600 : FontWeight.w500, ), ), ], @@ -544,6 +600,92 @@ class _SupporterChip extends StatelessWidget { ), ); } + + Widget _buildDiamondChip(bool isDark) { + const diamondLight = Color(0xFFE8F4FD); + const diamondDark = Color(0xFF0D2B3E); + const diamondAccent = Color(0xFF4FC3F7); + const diamondHighlight = Color(0xFFB3E5FC); + + final chipBg = isDark ? diamondDark : diamondLight; + + return AnimatedBuilder( + animation: _shimmerController!, + builder: (context, child) { + final t = _shimmerController!.value; + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment(-2.0 + 4.0 * t, 0.0), + end: Alignment(-1.0 + 4.0 * t, 0.0), + colors: [ + chipBg, + isDark + ? diamondAccent.withValues(alpha: 0.18) + : diamondHighlight.withValues(alpha: 0.7), + chipBg, + ], + stops: const [0.0, 0.5, 1.0], + ), + border: Border.all( + color: diamondAccent.withValues( + alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()), + ), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: diamondAccent.withValues( + alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()), + ), + blurRadius: 8, + spreadRadius: 0, + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + diamondAccent.withValues(alpha: 0.3), + diamondAccent.withValues(alpha: 0.15), + ], + ), + ), + child: const Icon( + Icons.diamond_rounded, + size: 12, + color: diamondAccent, + ), + ), + const SizedBox(width: 8), + Text( + widget.name, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: isDark ? diamondHighlight : diamondAccent, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + }, + ); + } } class _NoticeLine extends StatelessWidget { diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 1987bb2d..85470e99 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -6,6 +6,7 @@ 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/models/settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -376,7 +377,7 @@ class _DownloadSettingsPageState extends ConsumerState { title: context.l10n.downloadAskBeforeDownload, subtitle: isBuiltInService ? context.l10n.downloadAskQualitySubtitle - : 'Select a built-in service to enable', + : context.l10n.downloadSelectServiceToEnable, value: settings.askQualityBeforeDownload, enabled: isBuiltInService, onChanged: (value) => ref @@ -410,11 +411,12 @@ class _DownloadSettingsPageState extends ConsumerState { .setAudioQuality('HI_RES_LOSSLESS'), showDivider: isTidalService, ), - // Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus + // Lossy 320kbps option (Tidal only) - downloads M4A AAC from server, converts to MP3/Opus if (isTidalService) _QualityOption( - title: 'Lossy 320kbps', + title: context.l10n.downloadLossy320, subtitle: _getTidalHighFormatLabel( + context, settings.tidalHighFormat, ), isSelected: settings.audioQuality == 'HIGH', @@ -426,8 +428,9 @@ class _DownloadSettingsPageState extends ConsumerState { if (isTidalService && settings.audioQuality == 'HIGH') SettingsItem( icon: Icons.tune, - title: 'Lossy Format', + title: context.l10n.downloadLossyFormat, subtitle: _getTidalHighFormatLabel( + context, settings.tidalHighFormat, ), onTap: () => _showTidalHighFormatPicker( @@ -451,7 +454,7 @@ class _DownloadSettingsPageState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - 'Select Tidal or Qobuz above to configure quality', + context.l10n.downloadSelectTidalQobuz, style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, @@ -464,12 +467,13 @@ class _DownloadSettingsPageState extends ConsumerState { ], SettingsItem( title: context.l10n.youtubeOpusBitrateTitle, - subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)', + subtitle: + '${settings.youtubeOpusBitrate}kbps (128/256/320)', onTap: () => _showYoutubeBitratePicker( context: context, title: context.l10n.youtubeOpusBitrateTitle, currentValue: settings.youtubeOpusBitrate, - options: const [128, 256], + options: const [128, 256, 320], onSave: (value) => ref .read(settingsProvider.notifier) .setYoutubeOpusBitrate(value), @@ -504,7 +508,7 @@ class _DownloadSettingsPageState extends ConsumerState { title: context.l10n.optionsEmbedLyrics, subtitle: settings.embedMetadata ? context.l10n.optionsEmbedLyricsSubtitle - : 'Disabled while Embed Metadata is turned off', + : context.l10n.downloadEmbedLyricsDisabled, value: settings.embedLyrics, enabled: settings.embedMetadata, onChanged: (value) => ref @@ -528,7 +532,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), SettingsItem( icon: Icons.source_outlined, - title: 'Lyrics Providers', + title: context.l10n.lyricsProvidersTitle, subtitle: _getLyricsProvidersSubtitle( settings.lyricsProviders, ), @@ -541,10 +545,14 @@ class _DownloadSettingsPageState extends ConsumerState { ), SettingsSwitchItem( icon: Icons.translate_outlined, - title: 'Netease: Include Translation', + title: context.l10n.downloadNeteaseIncludeTranslation, subtitle: settings.lyricsIncludeTranslationNetease - ? 'Append translated lyrics when available' - : 'Use original lyrics only', + ? context + .l10n + .downloadNeteaseIncludeTranslationEnabled + : context + .l10n + .downloadNeteaseIncludeTranslationDisabled, value: settings.lyricsIncludeTranslationNetease, onChanged: (value) => ref .read(settingsProvider.notifier) @@ -552,10 +560,14 @@ class _DownloadSettingsPageState extends ConsumerState { ), SettingsSwitchItem( icon: Icons.text_fields_outlined, - title: 'Netease: Include Romanization', + title: context.l10n.downloadNeteaseIncludeRomanization, subtitle: settings.lyricsIncludeRomanizationNetease - ? 'Append romanized lyrics when available' - : 'Disabled', + ? context + .l10n + .downloadNeteaseIncludeRomanizationEnabled + : context + .l10n + .downloadNeteaseIncludeRomanizationDisabled, value: settings.lyricsIncludeRomanizationNetease, onChanged: (value) => ref .read(settingsProvider.notifier) @@ -563,10 +575,10 @@ class _DownloadSettingsPageState extends ConsumerState { ), SettingsSwitchItem( icon: Icons.record_voice_over_outlined, - title: 'Apple/QQ Multi-Person Word-by-Word', + title: context.l10n.downloadAppleQqMultiPerson, subtitle: settings.lyricsMultiPersonWordByWord - ? 'Enable v1/v2 speaker and [bg:] tags' - : 'Simplified word-by-word formatting', + ? context.l10n.downloadAppleQqMultiPersonEnabled + : context.l10n.downloadAppleQqMultiPersonDisabled, value: settings.lyricsMultiPersonWordByWord, onChanged: (value) => ref .read(settingsProvider.notifier) @@ -574,9 +586,9 @@ class _DownloadSettingsPageState extends ConsumerState { ), SettingsItem( icon: Icons.language_outlined, - title: 'Musixmatch Language', + title: context.l10n.downloadMusixmatchLanguage, subtitle: settings.musixmatchLanguage.isEmpty - ? 'Auto (original)' + ? context.l10n.downloadMusixmatchLanguageAuto : settings.musixmatchLanguage.toUpperCase(), onTap: () => _showMusixmatchLanguagePicker( context, @@ -622,8 +634,8 @@ class _DownloadSettingsPageState extends ConsumerState { icon: Icons.library_music_outlined, title: context.l10n.downloadSeparateSinglesFolder, subtitle: settings.separateSingles - ? 'Albums/ and Singles/ folders' - : 'All files in same structure', + ? context.l10n.downloadSeparateSinglesEnabled + : context.l10n.downloadSeparateSinglesDisabled, value: settings.separateSingles, onChanged: (value) => ref .read(settingsProvider.notifier) @@ -655,6 +667,15 @@ class _DownloadSettingsPageState extends ConsumerState { settings.folderOrganization, ), ), + SettingsSwitchItem( + icon: Icons.playlist_play_outlined, + title: context.l10n.downloadCreatePlaylistSourceFolder, + subtitle: _getPlaylistFolderSubtitle(settings), + value: settings.createPlaylistFolder, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setCreatePlaylistFolder(value), + ), SettingsSwitchItem( icon: Icons.person_search_outlined, title: context.l10n.downloadUseAlbumArtistForFolders, @@ -672,7 +693,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), SettingsItem( icon: Icons.filter_alt_outlined, - title: 'Artist Name Filters', + title: context.l10n.downloadArtistNameFilters, subtitle: _getArtistFolderFilterSubtitle( context, usePrimaryArtistOnly: settings.usePrimaryArtistOnly, @@ -707,10 +728,10 @@ class _DownloadSettingsPageState extends ConsumerState { if (_artistFolderFiltersExpanded) SettingsSwitchItem( icon: Icons.group_remove_outlined, - title: 'Filter contributing artists in Album Artist', + title: context.l10n.downloadFilterContributing, subtitle: settings.filterContributingArtistsInAlbumArtist - ? 'Album Artist metadata uses primary artist only' - : 'Keep full Album Artist metadata value', + ? context.l10n.downloadFilterContributingEnabled + : context.l10n.downloadFilterContributingDisabled, value: settings.filterContributingArtistsInAlbumArtist, onChanged: (value) => ref .read(settingsProvider.notifier) @@ -741,7 +762,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), SettingsItem( icon: Icons.public, - title: 'SongLink Region', + title: context.l10n.downloadSongLinkRegion, subtitle: _getSongLinkRegionLabel(settings.songLinkRegion), onTap: () => _showSongLinkRegionPicker( context, @@ -751,10 +772,10 @@ class _DownloadSettingsPageState extends ConsumerState { ), SettingsSwitchItem( icon: Icons.security_outlined, - title: 'Network compatibility mode', + title: context.l10n.downloadNetworkCompatibilityMode, subtitle: settings.networkCompatibilityMode - ? 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)' - : 'Off: strict HTTPS certificate validation (recommended)', + ? context.l10n.downloadNetworkCompatibilityModeEnabled + : context.l10n.downloadNetworkCompatibilityModeDisabled, value: settings.networkCompatibilityMode, onChanged: (value) { ref @@ -1033,7 +1054,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), const SizedBox(height: 8), Text( - 'Customize how your files are named.', + context.l10n.downloadFilenameDescription, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -1058,7 +1079,7 @@ class _DownloadSettingsPageState extends ConsumerState { const SizedBox(height: 24), Text( - 'Tap to insert tag:', + context.l10n.downloadFilenameInsertTag, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), @@ -1170,7 +1191,7 @@ class _DownloadSettingsPageState extends ConsumerState { Future _pickDirectory(BuildContext context, WidgetRef ref) async { if (Platform.isIOS) { _showIOSDirectoryOptions(context, ref); - } else { + } else if (Platform.isAndroid) { _showAndroidDirectoryOptions(context, ref); } } @@ -1226,7 +1247,7 @@ class _DownloadSettingsPageState extends ConsumerState { Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( - 'Download Location', + context.l10n.setupDownloadLocationTitle, style: Theme.of( ctx, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), @@ -1235,7 +1256,7 @@ class _DownloadSettingsPageState extends ConsumerState { Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Text( - 'Choose storage mode for downloaded files.', + context.l10n.downloadLocationSubtitle, style: Theme.of(ctx).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -1243,8 +1264,8 @@ class _DownloadSettingsPageState extends ConsumerState { ), ListTile( leading: Icon(Icons.folder_special, color: colorScheme.primary), - title: const Text('App folder (non-SAF)'), - subtitle: const Text('Use default Music/SpotiFLAC path'), + title: Text(context.l10n.storageModeAppFolder), + subtitle: Text(context.l10n.storageModeAppFolderSubtitle), trailing: !isSafMode ? const Icon(Icons.check) : null, onTap: () async { Navigator.pop(ctx); @@ -1257,10 +1278,8 @@ class _DownloadSettingsPageState extends ConsumerState { ), ListTile( leading: Icon(Icons.folder_open, color: colorScheme.primary), - title: const Text('SAF folder'), - subtitle: const Text( - 'Pick folder via Android Storage Access Framework', - ), + title: Text(context.l10n.storageModeSaf), + subtitle: Text(context.l10n.storageModeSafSubtitle), trailing: isSafMode ? const Icon(Icons.check) : null, onTap: () async { Navigator.pop(ctx); @@ -1340,8 +1359,27 @@ class _DownloadSettingsPageState extends ConsumerState { subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), onTap: () async { Navigator.pop(ctx); + if (Platform.isIOS) { + await Future.delayed(const Duration(milliseconds: 250)); + } + // Note: iOS requires folder to have at least one file to be selectable - final result = await FilePicker.platform.getDirectoryPath(); + String? result; + try { + result = await FilePicker.platform.getDirectoryPath(); + } catch (e) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text('Failed to open folder picker: $e'), + backgroundColor: Theme.of(ctx).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); + } + return; + } + if (result != null) { // iOS: Validate the selected path is writable (not iCloud or container root) if (Platform.isIOS) { @@ -1405,6 +1443,8 @@ class _DownloadSettingsPageState extends ConsumerState { String _getFolderOrganizationLabel(String value) { switch (value) { + case 'playlist': + return 'By Playlist'; case 'artist': return 'By Artist'; case 'album': @@ -1416,6 +1456,16 @@ class _DownloadSettingsPageState extends ConsumerState { } } + String _getPlaylistFolderSubtitle(AppSettings settings) { + if (settings.folderOrganization == 'playlist') { + return context.l10n.downloadCreatePlaylistSourceFolderRedundant; + } + if (settings.createPlaylistFolder) { + return context.l10n.downloadCreatePlaylistSourceFolderEnabled; + } + return context.l10n.downloadCreatePlaylistSourceFolderDisabled; + } + String _getArtistFolderFilterSubtitle( BuildContext context, { required bool usePrimaryArtistOnly, @@ -1532,7 +1582,7 @@ class _DownloadSettingsPageState extends ConsumerState { }; String _getLyricsProvidersSubtitle(List providers) { - if (providers.isEmpty) return 'None enabled'; + if (providers.isEmpty) return context.l10n.downloadProvidersNoneEnabled; return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > '); } @@ -1541,6 +1591,104 @@ class _DownloadSettingsPageState extends ConsumerState { return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); } + String _getTidalHighFormatLabel(BuildContext context, String format) { + switch (format) { + case 'mp3_320': + return context.l10n.downloadLossyMp3; + case 'opus_256': + return context.l10n.downloadLossyOpus256; + case 'opus_128': + return context.l10n.downloadLossyOpus128; + default: + return context.l10n.downloadLossyMp3; + } + } + + void _showTidalHighFormatPicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + 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( + context.l10n.downloadLossy320Format, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.downloadLossy320FormatDesc, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: Text(context.l10n.downloadLossyMp3), + subtitle: Text(context.l10n.downloadLossyMp3Subtitle), + 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: Text(context.l10n.downloadLossyOpus256), + subtitle: Text(context.l10n.downloadLossyOpus256Subtitle), + trailing: current == 'opus_256' + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setTidalHighFormat('opus_256'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.graphic_eq), + title: Text(context.l10n.downloadLossyOpus128), + subtitle: Text(context.l10n.downloadLossyOpus128Subtitle), + 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 _showYoutubeBitratePicker({ required BuildContext context, required String title, @@ -1631,14 +1779,14 @@ class _DownloadSettingsPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Musixmatch Language', + context.l10n.downloadMusixmatchLanguage, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( - 'Set preferred language code (example: en, es, ja). Leave empty for auto.', + context.l10n.downloadMusixmatchLanguageDesc, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -1647,9 +1795,9 @@ class _DownloadSettingsPageState extends ConsumerState { TextField( controller: controller, textInputAction: TextInputAction.done, - decoration: const InputDecoration( - labelText: 'Language code', - hintText: 'auto / en / es / ja', + decoration: InputDecoration( + labelText: context.l10n.downloadMusixmatchLanguageCode, + hintText: context.l10n.downloadMusixmatchLanguageHint, ), ), const SizedBox(height: 16), @@ -1668,7 +1816,7 @@ class _DownloadSettingsPageState extends ConsumerState { .setMusixmatchLanguage(''); Navigator.pop(context); }, - child: const Text('Auto'), + child: Text(context.l10n.downloadMusixmatchAuto), ), const SizedBox(width: 8), FilledButton( @@ -1691,104 +1839,6 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - String _getTidalHighFormatLabel(String format) { - switch (format) { - case 'mp3_320': - return 'MP3 320kbps'; - case 'opus_256': - return 'Opus 256kbps'; - 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, - useRootNavigator: true, - 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 256kbps'), - subtitle: const Text('Best quality Opus, ~8MB per track'), - trailing: current == 'opus_256' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('opus_256'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: const Text('Opus 128kbps'), - subtitle: const Text('Smallest size, ~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 _showNetworkModePicker( BuildContext context, WidgetRef ref, @@ -1828,7 +1878,7 @@ class _DownloadSettingsPageState extends ConsumerState { ListTile( leading: const Icon(Icons.signal_cellular_alt), title: Text(context.l10n.settingsDownloadNetworkAny), - subtitle: const Text('WiFi + Mobile Data'), + subtitle: Text(context.l10n.downloadNetworkAnySubtitle), trailing: current == 'any' ? Icon(Icons.check, color: colorScheme.primary) : null, @@ -1842,7 +1892,7 @@ class _DownloadSettingsPageState extends ConsumerState { ListTile( leading: const Icon(Icons.wifi), title: Text(context.l10n.settingsDownloadNetworkWifiOnly), - subtitle: const Text('Pause downloads on mobile data'), + subtitle: Text(context.l10n.downloadNetworkWifiOnlySubtitle), trailing: current == 'wifi_only' ? Icon(Icons.check, color: colorScheme.primary) : null, @@ -1884,7 +1934,7 @@ class _DownloadSettingsPageState extends ConsumerState { Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( - 'SongLink Region', + context.l10n.downloadSongLinkRegion, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), @@ -1893,7 +1943,7 @@ class _DownloadSettingsPageState extends ConsumerState { Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Text( - 'Used as userCountry for SongLink API lookup.', + context.l10n.downloadSongLinkRegionDesc, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -1955,7 +2005,7 @@ class _DownloadSettingsPageState extends ConsumerState { Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( - 'Folder Organization', + context.l10n.downloadFolderOrganization, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), @@ -1982,6 +2032,18 @@ class _DownloadSettingsPageState extends ConsumerState { Navigator.pop(context); }, ), + _FolderOption( + title: context.l10n.folderOrganizationByPlaylist, + subtitle: context.l10n.folderOrganizationByPlaylistSubtitle, + example: 'SpotiFLAC/Playlist Name/Track.flac', + isSelected: current == 'playlist', + onTap: () { + ref + .read(settingsProvider.notifier) + .setFolderOrganization('playlist'); + Navigator.pop(context); + }, + ), _FolderOption( title: context.l10n.folderOrganizationByArtist, subtitle: context.l10n.folderOrganizationByArtistSubtitle, diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 0a702946..c6e00b55 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -801,7 +801,7 @@ class _SettingItemState extends State<_SettingItem> { Future _invokeAction(BuildContext context) async { if (widget.setting.action == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No action defined for this button')), + SnackBar(content: Text(context.l10n.snackbarNoActionDefined)), ); return; } @@ -834,7 +834,7 @@ class _SettingItemState extends State<_SettingItem> { if (context.mounted) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); + ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); } } finally { if (mounted) { diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 196dd0aa..57979689 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/settings/extension_detail_page.dart'; import 'package:spotiflac_android/screens/settings/provider_priority_page.dart'; @@ -151,6 +152,7 @@ class _ExtensionsPageState extends ConsumerState { _DownloadPriorityItem(), _MetadataPriorityItem(), _SearchProviderSelector(), + _HomeFeedProviderSelector(), ], ), ), @@ -586,6 +588,8 @@ class _MetadataPriorityItem extends ConsumerWidget { class _SearchProviderSelector extends ConsumerWidget { const _SearchProviderSelector(); + static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; + @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); @@ -596,20 +600,29 @@ class _SearchProviderSelector extends ConsumerWidget { .where((e) => e.enabled && e.hasCustomSearch) .toList(); + // Always allow tapping: built-in providers are always available + final hasAnyProvider = + searchProviders.isNotEmpty || _builtInProviders.isNotEmpty; + String currentProviderName = context.l10n.extensionDefaultProvider; if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { - final ext = searchProviders - .where((e) => e.id == settings.searchProvider) - .firstOrNull; - currentProviderName = ext?.displayName ?? settings.searchProvider!; + // Check built-in first + if (_builtInProviders.containsKey(settings.searchProvider)) { + currentProviderName = _builtInProviders[settings.searchProvider]!; + } else { + final ext = searchProviders + .where((e) => e.id == settings.searchProvider) + .firstOrNull; + currentProviderName = ext?.displayName ?? settings.searchProvider!; + } } return Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: searchProviders.isEmpty + onTap: !hasAnyProvider ? null : () => _showSearchProviderPicker( context, @@ -623,7 +636,7 @@ class _SearchProviderSelector extends ConsumerWidget { children: [ Icon( Icons.manage_search, - color: searchProviders.isEmpty + color: !hasAnyProvider ? colorScheme.outline : colorScheme.onSurfaceVariant, ), @@ -635,14 +648,12 @@ class _SearchProviderSelector extends ConsumerWidget { Text( context.l10n.extensionsSearchProvider, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: searchProviders.isEmpty - ? colorScheme.outline - : null, + color: !hasAnyProvider ? colorScheme.outline : null, ), ), const SizedBox(height: 2), Text( - searchProviders.isEmpty + !hasAnyProvider ? context.l10n.extensionsNoCustomSearch : currentProviderName, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -654,7 +665,7 @@ class _SearchProviderSelector extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: searchProviders.isEmpty + color: !hasAnyProvider ? colorScheme.outline : colorScheme.onSurfaceVariant, ), @@ -682,61 +693,245 @@ class _SearchProviderSelector extends ConsumerWidget { borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - ctx.l10n.extensionsSearchProvider, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - ctx.l10n.extensionsSearchProviderDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + ctx.l10n.extensionsSearchProvider, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), - ), - ListTile( - leading: Icon(Icons.music_note, color: colorScheme.primary), - title: Text(ctx.l10n.extensionDefaultProvider), - subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle), - trailing: - (settings.searchProvider == null || - settings.searchProvider!.isEmpty) - ? Icon(Icons.check_circle, color: colorScheme.primary) - : Icon(Icons.circle_outlined, color: colorScheme.outline), - onTap: () { - ref.read(settingsProvider.notifier).setSearchProvider(null); - Navigator.pop(ctx); - }, - ), - ...searchProviders.map( - (ext) => ListTile( - leading: Icon(Icons.extension, color: colorScheme.secondary), - title: Text(ext.displayName), - subtitle: Text( - ext.searchBehavior?.placeholder ?? - ctx.l10n.extensionsCustomSearch, + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + ctx.l10n.extensionsSearchProviderDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - trailing: settings.searchProvider == ext.id + ), + ListTile( + leading: Icon(Icons.music_note, color: colorScheme.primary), + title: Text(ctx.l10n.extensionDefaultProvider), + subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle), + trailing: + (settings.searchProvider == null || + settings.searchProvider!.isEmpty) ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline), onTap: () { - ref.read(settingsProvider.notifier).setSearchProvider(ext.id); + ref.read(settingsProvider.notifier).setSearchProvider(null); Navigator.pop(ctx); }, ), - ), - const SizedBox(height: 16), - ], + ..._builtInProviders.entries.map( + (entry) => ListTile( + leading: Icon(Icons.search, color: colorScheme.tertiary), + title: Text(entry.value), + subtitle: Text('Search with ${entry.value}'), + trailing: settings.searchProvider == entry.key + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref + .read(settingsProvider.notifier) + .setSearchProvider(entry.key); + Navigator.pop(ctx); + }, + ), + ), + if (searchProviders.isNotEmpty) const Divider(height: 1), + ...searchProviders.map( + (ext) => ListTile( + leading: Icon(Icons.extension, color: colorScheme.secondary), + title: Text(ext.displayName), + subtitle: Text( + ext.searchBehavior?.placeholder ?? + ctx.l10n.extensionsCustomSearch, + ), + trailing: settings.searchProvider == ext.id + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref + .read(settingsProvider.notifier) + .setSearchProvider(ext.id); + Navigator.pop(ctx); + }, + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } +} + +class _HomeFeedProviderSelector extends ConsumerWidget { + const _HomeFeedProviderSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + + final homeFeedProviders = extState.extensions + .where((e) => e.enabled && e.hasHomeFeed) + .toList(); + + final hasAnyProvider = homeFeedProviders.isNotEmpty; + + String currentProviderName = 'Auto'; + if (settings.homeFeedProvider != null && + settings.homeFeedProvider!.isNotEmpty) { + final ext = homeFeedProviders + .where((e) => e.id == settings.homeFeedProvider) + .firstOrNull; + currentProviderName = ext?.displayName ?? settings.homeFeedProvider!; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: !hasAnyProvider + ? null + : () => _showHomeFeedProviderPicker( + context, + ref, + settings, + homeFeedProviders, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.explore_outlined, + color: !hasAnyProvider + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Home Feed Provider', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: !hasAnyProvider ? colorScheme.outline : null, + ), + ), + const SizedBox(height: 2), + Text( + !hasAnyProvider + ? 'No extensions with home feed' + : currentProviderName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: !hasAnyProvider + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ], + ); + } + + void _showHomeFeedProviderPicker( + BuildContext context, + WidgetRef ref, + dynamic settings, + List homeFeedProviders, + ) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (ctx) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'Home Feed Provider', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Choose which extension provides the home feed on the main screen', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ListTile( + leading: Icon(Icons.auto_awesome, color: colorScheme.primary), + title: const Text('Auto'), + subtitle: const Text('Automatically select the best available'), + trailing: + (settings.homeFeedProvider == null || + settings.homeFeedProvider!.isEmpty) + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref.read(settingsProvider.notifier).setHomeFeedProvider(null); + ref.read(exploreProvider.notifier).refresh(); + Navigator.pop(ctx); + }, + ), + ...homeFeedProviders.map( + (ext) => ListTile( + leading: Icon(Icons.extension, color: colorScheme.secondary), + title: Text(ext.displayName), + subtitle: Text('Use ${ext.displayName} home feed'), + trailing: settings.homeFeedProvider == ext.id + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref + .read(settingsProvider.notifier) + .setHomeFeedProvider(ext.id); + ref.read(exploreProvider.notifier).refresh(); + Navigator.pop(ctx); + }, + ), + ), + const SizedBox(height: 16), + ], + ), ), ), ); diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index e71c1046..86c461a3 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -73,11 +73,13 @@ class _LibrarySettingsPageState extends ConsumerState { } else if (Platform.isIOS) { // iOS doesn't need explicit storage permission for app documents setState(() => _hasStoragePermission = true); + } else { + setState(() => _hasStoragePermission = true); } } Future _requestStoragePermission() async { - if (Platform.isIOS) return true; + if (!Platform.isAndroid) return true; // SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE if (_androidSdkVersion >= 29) return true; @@ -135,8 +137,9 @@ class _LibrarySettingsPageState extends ConsumerState { if (Platform.isIOS) { // On iOS, create a security-scoped bookmark so we can access // this folder across app restarts and from the Go backend. - final bookmark = - await PlatformBridge.createIosBookmarkFromPath(result); + final bookmark = await PlatformBridge.createIosBookmarkFromPath( + result, + ); if (bookmark != null && bookmark.isNotEmpty) { ref .read(settingsProvider.notifier) @@ -182,11 +185,13 @@ class _LibrarySettingsPageState extends ConsumerState { return; } - await ref.read(localLibraryProvider.notifier).startScan( - libraryPath, - forceFullScan: forceFullScan, - iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, - ); + await ref + .read(localLibraryProvider.notifier) + .startScan( + libraryPath, + forceFullScan: forceFullScan, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } Future _cancelScan() async { @@ -241,6 +246,106 @@ class _LibrarySettingsPageState extends ConsumerState { } } + String _getAutoScanLabel(BuildContext context, String mode) { + switch (mode) { + case 'on_open': + return context.l10n.libraryAutoScanOnOpen; + case 'daily': + return context.l10n.libraryAutoScanDaily; + case 'weekly': + return context.l10n.libraryAutoScanWeekly; + default: + return context.l10n.libraryAutoScanOff; + } + } + + void _showAutoScanPicker(BuildContext context, String current) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + 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( + context.l10n.libraryAutoScan, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.libraryAutoScanSubtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + _AutoScanOption( + icon: Icons.block, + title: context.l10n.libraryAutoScanOff, + selected: current == 'off', + colorScheme: colorScheme, + onTap: () { + ref + .read(settingsProvider.notifier) + .setLocalLibraryAutoScan('off'); + Navigator.pop(context); + }, + ), + _AutoScanOption( + icon: Icons.open_in_new, + title: context.l10n.libraryAutoScanOnOpen, + selected: current == 'on_open', + colorScheme: colorScheme, + onTap: () { + ref + .read(settingsProvider.notifier) + .setLocalLibraryAutoScan('on_open'); + Navigator.pop(context); + }, + ), + _AutoScanOption( + icon: Icons.today, + title: context.l10n.libraryAutoScanDaily, + selected: current == 'daily', + colorScheme: colorScheme, + onTap: () { + ref + .read(settingsProvider.notifier) + .setLocalLibraryAutoScan('daily'); + Navigator.pop(context); + }, + ), + _AutoScanOption( + icon: Icons.date_range, + title: context.l10n.libraryAutoScanWeekly, + selected: current == 'weekly', + colorScheme: colorScheme, + onTap: () { + ref + .read(settingsProvider.notifier) + .setLocalLibraryAutoScan('weekly'); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); @@ -344,7 +449,24 @@ class _LibrarySettingsPageState extends ConsumerState { onChanged: (value) => ref .read(settingsProvider.notifier) .setLocalLibraryShowDuplicates(value), - showDivider: false, + ), + Opacity( + opacity: settings.localLibraryEnabled ? 1.0 : 0.5, + child: SettingsItem( + icon: Icons.autorenew_rounded, + title: context.l10n.libraryAutoScan, + subtitle: _getAutoScanLabel( + context, + settings.localLibraryAutoScan, + ), + onTap: settings.localLibraryEnabled + ? () => _showAutoScanPicker( + context, + settings.localLibraryAutoScan, + ) + : null, + showDivider: false, + ), ), ], ), @@ -825,3 +947,29 @@ class _ScanProgressTile extends StatelessWidget { ); } } + +class _AutoScanOption extends StatelessWidget { + final IconData icon; + final String title; + final bool selected; + final ColorScheme colorScheme; + final VoidCallback onTap; + + const _AutoScanOption({ + required this.icon, + required this.title, + required this.selected, + required this.colorScheme, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(title), + trailing: selected ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: onTap, + ); + } +} diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index 426d82c8..3e3537a2 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -55,18 +56,18 @@ class _LyricsProviderPriorityPageState return PrioritySettingsScaffold( hasChanges: _hasChanges, - title: 'Lyrics Providers', - description: - 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.', - infoText: - 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.', + title: context.l10n.lyricsProvidersTitle, + description: context.l10n.lyricsProvidersDescription, + infoText: context.l10n.lyricsProvidersInfoText, onSave: _saveChanges, onConfirmDiscard: _confirmDiscard, slivers: [ if (_enabledProviders.isNotEmpty) SliverToBoxAdapter( child: SettingsSectionHeader( - title: 'Enabled (${_enabledProviders.length})', + title: context.l10n.lyricsProvidersEnabledSection( + _enabledProviders.length, + ), ), ), if (_enabledProviders.isNotEmpty) @@ -76,7 +77,7 @@ class _LyricsProviderPriorityPageState itemCount: _enabledProviders.length, itemBuilder: (context, index) { final id = _enabledProviders[index]; - final info = _getLyricsProviderInfo(id); + final info = _getLyricsProviderInfo(id, context); return _EnabledProviderItem( key: ValueKey(id), providerId: id, @@ -99,7 +100,9 @@ class _LyricsProviderPriorityPageState if (disabled.isNotEmpty) SliverToBoxAdapter( child: SettingsSectionHeader( - title: 'Disabled (${disabled.length})', + title: context.l10n.lyricsProvidersDisabledSection( + disabled.length, + ), ), ), if (disabled.isNotEmpty) @@ -108,7 +111,7 @@ class _LyricsProviderPriorityPageState sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final id = disabled[index]; - final info = _getLyricsProviderInfo(id); + final info = _getLyricsProviderInfo(id, context); return _DisabledProviderItem( key: ValueKey(id), providerId: id, @@ -130,8 +133,8 @@ class _LyricsProviderPriorityPageState void _disableProvider(String id) { if (_enabledProviders.length <= 1) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('At least one provider must remain enabled'), + SnackBar( + content: Text(context.l10n.lyricsProvidersAtLeastOne), ), ); return; @@ -150,7 +153,7 @@ class _LyricsProviderPriorityPageState }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Lyrics provider priority saved')), + SnackBar(content: Text(context.l10n.lyricsProvidersSaved)), ); } } @@ -159,16 +162,16 @@ class _LyricsProviderPriorityPageState final result = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Discard changes?'), - content: const Text('You have unsaved changes that will be lost.'), + title: Text(context.l10n.dialogDiscardChanges), + content: Text(context.l10n.lyricsProvidersDiscardContent), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + child: Text(context.l10n.dialogCancel), ), FilledButton( onPressed: () => Navigator.pop(context, true), - child: const Text('Discard'), + child: Text(context.l10n.dialogDiscard), ), ], ), @@ -176,48 +179,51 @@ class _LyricsProviderPriorityPageState return result ?? false; } - static _LyricsProviderInfo _getLyricsProviderInfo(String id) { + static _LyricsProviderInfo _getLyricsProviderInfo( + String id, + BuildContext context, + ) { switch (id) { case 'spotify_api': return _LyricsProviderInfo( name: 'Spotify Lyrics API', - description: 'Spotify-sourced synced lyrics via community API', + description: context.l10n.lyricsProviderSpotifyApiDesc, icon: Icons.music_note_outlined, ); case 'lrclib': return _LyricsProviderInfo( name: 'LRCLIB', - description: 'Open-source synced lyrics database', + description: context.l10n.lyricsProviderLrclibDesc, icon: Icons.subtitles_outlined, ); case 'netease': return _LyricsProviderInfo( name: 'Netease', - description: 'NetEase Cloud Music (good for Asian songs)', + description: context.l10n.lyricsProviderNeteaseDesc, icon: Icons.cloud_outlined, ); case 'musixmatch': return _LyricsProviderInfo( name: 'Musixmatch', - description: 'Largest lyrics database (multi-language)', + description: context.l10n.lyricsProviderMusixmatchDesc, icon: Icons.translate, ); case 'apple_music': return _LyricsProviderInfo( name: 'Apple Music', - description: 'Word-by-word synced lyrics (via proxy)', + description: context.l10n.lyricsProviderAppleMusicDesc, icon: Icons.music_note, ); case 'qqmusic': return _LyricsProviderInfo( name: 'QQ Music', - description: 'QQ Music (good for Chinese songs, via proxy)', + description: context.l10n.lyricsProviderQqMusicDesc, icon: Icons.queue_music, ); default: return _LyricsProviderInfo( name: id, - description: 'Extension provider', + description: context.l10n.lyricsProviderExtensionDesc, icon: Icons.extension, ); } diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart index 7631e27d..ba66489c 100644 --- a/lib/screens/settings/metadata_provider_priority_page.dart +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -228,11 +228,18 @@ class _MetadataProviderItem extends StatelessWidget { description: context.l10n.metadataNoRateLimits, isBuiltIn: true, ); - case 'spotify': + case 'qobuz': return _MetadataProviderInfo( - name: 'Spotify', + name: 'Qobuz', + icon: Icons.library_music, + description: context.l10n.providerBuiltIn, + isBuiltIn: true, + ); + case 'tidal': + return _MetadataProviderInfo( + name: 'Tidal', icon: Icons.music_note, - description: context.l10n.metadataMayRateLimit, + description: context.l10n.providerBuiltIn, isBuiltIn: true, ); default: diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index d57558fa..3f5a2e7c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -73,68 +72,10 @@ class OptionsSettingsPage extends ConsumerWidget { child: SettingsGroup( children: [ _MetadataSourceSelector( - currentSource: settings.metadataSource, onChanged: (v) => ref .read(settingsProvider.notifier) .setMetadataSource(v), ), - if (settings.metadataSource == 'spotify') ...[ - if (settings.spotifyClientId.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Card( - color: Theme.of(context).colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - Icons.warning_amber_rounded, - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.optionsSpotifyWarning, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - fontSize: 12, - ), - ), - ), - ], - ), - ), - ), - ), - SettingsItem( - icon: Icons.key, - title: context.l10n.optionsSpotifyCredentials, - subtitle: settings.spotifyClientId.isNotEmpty - ? context.l10n.optionsSpotifyCredentialsConfigured( - settings.spotifyClientId.length > 8 - ? settings.spotifyClientId.substring(0, 8) - : settings.spotifyClientId, - ) - : context.l10n.optionsSpotifyCredentialsRequired, - onTap: () => - _showSpotifyCredentialsDialog(context, ref, settings), - trailing: Icon( - settings.spotifyClientId.isNotEmpty - ? Icons.check_circle - : Icons.error_outline, - color: settings.spotifyClientId.isNotEmpty - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.error, - size: 20, - ), - showDivider: false, - ), - ], ], ), ), @@ -372,214 +313,6 @@ class OptionsSettingsPage extends ConsumerWidget { } } } - - void _showSpotifyCredentialsDialog( - BuildContext context, - WidgetRef ref, - AppSettings settings, - ) { - final clientIdController = TextEditingController( - text: settings.spotifyClientId, - ); - final clientSecretController = TextEditingController( - text: settings.spotifyClientSecret, - ); - final colorScheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - backgroundColor: colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (context) => Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 24), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Text( - context.l10n.credentialsTitle, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - context.l10n.credentialsDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - - TextField( - controller: clientIdController, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientId, - hintText: context.l10n.credentialsClientIdHint, - filled: true, - fillColor: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.primary, - width: 2, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - prefixIcon: const Icon(Icons.person_outline), - ), - ), - const SizedBox(height: 16), - - TextField( - controller: clientSecretController, - obscureText: true, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientSecret, - hintText: context.l10n.credentialsClientSecretHint, - filled: true, - fillColor: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.primary, - width: 2, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - prefixIcon: const Icon(Icons.lock_outline), - ), - ), - - const SizedBox(height: 32), - - FilledButton( - onPressed: () { - final clientId = clientIdController.text.trim(); - final clientSecret = clientSecretController.text.trim(); - - if (clientId.isNotEmpty && clientSecret.isNotEmpty) { - ref - .read(settingsProvider.notifier) - .setSpotifyCredentials(clientId, clientSecret); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarCredentialsSaved, - ), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarFillAllFields), - ), - ); - } - }, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text( - context.l10n.actionSaveCredentials, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - - if (settings.spotifyClientId.isNotEmpty) ...[ - const SizedBox(height: 12), - TextButton( - onPressed: () { - ref - .read(settingsProvider.notifier) - .clearSpotifyCredentials(); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarCredentialsCleared, - ), - ), - ); - }, - style: TextButton.styleFrom( - foregroundColor: colorScheme.error, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: Text(context.l10n.actionRemoveCredentials), - ), - ], - - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ), - ); - } } class _ConcurrentDownloadsItem extends StatelessWidget { @@ -875,12 +608,10 @@ class _ChannelChip extends StatelessWidget { } class _MetadataSourceSelector extends ConsumerWidget { - final String currentSource; final ValueChanged onChanged; - const _MetadataSourceSelector({ - required this.currentSource, - required this.onChanged, - }); + const _MetadataSourceSelector({required this.onChanged}); + + static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; @override Widget build(BuildContext context, WidgetRef ref) { @@ -888,18 +619,26 @@ class _MetadataSourceSelector extends ConsumerWidget { final settings = ref.watch(settingsProvider); final extState = ref.watch(extensionProvider); + final searchProvider = settings.searchProvider ?? ''; + final isBuiltIn = _builtInProviders.containsKey(searchProvider); + Extension? activeExtension; - if (settings.searchProvider != null && - settings.searchProvider!.isNotEmpty) { + if (searchProvider.isNotEmpty && !isBuiltIn) { activeExtension = extState.extensions - .where((e) => e.id == settings.searchProvider && e.enabled) + .where((e) => e.id == searchProvider && e.enabled) .firstOrNull; } - final hasExtensionSearch = activeExtension != null; + final hasNonDefaultProvider = isBuiltIn || activeExtension != null; - String? extensionName; - if (hasExtensionSearch) { - extensionName = activeExtension.displayName; + String subtitle; + if (isBuiltIn) { + subtitle = 'Using ${_builtInProviders[searchProvider]}'; + } else if (activeExtension != null) { + subtitle = context.l10n.optionsUsingExtension( + activeExtension.displayName, + ); + } else { + subtitle = context.l10n.optionsPrimaryProviderSubtitle; } return Padding( @@ -915,11 +654,9 @@ class _MetadataSourceSelector extends ConsumerWidget { ), const SizedBox(height: 4), Text( - hasExtensionSearch - ? context.l10n.optionsUsingExtension(extensionName!) - : context.l10n.optionsPrimaryProviderSubtitle, + subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: hasExtensionSearch + color: hasNonDefaultProvider ? colorScheme.primary : colorScheme.onSurfaceVariant, ), @@ -930,29 +667,41 @@ class _MetadataSourceSelector extends ConsumerWidget { _SourceChip( icon: Icons.graphic_eq, label: 'Deezer', - isSelected: currentSource == 'deezer' && !hasExtensionSearch, + isSelected: searchProvider.isEmpty, onTap: () { - if (hasExtensionSearch) { + if (hasNonDefaultProvider) { ref.read(settingsProvider.notifier).setSearchProvider(null); } onChanged('deezer'); }, ), - const SizedBox(width: 12), + const SizedBox(width: 8), _SourceChip( - icon: Icons.music_note, - label: 'Spotify', - isSelected: currentSource == 'spotify' && !hasExtensionSearch, + icon: Icons.waves, + label: 'Tidal', + isSelected: searchProvider == 'tidal', onTap: () { - if (hasExtensionSearch) { - ref.read(settingsProvider.notifier).setSearchProvider(null); - } - onChanged('spotify'); + ref + .read(settingsProvider.notifier) + .setSearchProvider('tidal'); + onChanged('tidal'); + }, + ), + const SizedBox(width: 8), + _SourceChip( + icon: Icons.album, + label: 'Qobuz', + isSelected: searchProvider == 'qobuz', + onTap: () { + ref + .read(settingsProvider.notifier) + .setSearchProvider('qobuz'); + onChanged('qobuz'); }, ), ], ), - if (hasExtensionSearch) ...[ + if (activeExtension != null) ...[ const SizedBox(height: 12), Row( children: [ @@ -964,7 +713,7 @@ class _MetadataSourceSelector extends ConsumerWidget { const SizedBox(width: 8), Expanded( child: Text( - context.l10n.optionsSwitchBack, + 'Tap Deezer to switch back from extension', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -973,27 +722,6 @@ class _MetadataSourceSelector extends ConsumerWidget { ], ), ], - if (currentSource == 'spotify' && !hasExtensionSearch) ...[ - const SizedBox(height: 12), - Row( - children: [ - Icon( - Icons.warning_amber_rounded, - size: 16, - color: colorScheme.error, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.optionsSpotifyDeprecationWarning, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colorScheme.error), - ), - ), - ], - ), - ], ], ), ); diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index e28ad7e3..02c15b1a 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -334,6 +334,12 @@ class _ProviderItem extends StatelessWidget { ); case 'qobuz': return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true); + case 'deezer': + return _ProviderInfo( + name: 'Deezer', + icon: Icons.graphic_eq, + isBuiltIn: true, + ); case 'youtube': return _ProviderInfo( name: 'YouTube', diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index df5bc8d0..7e66fc62 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -107,8 +107,8 @@ class SettingsTab extends ConsumerWidget { ), SettingsItem( icon: Icons.favorite_outline, - title: 'Donate', - subtitle: 'Support SpotiFLAC-Mobile development', + title: l10n.settingsDonate, + subtitle: l10n.settingsDonateSubtitle, onTap: () => _navigateTo(context, const DonatePage()), showDivider: false, ), @@ -133,7 +133,7 @@ class SettingsTab extends ConsumerWidget { SettingsItem( icon: Icons.info_outline, title: l10n.settingsAbout, - subtitle: '${l10n.aboutVersion} ${AppInfo.version}', + subtitle: '${l10n.aboutVersion} ${AppInfo.displayVersion}', onTap: () => _navigateTo(context, const AboutPage()), showDivider: false, ), diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 7c74d92e..5f137482 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -91,6 +91,11 @@ class _SetupScreenState extends ConsumerState { _notificationPermissionGranted = notificationStatus.isGranted; }); } + } else { + setState(() { + _storagePermissionGranted = true; + _notificationPermissionGranted = true; + }); } } @@ -139,6 +144,8 @@ class _SetupScreenState extends ConsumerState { SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)), ); } + } else { + setState(() => _storagePermissionGranted = true); } } catch (e) { debugPrint('Permission error: $e'); @@ -225,7 +232,7 @@ class _SetupScreenState extends ConsumerState { try { if (Platform.isIOS) { await _showIOSDirectoryOptions(); - } else { + } else if (Platform.isAndroid) { final result = await PlatformBridge.pickSafTree(); if (result != null) { final treeUri = result['tree_uri'] as String? ?? ''; @@ -321,7 +328,26 @@ class _SetupScreenState extends ConsumerState { title: Text(context.l10n.setupChooseFromFiles), onTap: () async { Navigator.pop(ctx); - final result = await FilePicker.platform.getDirectoryPath(); + if (Platform.isIOS) { + await Future.delayed(const Duration(milliseconds: 250)); + } + + String? result; + try { + result = await FilePicker.platform.getDirectoryPath(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to open folder picker: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); + } + return; + } + if (result != null) { // iOS: Validate the selected path is writable if (Platform.isIOS) { diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index ff97c194..63256832 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -16,6 +16,7 @@ class StoreTab extends ConsumerStatefulWidget { class _StoreTabState extends ConsumerState { final _searchController = TextEditingController(); + final _repoUrlController = TextEditingController(); bool _isInitialized = false; @override @@ -38,6 +39,7 @@ class _StoreTabState extends ConsumerState { @override void dispose() { _searchController.dispose(); + _repoUrlController.dispose(); super.dispose(); } @@ -56,6 +58,8 @@ class _StoreTabState extends ConsumerState { final downloadingId = ref.watch( storeProvider.select((s) => s.downloadingId), ); + final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl)); + final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl)); final filteredExtensions = StoreState( extensions: extensions, selectedCategory: selectedCategory, @@ -84,6 +88,14 @@ class _StoreTabState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, automaticallyImplyLeading: false, + actions: [ + if (hasRegistryUrl) + IconButton( + icon: const Icon(Icons.link), + tooltip: context.l10n.storeChangeRepoTooltip, + onPressed: () => _showChangeRepoDialog(registryUrl), + ), + ], flexibleSpace: LayoutBuilder( builder: (context, constraints) { final maxHeight = 120 + topPadding; @@ -109,151 +121,154 @@ class _StoreTabState extends ConsumerState { ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: ValueListenableBuilder( - valueListenable: _searchController, - builder: (context, value, _) { - return TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.storeSearch, - prefixIcon: const Icon(Icons.search), - suffixIcon: value.text.isNotEmpty - ? IconButton( - tooltip: 'Clear search', - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - ref - .read(storeProvider.notifier) - .setSearchQuery(''); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: - Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onChanged: (value) { - ref.read(storeProvider.notifier).setSearchQuery(value); - }, - ); - }, - ), - ), - ), - - SliverToBoxAdapter( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - children: [ - _CategoryChip( - label: context.l10n.storeFilterAll, - icon: Icons.apps, - isSelected: selectedCategory == null, - onTap: () => - ref.read(storeProvider.notifier).setCategory(null), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterMetadata, - icon: Icons.label_outline, - isSelected: selectedCategory == StoreCategory.metadata, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.metadata), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterDownload, - icon: Icons.download_outlined, - isSelected: selectedCategory == StoreCategory.download, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.download), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterUtility, - icon: Icons.build_outlined, - isSelected: selectedCategory == StoreCategory.utility, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.utility), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterLyrics, - icon: Icons.lyrics_outlined, - isSelected: selectedCategory == StoreCategory.lyrics, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.lyrics), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterIntegration, - icon: Icons.link, - isSelected: selectedCategory == StoreCategory.integration, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.integration), - ), - ], - ), - ), - ), - - if (isLoading && extensions.isEmpty) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (error != null && extensions.isEmpty) - SliverFillRemaining(child: _buildErrorState(error, colorScheme)) - else if (filteredExtensions.isEmpty) + if (!hasRegistryUrl) SliverFillRemaining( - child: _buildEmptyState( - hasFilters: - searchQuery.isNotEmpty || selectedCategory != null, - colorScheme: colorScheme, - ), + child: _buildSetupRepoState(colorScheme, error), ) else ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - '${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + child: ValueListenableBuilder( + valueListenable: _searchController, + builder: (context, value, _) { + return TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: context.l10n.storeSearch, + prefixIcon: const Icon(Icons.search), + suffixIcon: value.text.isNotEmpty + ? IconButton( + tooltip: 'Clear search', + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + ref + .read(storeProvider.notifier) + .setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: + Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + ref.read(storeProvider.notifier).setSearchQuery(value); + }, + ); + }, ), ), ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + _CategoryChip( + label: context.l10n.storeFilterAll, + icon: Icons.apps, + isSelected: selectedCategory == null, + onTap: () => + ref.read(storeProvider.notifier).setCategory(null), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterMetadata, + icon: Icons.label_outline, + isSelected: selectedCategory == StoreCategory.metadata, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.metadata), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterDownload, + icon: Icons.download_outlined, + isSelected: selectedCategory == StoreCategory.download, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.download), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterUtility, + icon: Icons.build_outlined, + isSelected: selectedCategory == StoreCategory.utility, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.utility), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterLyrics, + icon: Icons.lyrics_outlined, + isSelected: selectedCategory == StoreCategory.lyrics, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.lyrics), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterIntegration, + icon: Icons.link, + isSelected: selectedCategory == StoreCategory.integration, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.integration), + ), + ], + ), + ), + ), + + if (isLoading && extensions.isEmpty) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (error != null && extensions.isEmpty) + SliverFillRemaining(child: _buildErrorState(error, colorScheme)) + else if (filteredExtensions.isEmpty) + SliverFillRemaining( + child: _buildEmptyState( + hasFilters: + searchQuery.isNotEmpty || selectedCategory != null, + colorScheme: colorScheme, + ), + ) + else ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + '${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + SliverToBoxAdapter( child: SettingsGroup( children: filteredExtensions.asMap().entries.map((entry) { final index = entry.key; @@ -269,9 +284,9 @@ class _StoreTabState extends ConsumerState { }).toList(), ), ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], ], ], ), @@ -279,6 +294,166 @@ class _StoreTabState extends ConsumerState { ); } + Widget _buildSetupRepoState(ColorScheme colorScheme, String? error) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.store_outlined, + size: 72, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 24), + Text( + context.l10n.storeAddRepoTitle, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextField( + controller: _repoUrlController, + decoration: InputDecoration( + hintText: context.l10n.storeRepoUrlHint, + labelText: context.l10n.storeRepoUrlLabel, + prefixIcon: const Icon(Icons.link), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + ), + keyboardType: TextInputType.url, + autocorrect: false, + onSubmitted: (_) => _submitRepoUrl(), + ), + if (error != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + error, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _submitRepoUrl, + icon: const Icon(Icons.add), + label: Text(context.l10n.storeAddRepoButton), + ), + ), + ], + ), + ), + ); + } + + void _submitRepoUrl() { + final url = _repoUrlController.text.trim(); + if (url.isEmpty) return; + ref.read(storeProvider.notifier).setRegistryUrl(url); + } + + void _showChangeRepoDialog(String currentUrl) { + final changeUrlController = TextEditingController(text: currentUrl); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.storeRepoDialogTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.storeRepoDialogCurrent, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + currentUrl, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 11, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 16), + TextField( + controller: changeUrlController, + decoration: InputDecoration( + hintText: context.l10n.storeRepoUrlHint, + labelText: context.l10n.storeNewRepoUrlLabel, + prefixIcon: const Icon(Icons.link), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + keyboardType: TextInputType.url, + autocorrect: false, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + ref.read(storeProvider.notifier).removeRegistryUrl(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(context.l10n.dialogRemove), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + final newUrl = changeUrlController.text.trim(); + Navigator.of(context).pop(); + if (newUrl.isNotEmpty) { + ref.read(storeProvider.notifier).setRegistryUrl(newUrl); + } + }, + child: Text(context.l10n.dialogSave), + ), + ], + ), + ).then((_) => changeUrlController.dispose()); + } + Widget _buildErrorState(String error, ColorScheme colorScheme) { return Center( child: Padding( @@ -289,7 +464,7 @@ class _StoreTabState extends ConsumerState { Icon(Icons.error_outline, size: 64, color: colorScheme.error), const SizedBox(height: 16), Text( - 'Failed to load store', + context.l10n.storeLoadError, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), @@ -328,7 +503,7 @@ class _StoreTabState extends ConsumerState { ), const SizedBox(height: 16), Text( - hasFilters ? 'No extensions found' : 'No extensions available', + hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index fdcd3eef..09f0175c 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -13,11 +13,15 @@ import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; +import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; final _log = AppLogger('TrackMetadata'); @@ -61,9 +65,9 @@ class _TrackMetadataScreenState extends ConsumerState { String? _lyricsError; String? _lyricsSource; bool _showTitleInAppBar = false; - bool _lyricsEmbedded = false; // Track if lyrics are embedded in file + bool _lyricsEmbedded = false; bool _isEmbedding = false; // Track embed operation in progress - bool _isInstrumental = false; // Track if detected as instrumental + bool _isInstrumental = false; bool _isConverting = false; // Track convert operation in progress bool _hasMetadataChanges = false; bool _hasLoadedResolvedAudioMetadata = false; @@ -214,10 +218,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _checkFile() async { - var filePath = _filePath; - if (filePath.startsWith('EXISTS:')) { - filePath = filePath.substring(7); - } + final filePath = cleanFilePath; bool exists = false; int? size; @@ -298,11 +299,25 @@ class _TrackMetadataScreenState extends ConsumerState { final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']); final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']); + final resolvedDuration = _readPositiveInt(metadata['duration']); + final resolvedAlbum = metadata['album']?.toString(); final resolvedQuality = buildDisplayAudioQuality( bitDepth: resolvedBitDepth ?? bitDepth, sampleRate: resolvedSampleRate ?? sampleRate, storedQuality: _quality, ); + + // Fill in album name from file tags if stored value is empty + final needsAlbum = + resolvedAlbum != null && + resolvedAlbum.isNotEmpty && + (albumName.isEmpty); + // Fill in duration from file if stored value is missing/zero + final needsDuration = + resolvedDuration != null && + resolvedDuration > 0 && + (duration == null || duration == 0); + final shouldPersistResolvedAudioMetadata = resolvedBitDepth != null || resolvedSampleRate != null || @@ -310,6 +325,8 @@ class _TrackMetadataScreenState extends ConsumerState { if ((resolvedBitDepth != null || resolvedSampleRate != null || + needsAlbum || + needsDuration || isPlaceholderQualityLabel(_quality)) && mounted) { setState(() { @@ -317,6 +334,8 @@ class _TrackMetadataScreenState extends ConsumerState { ...?_editedMetadata, if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth, if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate, + if (needsAlbum) 'album': resolvedAlbum, + if (needsDuration) 'duration': resolvedDuration, }; }); } @@ -486,7 +505,8 @@ class _TrackMetadataScreenState extends ConsumerState { _editedMetadata?['copyright']?.toString() ?? (_isLocalItem ? null : _downloadItem!.copyright); int? get duration => - _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration; + _readPositiveInt(_editedMetadata?['duration']) ?? + (_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration); int? get bitDepth => _readPositiveInt(_editedMetadata?['bit_depth']) ?? (_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth); @@ -499,7 +519,8 @@ class _TrackMetadataScreenState extends ConsumerState { String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; - String? get _coverUrl => _isLocalItem ? null : _downloadItem!.coverUrl; + String? get _coverUrl => + _isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl); String? get _localCoverPath => _isLocalItem ? _localLibraryItem!.coverPath : null; String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; @@ -544,11 +565,62 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - String get cleanFilePath { + /// The raw file path, with EXISTS: prefix stripped but #trackNN preserved. + /// Use this when you need the full virtual path (e.g. for display or DB lookups). + String get rawFilePath { final path = _filePath; return path.startsWith('EXISTS:') ? path.substring(7) : path; } + /// The clean file path with both EXISTS: prefix and #trackNN suffix stripped. + /// Use this for actual filesystem/SAF operations. + String get cleanFilePath { + var path = _filePath; + if (path.startsWith('EXISTS:')) path = path.substring(7); + // Strip CUE virtual path suffix for filesystem operations + if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path); + return path; + } + + bool get _isCueVirtualTrack => isCueVirtualPath(rawFilePath); + + String _cueVirtualTrackGuidance(BuildContext context) { + return 'This CUE track is virtual. Use ${context.l10n.cueSplitButton} first.'; + } + + void _showCueVirtualTrackSnackBar(BuildContext context) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_cueVirtualTrackGuidance(context)))); + } + + void _hideCurrentSnackBar() { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + } + + String get _l10nCueSplitFailed => context.l10n.cueSplitFailed; + String get _l10nCueSplitNoAudioFile => context.l10n.cueSplitNoAudioFile; + + String _l10nCueSplitSplitting(int current, int total) { + return context.l10n.cueSplitSplitting(current, total); + } + + String _l10nCueSplitSuccess(int count) { + return context.l10n.cueSplitSuccess(count); + } + + void _showSnackBarMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + void _showLongSnackBarMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 60)), + ); + } + String _formatPathForDisplay(String pathOrUri) { if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) { return pathOrUri; @@ -981,14 +1053,23 @@ class _TrackMetadataScreenState extends ConsumerState { Builder( builder: (context) { final isDeezer = _spotifyId!.contains('deezer'); + final svc = _service.toLowerCase(); + String buttonLabel; + if (isDeezer) { + buttonLabel = context.l10n.trackOpenInDeezer; + } else if (svc == 'amazon') { + buttonLabel = 'Open in Amazon Music'; + } else if (svc == 'tidal') { + buttonLabel = 'Open in Tidal'; + } else if (svc == 'qobuz') { + buttonLabel = 'Open in Qobuz'; + } else { + buttonLabel = context.l10n.trackOpenInSpotify; + } return OutlinedButton.icon( onPressed: () => _openServiceUrl(context), icon: const Icon(Icons.open_in_new, size: 18), - label: Text( - isDeezer - ? context.l10n.trackOpenInDeezer - : context.l10n.trackOpenInSpotify, - ), + label: Text(buttonLabel), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -1013,14 +1094,33 @@ class _TrackMetadataScreenState extends ConsumerState { final isDeezer = _spotifyId!.contains('deezer'); final rawId = _spotifyId!.replaceAll('deezer:', ''); + final svc = _service.toLowerCase(); - final webUrl = isDeezer - ? 'https://www.deezer.com/track/$rawId' - : 'https://open.spotify.com/track/$rawId'; + String webUrl; + Uri? appUri; + String serviceName; - final appUri = isDeezer - ? Uri.parse('deezer://www.deezer.com/track/$rawId') - : Uri.parse('spotify:track:$rawId'); + if (isDeezer) { + webUrl = 'https://www.deezer.com/track/$rawId'; + appUri = Uri.parse('deezer://www.deezer.com/track/$rawId'); + serviceName = 'Deezer'; + } else if (svc == 'amazon') { + webUrl = 'https://music.amazon.com/search/$rawId'; + appUri = Uri.parse('amznm://search/$rawId'); + serviceName = 'Amazon Music'; + } else if (svc == 'tidal') { + webUrl = 'https://listen.tidal.com/track/$rawId'; + appUri = Uri.parse('tidal://track/$rawId'); + serviceName = 'Tidal'; + } else if (svc == 'qobuz') { + webUrl = 'https://play.qobuz.com/track/$rawId'; + appUri = Uri.parse('qobuz://track/$rawId'); + serviceName = 'Qobuz'; + } else { + webUrl = 'https://open.spotify.com/track/$rawId'; + appUri = Uri.parse('spotify:track:$rawId'); + serviceName = 'Spotify'; + } try { final launched = await launchUrl( @@ -1045,9 +1145,7 @@ class _TrackMetadataScreenState extends ConsumerState { _copyToClipboard(context, webUrl); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'), - ), + content: Text(context.l10n.snackbarUrlCopied(serviceName)), ), ); } @@ -1086,7 +1184,22 @@ class _TrackMetadataScreenState extends ConsumerState { if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) { final isDeezer = _spotifyId!.contains('deezer'); final cleanId = _spotifyId!.replaceAll('deezer:', ''); - items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId)); + String idLabel; + if (isDeezer) { + idLabel = 'Deezer ID'; + } else { + switch (_service.toLowerCase()) { + case 'amazon': + idLabel = 'Amazon ASIN'; + case 'tidal': + idLabel = 'Tidal ID'; + case 'qobuz': + idLabel = 'Qobuz ID'; + default: + idLabel = 'Spotify ID'; + } + } + items.add(_MetadataItem(idLabel, cleanId)); } items.add( @@ -1099,7 +1212,12 @@ class _TrackMetadataScreenState extends ConsumerState { return Column( children: items.map((metadata) { final isCopyable = - metadata.label == 'ISRC' || metadata.label == 'Spotify ID'; + metadata.label == 'ISRC' || + metadata.label == 'Spotify ID' || + metadata.label == 'Deezer ID' || + metadata.label == 'Amazon ASIN' || + metadata.label == 'Tidal ID' || + metadata.label == 'Qobuz ID'; return InkWell( onTap: isCopyable ? () => _copyToClipboard(context, metadata.value) @@ -1153,8 +1271,8 @@ class _TrackMetadataScreenState extends ConsumerState { bool fileExists, int? fileSize, ) { - final displayFilePath = _formatPathForDisplay(cleanFilePath); - final fileName = _extractFileNameFromPathOrUri(cleanFilePath); + final displayFilePath = _formatPathForDisplay(rawFilePath); + final fileName = _extractFileNameFromPathOrUri(rawFilePath); final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; @@ -1542,7 +1660,6 @@ class _TrackMetadataScreenState extends ConsumerState { }); try { - // Convert duration from seconds to milliseconds final durationMs = (duration ?? 0) * 1000; // First, check if lyrics are embedded in the file @@ -1563,7 +1680,6 @@ class _TrackMetadataScreenState extends ConsumerState { final embeddedSource = embeddedResult['source']?.toString() ?? ''; if (embeddedLyrics.isNotEmpty) { - // Lyrics found in file if (mounted) { final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); setState(() { @@ -1596,7 +1712,6 @@ class _TrackMetadataScreenState extends ConsumerState { lrcText == '[instrumental:true]'; if (mounted) { - // Check for instrumental marker if (instrumental) { setState(() { _isInstrumental = true; @@ -1641,6 +1756,11 @@ class _TrackMetadataScreenState extends ConsumerState { setState(() => _isEmbedding = true); + // Capture l10n strings before async gaps to avoid use_build_context_synchronously + final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage; + final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics; + final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat; + String? safTempPath; String? coverPath; @@ -1660,6 +1780,7 @@ class _TrackMetadataScreenState extends ConsumerState { final isFlac = lower.endsWith('.flac'); final isMp3 = lower.endsWith('.mp3'); final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); + final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac'); bool success = false; String? error; @@ -1677,15 +1798,15 @@ class _TrackMetadataScreenState extends ConsumerState { ); success = ok; if (!ok) { - error = 'Failed to write back to storage'; + error = l10nFailedToWriteStorage; } } else { success = true; } } else { - error = result['error']?.toString() ?? 'Failed to embed lyrics'; + error = result['error']?.toString() ?? l10nFailedToEmbedLyrics; } - } else if (isMp3 || isOpus) { + } else if (isMp3 || isOpus || isM4A) { final metadata = _buildFallbackMetadata(); try { final result = await PlatformBridge.readFileMetadata(workingPath); @@ -1720,6 +1841,12 @@ class _TrackMetadataScreenState extends ConsumerState { coverPath: coverPath, metadata: metadata, ); + } else if (isM4A) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: workingPath, + coverPath: coverPath, + metadata: metadata, + ); } else { ffmpegResult = await FFmpegService.embedMetadataToOpus( opusPath: workingPath, @@ -1729,7 +1856,7 @@ class _TrackMetadataScreenState extends ConsumerState { } if (ffmpegResult == null) { - error = 'Failed to embed lyrics'; + error = l10nFailedToEmbedLyrics; } else if (_isSafFile) { final ok = await PlatformBridge.writeTempToSaf( ffmpegResult, @@ -1737,13 +1864,13 @@ class _TrackMetadataScreenState extends ConsumerState { ); success = ok; if (!ok) { - error = 'Failed to write back to storage'; + error = l10nFailedToWriteStorage; } } else { success = true; } } else { - error = 'Unsupported audio format'; + error = l10nUnsupportedFormat; } if (mounted) { @@ -1758,16 +1885,18 @@ class _TrackMetadataScreenState extends ConsumerState { } else { setState(() => _isEmbedding = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error ?? 'Failed to embed lyrics')), + SnackBar( + content: Text(error ?? context.l10n.snackbarFailedToEmbedLyrics), + ), ); } } } catch (e) { if (mounted) { setState(() => _isEmbedding = false); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } finally { if (coverPath != null) { @@ -2201,6 +2330,12 @@ class _TrackMetadataScreenState extends ConsumerState { coverPath: effectiveCoverPath, metadata: metadata, ); + } else if (lower.endsWith('.m4a') || lower.endsWith('.aac')) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { ffmpegResult = await FFmpegService.embedMetadataToOpus( opusPath: ffmpegTarget, @@ -2217,7 +2352,7 @@ class _TrackMetadataScreenState extends ConsumerState { SnackBar( content: Text( context.l10n.trackSaveFailed( - 'Failed to write back to storage', + context.l10n.snackbarFailedToWriteStorage, ), ), ), @@ -2365,7 +2500,7 @@ class _TrackMetadataScreenState extends ConsumerState { flex: 2, child: FilledButton.icon( onPressed: fileExists - ? () => _openFile(context, cleanFilePath) + ? () => _openFile(context, rawFilePath) : null, icon: const Icon(Icons.play_arrow), label: Text(context.l10n.trackMetadataPlay), @@ -2401,21 +2536,21 @@ class _TrackMetadataScreenState extends ConsumerState { } void _showOptionsMenu( - BuildContext context, + BuildContext screenContext, WidgetRef ref, ColorScheme colorScheme, ) { showModalBottomSheet( - context: context, + context: screenContext, useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), isScrollControlled: true, constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.7, + maxHeight: MediaQuery.of(screenContext).size.height * 0.7, ), - builder: (context) => SafeArea( + builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -2432,79 +2567,102 @@ class _TrackMetadataScreenState extends ConsumerState { const SizedBox(height: 16), ListTile( leading: const Icon(Icons.copy), - title: Text(context.l10n.trackCopyFilePath), + title: Text(sheetContext.l10n.trackCopyFilePath), onTap: () { - Navigator.pop(context); - _copyToClipboard(context, cleanFilePath); + _closeOptionsMenuAndRun( + sheetContext, + () => _copyToClipboard(screenContext, cleanFilePath), + ); }, ), if (_fileExists) ListTile( leading: const Icon(Icons.edit_outlined), - title: Text(context.l10n.trackEditMetadata), + title: Text(sheetContext.l10n.trackEditMetadata), onTap: () { - Navigator.pop(context); - _showEditMetadataSheet(context, ref, colorScheme); + _closeOptionsMenuAndRun( + sheetContext, + () => _showEditMetadataSheet( + screenContext, + ref, + colorScheme, + ), + ); }, ), if (!_isLocalItem && (_coverUrl != null || _fileExists)) ListTile( leading: const Icon(Icons.image_outlined), - title: Text(context.l10n.trackSaveCoverArt), - subtitle: Text(context.l10n.trackSaveCoverArtSubtitle), + title: Text(sheetContext.l10n.trackSaveCoverArt), + subtitle: Text(sheetContext.l10n.trackSaveCoverArtSubtitle), onTap: () { - Navigator.pop(context); - _saveCoverArt(); + _closeOptionsMenuAndRun(sheetContext, _saveCoverArt); }, ), if (!_isLocalItem) ListTile( leading: const Icon(Icons.lyrics_outlined), - title: Text(context.l10n.trackSaveLyrics), - subtitle: Text(context.l10n.trackSaveLyricsSubtitle), + title: Text(sheetContext.l10n.trackSaveLyrics), + subtitle: Text(sheetContext.l10n.trackSaveLyricsSubtitle), onTap: () { - Navigator.pop(context); - _saveLyrics(); + _closeOptionsMenuAndRun(sheetContext, _saveLyrics); }, ), if (_fileExists) ListTile( leading: const Icon(Icons.travel_explore), - title: Text(context.l10n.trackReEnrich), - subtitle: Text(context.l10n.trackReEnrichOnlineSubtitle), + title: Text(sheetContext.l10n.trackReEnrich), + subtitle: Text(sheetContext.l10n.trackReEnrichOnlineSubtitle), onTap: () { - Navigator.pop(context); - _reEnrichMetadata(); + _closeOptionsMenuAndRun(sheetContext, _reEnrichMetadata); }, ), if (_fileExists && _isConvertibleFormat) ListTile( leading: const Icon(Icons.swap_horiz), - title: Text(context.l10n.trackConvertFormat), - subtitle: Text(context.l10n.trackConvertFormatSubtitle), + title: Text(sheetContext.l10n.trackConvertFormat), + subtitle: Text(sheetContext.l10n.trackConvertFormatSubtitle), onTap: () { - Navigator.pop(context); - _showConvertSheet(context); + _closeOptionsMenuAndRun( + sheetContext, + () => _showConvertSheet(screenContext), + ); + }, + ), + if (_fileExists && _isCueFile) + ListTile( + leading: const Icon(Icons.call_split), + title: Text(sheetContext.l10n.cueSplitTitle), + subtitle: Text(sheetContext.l10n.cueSplitSubtitle), + onTap: () { + _closeOptionsMenuAndRun( + sheetContext, + () => _showCueSplitSheet(screenContext), + ); }, ), const Divider(height: 1), ListTile( leading: const Icon(Icons.share), - title: Text(context.l10n.trackMetadataShare), + title: Text(sheetContext.l10n.trackMetadataShare), onTap: () { - Navigator.pop(context); - _shareFile(context); + _closeOptionsMenuAndRun( + sheetContext, + () => _shareFile(screenContext), + ); }, ), ListTile( leading: Icon(Icons.delete, color: colorScheme.error), title: Text( - context.l10n.trackRemoveFromDevice, + sheetContext.l10n.trackRemoveFromDevice, style: TextStyle(color: colorScheme.error), ), onTap: () { - Navigator.pop(context); - _confirmDelete(context, ref, colorScheme); + _closeOptionsMenuAndRun( + sheetContext, + () => _confirmDelete(screenContext, ref, colorScheme), + ); }, ), const SizedBox(height: 16), @@ -2519,16 +2677,41 @@ class _TrackMetadataScreenState extends ConsumerState { bool get _isConvertibleFormat { final lower = cleanFilePath.toLowerCase(); return lower.endsWith('.flac') || + lower.endsWith('.m4a') || lower.endsWith('.mp3') || lower.endsWith('.opus') || lower.endsWith('.ogg'); } + /// Whether the current file is a CUE sheet (or CUE-referenced) + bool get _isCueFile { + // Check if the raw path has a CUE virtual path suffix + if (isCueVirtualPath(rawFilePath)) return true; + final lower = cleanFilePath.toLowerCase(); + if (lower.endsWith('.cue')) return true; + // Check if local library item has cue+ format + if (_isLocalItem && _localLibraryItem != null) { + final format = _localLibraryItem!.format ?? ''; + if (format.startsWith('cue+')) return true; + } + return false; + } + String get _currentFileFormat { + // For CUE tracks, use the format from the library item (e.g. "cue+flac") + if (_isCueFile && _isLocalItem && _localLibraryItem != null) { + final format = _localLibraryItem!.format ?? ''; + if (format.startsWith('cue+')) { + final audioFmt = format.substring(4).toUpperCase(); + return 'CUE+$audioFmt'; + } + } final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.flac')) return 'FLAC'; + if (lower.endsWith('.m4a')) return 'M4A'; if (lower.endsWith('.mp3')) return 'MP3'; if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; + if (lower.endsWith('.cue')) return 'CUE'; return 'Unknown'; } @@ -2569,6 +2752,8 @@ class _TrackMetadataScreenState extends ConsumerState { put('COPYRIGHT', source['copyright']); put('COMPOSER', source['composer']); put('COMMENT', source['comment']); + put('LYRICS', source['lyrics']); + put('UNSYNCEDLYRICS', source['lyrics']); final trackNumber = source['track_number']; if (trackNumber != null && trackNumber.toString() != '0') { @@ -2583,8 +2768,12 @@ class _TrackMetadataScreenState extends ConsumerState { } String _buildConvertedQualityLabel(String targetFormat, String bitrate) { + final upper = targetFormat.toUpperCase(); + if (upper == 'ALAC' || upper == 'FLAC') { + return '$upper Lossless'; + } final normalizedBitrate = bitrate.trim().toLowerCase(); - return '${targetFormat.toUpperCase()} $normalizedBitrate'; + return '$upper $normalizedBitrate'; } String? _extractLossyBitrateLabel(String? quality) { @@ -2624,17 +2813,26 @@ class _TrackMetadataScreenState extends ConsumerState { void _showConvertSheet(BuildContext context) { final currentFormat = _currentFileFormat; - // Available target formats (exclude current) - final formats = [ - 'MP3', - 'Opus', - ].where((f) => f != currentFormat).toList(); + final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; + + // Build available target formats based on source + final formats = []; if (currentFormat == 'FLAC') { - // FLAC can convert to both + formats.addAll(['ALAC', 'MP3', 'Opus']); + } else if (currentFormat == 'M4A') { + formats.addAll(['FLAC', 'MP3', 'Opus']); + } else if (currentFormat == 'MP3') { + formats.add('Opus'); + } else if (currentFormat == 'Opus') { + formats.add('MP3'); + } else { + formats.addAll(['MP3', 'Opus']); } String selectedFormat = formats.first; String selectedBitrate = selectedFormat == 'Opus' ? '128k' : '320k'; + bool isLosslessTarget = + selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; showModalBottomSheet( context: context, @@ -2683,53 +2881,77 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - // Reset bitrate to default for format - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; + } + }); } }, ); }).toList(), ), + + // Only show bitrate for lossy targets + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + + // Show lossless indicator + if (isLosslessTarget && isLosslessSource) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.primary), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( @@ -2751,7 +2973,9 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), child: Text( - '$currentFormat -> $selectedFormat @ $selectedBitrate', + isLosslessTarget + ? '$currentFormat -> $selectedFormat (Lossless)' + : '$currentFormat -> $selectedFormat @ $selectedBitrate', ), ), ), @@ -2766,23 +2990,495 @@ class _TrackMetadataScreenState extends ConsumerState { ); } + void _showCueSplitSheet(BuildContext context) async { + // Strip the #trackNN suffix from virtual CUE paths to get the real .cue path + var cuePath = cleanFilePath; + final trackSuffix = RegExp(r'#track\d+$'); + if (trackSuffix.hasMatch(cuePath)) { + cuePath = cuePath.replaceFirst(trackSuffix, ''); + } + + // Show loading indicator + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)), + ); + + try { + final cueInfo = await PlatformBridge.parseCueSheet(cuePath); + + if (!mounted) return; + _hideCurrentSnackBar(); + + if (cueInfo.containsKey('error')) { + _showSnackBarMessage(_l10nCueSplitNoAudioFile); + return; + } + + final album = cueInfo['album'] as String? ?? 'Unknown Album'; + final artist = cueInfo['artist'] as String? ?? 'Unknown Artist'; + final audioPath = cueInfo['audio_path'] as String? ?? ''; + final genre = cueInfo['genre'] as String? ?? ''; + final date = cueInfo['date'] as String? ?? ''; + final tracksRaw = cueInfo['tracks'] as List? ?? []; + + if (audioPath.isEmpty) { + _showSnackBarMessage(_l10nCueSplitNoAudioFile); + return; + } + + final tracks = tracksRaw + .map((t) => CueSplitTrackInfo.fromJson(t as Map)) + .toList(); + + if (tracks.isEmpty) { + _showSnackBarMessage(_l10nCueSplitFailed); + return; + } + + if (!mounted) return; + + showModalBottomSheet( + context: this.context, + useRootNavigator: true, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + final colorScheme = Theme.of(sheetContext).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + sheetContext.l10n.cueSplitTitle, + style: Theme.of(sheetContext).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Text( + sheetContext.l10n.cueSplitAlbum(album), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 4), + Text( + sheetContext.l10n.cueSplitArtist(artist), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 4), + Text( + sheetContext.l10n.cueSplitTrackCount(tracks.length), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + // Track list preview (scrollable, max 200px) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + shrinkWrap: true, + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + final duration = track.endSec > 0 + ? track.endSec - track.startSec + : 0.0; + final durationStr = duration > 0 + ? '${(duration ~/ 60).toString().padLeft(2, '0')}:${(duration.toInt() % 60).toString().padLeft(2, '0')}' + : ''; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + radius: 14, + backgroundColor: colorScheme.primaryContainer, + child: Text( + '${track.number}', + style: TextStyle( + fontSize: 11, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + title: Text( + track.title, + style: const TextStyle(fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: track.artist.isNotEmpty + ? Text( + track.artist, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: durationStr.isNotEmpty + ? Text( + durationStr, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ) + : null, + ); + }, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + Navigator.pop(sheetContext); + _confirmAndSplitCue( + context: this.context, + audioPath: audioPath, + album: album, + artist: artist, + genre: genre, + date: date, + tracks: tracks, + ); + }, + icon: const Icon(Icons.call_split), + label: Text(sheetContext.l10n.cueSplitButton), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ); + } catch (e) { + if (!mounted) return; + _hideCurrentSnackBar(); + _showSnackBarMessage(_l10nCueSplitFailed); + _log.e('Failed to parse CUE sheet: $e'); + } + } + + void _confirmAndSplitCue({ + required BuildContext context, + required String audioPath, + required String album, + required String artist, + required String genre, + required String date, + required List tracks, + }) { + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.cueSplitConfirmTitle), + content: Text( + dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _performCueSplit( + audioPath: audioPath, + album: album, + artist: artist, + genre: genre, + date: date, + tracks: tracks, + ); + }, + child: Text(dialogContext.l10n.cueSplitButton), + ), + ], + ); + }, + ); + } + + Future _resolvePersistentCueSplitOutputDir() async { + final settings = ref.read(settingsProvider); + final queueState = ref.read(downloadQueueProvider); + final configuredOutputDir = queueState.outputDir.trim(); + if (settings.storageMode != 'saf' && + configuredOutputDir.isNotEmpty && + !isContentUri(configuredOutputDir)) { + final dir = Directory(configuredOutputDir); + await dir.create(recursive: true); + return dir; + } + + if (Platform.isAndroid) { + final externalDir = await getExternalStorageDirectory(); + if (externalDir != null) { + final musicDir = Directory( + '${externalDir.parent.parent.parent.parent.path}' + '${Platform.pathSeparator}Music' + '${Platform.pathSeparator}SpotiFLAC', + ); + await musicDir.create(recursive: true); + return musicDir; + } + } + + final docsDir = await getApplicationDocumentsDirectory(); + final fallbackDir = Directory( + '${docsDir.path}${Platform.pathSeparator}SpotiFLAC', + ); + await fallbackDir.create(recursive: true); + return fallbackDir; + } + + Future?> _exportCueSplitOutputsToSaf({ + required List outputPaths, + required String treeUri, + required String relativeDir, + }) async { + final exportedUris = []; + for (final path in outputPaths) { + final fileName = path.split(Platform.pathSeparator).last; + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: audioMimeTypeForPath(path), + srcPath: path, + ); + if (safUri != null && safUri.isNotEmpty) { + exportedUris.add(safUri); + } + } + return exportedUris.isEmpty ? null : exportedUris; + } + + Future _performCueSplit({ + required String audioPath, + required String album, + required String artist, + required String genre, + required String date, + required List tracks, + }) async { + if (_isConverting) return; + setState(() => _isConverting = true); + + String? safTempAudioPath; + Directory? tempSplitDir; + try { + // For SAF content:// audio paths, copy to temp for FFmpeg processing + String workingAudioPath = audioPath; + final isSafSource = isContentUri(audioPath); + if (isSafSource) { + final tempPath = await PlatformBridge.copyContentUriToTemp(audioPath); + if (tempPath == null || tempPath.isEmpty) { + throw Exception('Failed to copy SAF audio file to temp'); + } + safTempAudioPath = tempPath; + workingAudioPath = tempPath; + } + + // Determine output directory + final String outputDir; + final treeUri = !_isLocalItem + ? (_downloadItem?.downloadTreeUri ?? '') + : ''; + final relativeDir = !_isLocalItem + ? (_downloadItem?.safRelativeDir ?? '') + : ''; + final writeBackToSaf = isSafSource && treeUri.isNotEmpty; + if (writeBackToSaf) { + final tempDir = await getTemporaryDirectory(); + tempSplitDir = Directory( + '${tempDir.path}${Platform.pathSeparator}' + 'cue_split_${DateTime.now().millisecondsSinceEpoch}', + ); + await tempSplitDir.create(recursive: true); + outputDir = tempSplitDir.path; + } else if (isSafSource) { + final persistentDir = await _resolvePersistentCueSplitOutputDir(); + outputDir = persistentDir.path; + } else { + outputDir = File(audioPath).parent.path; + } + + if (!mounted) return; + _showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length)); + + // Extract cover from audio file for embedding + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cue_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + workingAudioPath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + final albumMetadata = { + 'artist': artist, + 'album': album, + 'genre': genre, + 'date': date, + }; + + final outputPaths = await FFmpegService.splitCueToTracks( + audioPath: workingAudioPath, + outputDir: outputDir, + tracks: tracks, + albumMetadata: albumMetadata, + coverPath: coverPath, + onProgress: (current, total) { + if (mounted) { + _hideCurrentSnackBar(); + _showLongSnackBarMessage(_l10nCueSplitSplitting(current, total)); + } + }, + ); + + var finalOutputPaths = outputPaths; + + // Embed cover art into split FLAC files using Go backend + if (coverPath != null && finalOutputPaths != null) { + for (final path in finalOutputPaths) { + if (path.toLowerCase().endsWith('.flac')) { + try { + // Read existing metadata first + final metadata = await PlatformBridge.readFileMetadata(path); + if (metadata['error'] == null) { + final fields = {'cover_path': coverPath}; + // Preserve existing fields + for (final entry in metadata.entries) { + if (entry.key == 'error' || entry.value == null) continue; + final v = entry.value.toString().trim(); + if (v.isNotEmpty) { + fields[entry.key] = v; + } + } + await PlatformBridge.editFileMetadata(path, fields); + } + } catch (e) { + _log.w('Failed to embed cover to split track: $e'); + } + } + } + } + + if (writeBackToSaf && finalOutputPaths != null) { + final exportedUris = await _exportCueSplitOutputsToSaf( + outputPaths: finalOutputPaths, + treeUri: treeUri, + relativeDir: relativeDir, + ); + finalOutputPaths = exportedUris; + } + + // Cleanup cover temp + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (mounted) { + _hideCurrentSnackBar(); + if (finalOutputPaths != null && finalOutputPaths.isNotEmpty) { + _showSnackBarMessage(_l10nCueSplitSuccess(finalOutputPaths.length)); + } else { + _showSnackBarMessage(_l10nCueSplitFailed); + } + } + } catch (e) { + _log.e('CUE split failed: $e'); + if (mounted) { + _hideCurrentSnackBar(); + _showSnackBarMessage(_l10nCueSplitFailed); + } + } finally { + // Cleanup SAF temp audio copy + if (safTempAudioPath != null) { + try { + await File(safTempAudioPath).delete(); + } catch (_) {} + } + if (tempSplitDir != null) { + try { + await tempSplitDir.delete(recursive: true); + } catch (_) {} + } + if (mounted) { + setState(() => _isConverting = false); + } + } + } + void _confirmAndConvert({ required BuildContext context, required String sourceFormat, required String targetFormat, required String bitrate, }) { + final isLossless = + targetFormat.toUpperCase() == 'ALAC' || + targetFormat.toUpperCase() == 'FLAC'; showDialog( context: context, builder: (dialogContext) { return AlertDialog( title: Text(dialogContext.l10n.trackConvertConfirmTitle), content: Text( - dialogContext.l10n.trackConvertConfirmMessage( - sourceFormat, - targetFormat, - bitrate, - ), + isLossless + ? dialogContext.l10n.trackConvertConfirmMessageLossless( + sourceFormat, + targetFormat, + ) + : dialogContext.l10n.trackConvertConfirmMessage( + sourceFormat, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -2817,22 +3513,29 @@ class _TrackMetadataScreenState extends ConsumerState { SnackBar(content: Text(context.l10n.trackConvertConverting)), ); + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; final metadata = _buildFallbackMetadata(); try { final result = await PlatformBridge.readFileMetadata(cleanFilePath); if (result['error'] == null) { - result.forEach((key, value) { - if (key == 'error' || value == null) return; - final normalizedValue = value.toString().trim(); - if (normalizedValue.isEmpty) return; - metadata[key.toUpperCase()] = normalizedValue; - }); + mergePlatformMetadataForTagEmbed(target: metadata, source: result); } else { _log.w('readFileMetadata returned error, using fallback metadata'); } } catch (e) { _log.w('readFileMetadata threw, using fallback metadata: $e'); } + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: cleanFilePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: trackName, + artistName: artistName, + spotifyId: _spotifyId ?? '', + durationMs: (duration ?? 0) * 1000, + ); String? coverPath; try { @@ -2931,11 +3634,27 @@ class _TrackMetadataScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: // mp3 + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -3078,12 +3797,13 @@ class _TrackMetadataScreenState extends ConsumerState { colorScheme: colorScheme, initialValues: initialValues, filePath: cleanFilePath, + sourceTrackId: _spotifyId, ), ); if (saved == true && mounted) { ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Metadata saved successfully')), + SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)), ); // Re-read metadata from file to refresh the display try { @@ -3099,32 +3819,35 @@ class _TrackMetadataScreenState extends ConsumerState { } void _confirmDelete( - BuildContext context, + BuildContext screenContext, WidgetRef ref, ColorScheme colorScheme, ) { showDialog( - context: context, - useRootNavigator: false, - builder: (context) => AlertDialog( - title: Text(context.l10n.trackDeleteConfirmTitle), - content: Text(context.l10n.trackDeleteConfirmMessage), + context: screenContext, + useRootNavigator: true, + builder: (dialogContext) => AlertDialog( + title: Text(dialogContext.l10n.trackDeleteConfirmTitle), + content: Text(dialogContext.l10n.trackDeleteConfirmMessage), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.dialogCancel), + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.dialogCancel), ), TextButton( onPressed: () async { if (_isLocalItem) { - // For local items, just delete the file - try { - await deleteFile(cleanFilePath); - } catch (e) { - debugPrint('Failed to delete file: $e'); + if (_isCueVirtualTrack && _localLibraryItem != null) { + await ref + .read(localLibraryProvider.notifier) + .removeItem(_localLibraryItem!.id); + } else { + try { + await deleteFile(cleanFilePath); + } catch (e) { + debugPrint('Failed to delete file: $e'); + } } - // Also remove from local library database - // ref.read(localLibraryProvider.notifier).removeItem(_localLibraryItem!.id); } else { try { await deleteFile(cleanFilePath); @@ -3137,13 +3860,19 @@ class _TrackMetadataScreenState extends ConsumerState { .removeFromHistory(_downloadItem!.id); } - if (context.mounted) { - Navigator.pop(context); - Navigator.pop(context); + if (dialogContext.mounted) { + Navigator.pop(dialogContext); } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(true); + } + }); }, child: Text( - context.l10n.dialogDelete, + dialogContext.l10n.dialogDelete, style: TextStyle(color: colorScheme.error), ), ), @@ -3152,7 +3881,19 @@ class _TrackMetadataScreenState extends ConsumerState { ); } + void _closeOptionsMenuAndRun(BuildContext sheetContext, VoidCallback action) { + Navigator.pop(sheetContext); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + action(); + }); + } + Future _openFile(BuildContext context, String filePath) async { + if (isCueVirtualPath(filePath)) { + _showCueVirtualTrackSnackBar(context); + return; + } try { await ref .read(playbackProvider.notifier) @@ -3185,6 +3926,11 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _shareFile(BuildContext context) async { + if (_isCueVirtualTrack) { + _showCueVirtualTrackSnackBar(context); + return; + } + String sharePath = cleanFilePath; if (!await fileExists(sharePath)) { if (context.mounted) { @@ -3262,15 +4008,24 @@ class _TrackMetadataScreenState extends ConsumerState { } } +class _ResolvedAutoFillTrack { + final Map track; + final String? deezerId; + + const _ResolvedAutoFillTrack({required this.track, this.deezerId}); +} + class _EditMetadataSheet extends StatefulWidget { final ColorScheme colorScheme; final Map initialValues; final String filePath; + final String? sourceTrackId; const _EditMetadataSheet({ required this.colorScheme, required this.initialValues, required this.filePath, + this.sourceTrackId, }); @override @@ -3278,8 +4033,16 @@ class _EditMetadataSheet extends StatefulWidget { } class _EditMetadataSheetState extends State<_EditMetadataSheet> { + static final RegExp _metadataCollapsePattern = RegExp(r'[^a-z0-9]+'); + static final RegExp _metadataWhitespacePattern = RegExp(r'\s+'); + static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$'); + static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$'); + bool _saving = false; bool _showAdvanced = false; + bool _showAutoFill = false; + bool _fetching = false; String? _selectedCoverPath; String? _selectedCoverTempDir; String? _selectedCoverName; @@ -3287,6 +4050,25 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String? _currentCoverTempDir; bool _loadingCurrentCover = false; + // Auto-fill field selection — which fields the user wants to fetch + final Set _autoFillFields = {}; + + // All auto-fillable fields and their mapping + static const _fieldDefs = { + 'title': 'title', + 'artist': 'artist', + 'album': 'album', + 'album_artist': 'album_artist', + 'date': 'date', + 'track_number': 'track_number', + 'disc_number': 'disc_number', + 'genre': 'genre', + 'isrc': 'isrc', + 'label': 'label', + 'copyright': 'copyright', + 'cover': 'cover', + }; + late final TextEditingController _titleCtrl; late final TextEditingController _artistCtrl; late final TextEditingController _albumCtrl; @@ -3474,9 +4256,601 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to pick cover: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); + } + } + + String _fieldLabel(String key) { + final l10n = context.l10n; + switch (key) { + case 'title': + return l10n.editMetadataFieldTitle; + case 'artist': + return l10n.editMetadataFieldArtist; + case 'album': + return l10n.editMetadataFieldAlbum; + case 'album_artist': + return l10n.editMetadataFieldAlbumArtist; + case 'date': + return l10n.editMetadataFieldDate; + case 'track_number': + return l10n.editMetadataFieldTrackNum; + case 'disc_number': + return l10n.editMetadataFieldDiscNum; + case 'genre': + return l10n.editMetadataFieldGenre; + case 'isrc': + return l10n.editMetadataFieldIsrc; + case 'label': + return l10n.editMetadataFieldLabel; + case 'copyright': + return l10n.editMetadataFieldCopyright; + case 'cover': + return l10n.editMetadataFieldCover; + default: + return key; + } + } + + TextEditingController? _controllerForKey(String key) { + switch (key) { + case 'title': + return _titleCtrl; + case 'artist': + return _artistCtrl; + case 'album': + return _albumCtrl; + case 'album_artist': + return _albumArtistCtrl; + case 'date': + return _dateCtrl; + case 'track_number': + return _trackNumCtrl; + case 'disc_number': + return _discNumCtrl; + case 'genre': + return _genreCtrl; + case 'isrc': + return _isrcCtrl; + case 'label': + return _labelCtrl; + case 'copyright': + return _copyrightCtrl; + default: + return null; + } + } + + void _selectAllFields() { + setState(() { + _autoFillFields.addAll(_fieldDefs.keys); + }); + } + + void _selectEmptyFields() { + setState(() { + _autoFillFields.clear(); + for (final key in _fieldDefs.keys) { + if (key == 'cover') { + if (!_hasValue(_currentCoverPath) && !_hasValue(_selectedCoverPath)) { + _autoFillFields.add(key); + } + continue; + } + final ctrl = _controllerForKey(key); + if (ctrl != null && ctrl.text.trim().isEmpty) { + _autoFillFields.add(key); + } + } + }); + } + + String _normalizeMetadataText(String value) { + final collapsed = value + .toLowerCase() + .replaceAll(_metadataCollapsePattern, ' ') + .trim(); + return collapsed.replaceAll(_metadataWhitespacePattern, ' '); + } + + bool _looksLikeIsrc(String value) { + return _isrcPattern.hasMatch(value.trim().toUpperCase()); + } + + String? _extractRawSpotifyTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_spotifyTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('spotify:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_spotifyTrackIdPattern.hasMatch(last)) { + return last; + } + return null; + } + + final uri = Uri.tryParse(raw); + if (uri != null && + uri.host.contains('spotify.com') && + uri.pathSegments.length >= 2 && + uri.pathSegments.first == 'track') { + final candidate = uri.pathSegments[1].trim(); + if (_spotifyTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + + return null; + } + + String? _extractRawDeezerTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_deezerTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('deezer:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_deezerTrackIdPattern.hasMatch(last)) { + return last; + } + } + + final uri = Uri.tryParse(raw); + if (uri != null && uri.host.contains('deezer.com')) { + final trackIndex = uri.pathSegments.indexOf('track'); + if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) { + final candidate = uri.pathSegments[trackIndex + 1].trim(); + if (_deezerTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + } + + return null; + } + + String? _extractRawSpotifyTrackId(Map track) { + for (final candidate in [track['spotify_id'], track['id']]) { + final spotifyId = _extractRawSpotifyTrackIdFromValue(candidate); + if (spotifyId != null) return spotifyId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final spotifyId = _extractRawSpotifyTrackIdFromValue( + externalLinks['spotify'], + ); + if (spotifyId != null) return spotifyId; + } + + return null; + } + + String? _extractRawDeezerTrackId(Map track) { + for (final candidate in [ + track['deezer_id'], + track['spotify_id'], + track['id'], + ]) { + final deezerId = _extractRawDeezerTrackIdFromValue(candidate); + if (deezerId != null) return deezerId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final deezerId = _extractRawDeezerTrackIdFromValue( + externalLinks['deezer'], + ); + if (deezerId != null) return deezerId; + } + + return null; + } + + Map _unwrapTrackPayload(Map payload) { + final track = payload['track']; + if (track is Map) { + return track; + } + return payload; + } + + void _mergeOnlineTrackData( + Map enriched, + Map track, + ) { + void put(String key, Object? value) { + final text = value?.toString().trim() ?? ''; + if (text.isNotEmpty && text != 'null') { + enriched[key] = text; + } + } + + put('title', track['name'] ?? track['title']); + put('artist', track['artists'] ?? track['artist']); + put('album', track['album_name'] ?? track['album']); + put('album_artist', track['album_artist']); + put('date', track['release_date']); + put('track_number', track['track_number']); + put('disc_number', track['disc_number']); + put('isrc', track['isrc']); + put('genre', track['genre']); + put('label', track['label']); + put('copyright', track['copyright']); + } + + Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers( + String currentIsrc, + ) async { + if (_looksLikeIsrc(currentIsrc)) { + final deezerTrack = await PlatformBridge.searchDeezerByISRC(currentIsrc); + return _ResolvedAutoFillTrack( + track: _unwrapTrackPayload(deezerTrack), + deezerId: _extractRawDeezerTrackId(deezerTrack), + ); + } + + final sourceTrackId = widget.sourceTrackId?.trim() ?? ''; + if (sourceTrackId.isEmpty) { + return null; + } + + final deezerId = _extractRawDeezerTrackIdFromValue(sourceTrackId); + if (deezerId != null) { + final deezerTrack = await PlatformBridge.getDeezerMetadata( + 'track', + deezerId, + ); + return _ResolvedAutoFillTrack( + track: _unwrapTrackPayload(deezerTrack), + deezerId: deezerId, + ); + } + + final spotifyId = _extractRawSpotifyTrackIdFromValue(sourceTrackId); + if (spotifyId != null) { + final deezerTrack = await PlatformBridge.convertSpotifyToDeezer( + 'track', + spotifyId, + ); + final track = _unwrapTrackPayload(deezerTrack); + return _ResolvedAutoFillTrack( + track: track, + deezerId: + _extractRawDeezerTrackId(track) ?? + _extractRawDeezerTrackId(deezerTrack), + ); + } + + return null; + } + + int _metadataMatchScore( + Map track, { + required String currentTitle, + required String currentArtist, + required String currentAlbum, + required String currentIsrc, + }) { + var score = 0; + + final candidateIsrc = (track['isrc']?.toString() ?? '') + .trim() + .toUpperCase(); + if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) { + score += 10000; + } + + final candidateTitle = _normalizeMetadataText( + (track['name'] ?? track['title'] ?? '').toString(), + ); + final candidateArtist = _normalizeMetadataText( + (track['artists'] ?? track['artist'] ?? '').toString(), + ); + final candidateAlbum = _normalizeMetadataText( + (track['album_name'] ?? track['album'] ?? '').toString(), + ); + + if (currentTitle.isNotEmpty && candidateTitle.isNotEmpty) { + if (candidateTitle == currentTitle) { + score += 400; + } else if (candidateTitle.contains(currentTitle) || + currentTitle.contains(candidateTitle)) { + score += 180; + } + } + + if (currentArtist.isNotEmpty && candidateArtist.isNotEmpty) { + if (candidateArtist == currentArtist) { + score += 320; + } else if (candidateArtist.contains(currentArtist) || + currentArtist.contains(candidateArtist)) { + score += 140; + } + } + + if (currentAlbum.isNotEmpty && candidateAlbum.isNotEmpty) { + if (candidateAlbum == currentAlbum) { + score += 120; + } else if (candidateAlbum.contains(currentAlbum) || + currentAlbum.contains(candidateAlbum)) { + score += 50; + } + } + + return score; + } + + Future _fetchAndFill() async { + if (_autoFillFields.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoneSelected)), + ); + return; + } + + setState(() => _fetching = true); + + try { + final title = _titleCtrl.text.trim(); + final artist = _artistCtrl.text.trim(); + final album = _albumCtrl.text.trim(); + final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); + Map? best; + String? deezerId; + + try { + final resolved = await _resolveAutoFillTrackFromIdentifiers( + currentIsrc, + ); + if (resolved != null) { + best = resolved.track; + deezerId = resolved.deezerId; + } + } catch (e) { + _log.w('Identifier-first autofill lookup failed: $e'); + } + + final queryParts = []; + if (title.isNotEmpty) queryParts.add(title); + if (artist.isNotEmpty) queryParts.add(artist); + if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album); + + if (best == null && queryParts.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + ); + } + return; + } + + final normalizedTitle = _normalizeMetadataText(title); + final normalizedArtist = _normalizeMetadataText(artist); + final normalizedAlbum = _normalizeMetadataText(album); + + if (best == null) { + final query = queryParts.join(' '); + final results = await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 5, + ); + + if (!mounted) return; + + if (results.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + ); + return; + } + + // Pick best match using current metadata, not only provider order. + best = results.first; + var bestScore = -1; + for (final result in results) { + final score = _metadataMatchScore( + result, + currentTitle: normalizedTitle, + currentArtist: normalizedArtist, + currentAlbum: normalizedAlbum, + currentIsrc: currentIsrc, + ); + if (score > bestScore) { + bestScore = score; + best = result; + } + } + } + + final selectedBest = best; + if (selectedBest == null) { + throw StateError('No metadata match resolved for auto-fill'); + } + + // Extract basic metadata from search result + final enriched = { + 'title': (selectedBest['name'] ?? '').toString(), + 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') + .toString(), + 'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '') + .toString(), + 'album_artist': (selectedBest['album_artist'] ?? '').toString(), + 'date': (selectedBest['release_date'] ?? '').toString(), + 'track_number': (selectedBest['track_number'] ?? '').toString(), + 'disc_number': (selectedBest['disc_number'] ?? '').toString(), + 'isrc': (selectedBest['isrc'] ?? '').toString(), + }; + _mergeOnlineTrackData(enriched, selectedBest); + + final needsIsrc = + _autoFillFields.contains('isrc') && enriched['isrc']!.isEmpty; + final needsExtended = + _autoFillFields.contains('genre') || + _autoFillFields.contains('label') || + _autoFillFields.contains('copyright'); + + final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest); + + deezerId ??= _extractRawDeezerTrackId(selectedBest); + final candidateIsrc = enriched['isrc']!.trim().toUpperCase(); + final deezerLookupIsrc = _looksLikeIsrc(currentIsrc) + ? currentIsrc + : (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : ''); + + if (needsIsrc || needsExtended) { + try { + if (deezerId == null && deezerLookupIsrc.isNotEmpty) { + final deezerResult = await PlatformBridge.searchDeezerByISRC( + deezerLookupIsrc, + ); + deezerId = _extractRawDeezerTrackId(deezerResult); + _mergeOnlineTrackData(enriched, deezerResult); + } + + if (deezerId == null && rawSpotifyId != null) { + // Spotify IDs can be mapped through SongLink to a Deezer track. + final deezerData = await PlatformBridge.convertSpotifyToDeezer( + 'track', + rawSpotifyId, + ); + final trackData = deezerData['track']; + if (trackData is Map) { + deezerId = _extractRawDeezerTrackId(trackData); + _mergeOnlineTrackData(enriched, trackData); + } + deezerId ??= _extractRawDeezerTrackId(deezerData); + } + } catch (_) { + // Deezer resolution is best-effort + } + } + + if (!mounted) return; + + // Fetch ISRC from Deezer track metadata if still missing + if (needsIsrc && enriched['isrc']!.isEmpty && deezerId != null) { + try { + final deezerMeta = await PlatformBridge.getDeezerMetadata( + 'track', + deezerId, + ); + final trackData = _unwrapTrackPayload(deezerMeta); + _mergeOnlineTrackData(enriched, trackData); + final deezerIsrc = (trackData['isrc'] ?? '').toString().trim(); + if (deezerIsrc.isNotEmpty) { + enriched['isrc'] = deezerIsrc; + } + } catch (_) {} + } + + if (!mounted) return; + + // Fetch genre/label/copyright from Deezer extended metadata + if (needsExtended && deezerId != null) { + try { + final extended = await PlatformBridge.getDeezerExtendedMetadata( + deezerId, + ); + if (extended != null) { + enriched['genre'] = extended['genre'] ?? ''; + enriched['label'] = extended['label'] ?? ''; + enriched['copyright'] = extended['copyright'] ?? ''; + } + } catch (_) { + // Extended metadata is best-effort + } + } + + if (!mounted) return; + + // Apply selected fields to controllers + var filledCount = 0; + for (final key in _autoFillFields) { + if (key == 'cover') continue; // Handle cover separately below + final value = enriched[key]; + if (value != null && + value.isNotEmpty && + value != '0' && + value != 'null') { + final ctrl = _controllerForKey(key); + if (ctrl != null) { + ctrl.text = value; + filledCount++; + } + } + } + + // Handle cover art download + if (_autoFillFields.contains('cover')) { + final coverUrl = + (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') + .toString(); + if (coverUrl.isNotEmpty) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'autofill_cover_', + ); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final response = await HttpClient() + .getUrl(Uri.parse(coverUrl)) + .then((req) => req.close()); + final file = File(coverOutput); + final sink = file.openWrite(); + await response.pipe(sink); + if (await file.exists() && await file.length() > 0) { + await _cleanupSelectedCoverTemp(); + if (mounted) { + setState(() { + _selectedCoverPath = coverOutput; + _selectedCoverTempDir = tempDir.path; + _selectedCoverName = 'Online cover'; + }); + filledCount++; + } + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) { + // Cover download is best-effort + } + } + } + + if (mounted) { + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + filledCount > 0 + ? context.l10n.editMetadataAutoFillDone(filledCount) + : context.l10n.editMetadataAutoFillNoResults, + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); + } + } finally { + if (mounted) setState(() => _fetching = false); } } @@ -3568,6 +4942,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final lower = widget.filePath.toLowerCase(); final isMp3 = lower.endsWith('.mp3'); final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); + final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac'); final vorbisMap = {}; if (metadata['title']?.isNotEmpty == true) { @@ -3611,6 +4986,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (metadata['comment']?.isNotEmpty == true) { vorbisMap['COMMENT'] = metadata['comment']!; } + try { + final existingMetadata = await PlatformBridge.readFileMetadata( + ffmpegTarget, + ); + final existingLyrics = existingMetadata['lyrics']?.toString().trim(); + if (existingLyrics != null && existingLyrics.isNotEmpty) { + vorbisMap['LYRICS'] = existingLyrics; + vorbisMap['UNSYNCEDLYRICS'] = existingLyrics; + } + } catch (_) { + // Lyrics preservation is best-effort. + } String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath; String? extractedCoverPath; @@ -3644,6 +5031,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { coverPath: existingCoverPath, metadata: vorbisMap, ); + } else if (isM4A) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: ffmpegTarget, + coverPath: existingCoverPath, + metadata: vorbisMap, + ); } else if (isOpus) { ffmpegResult = await FFmpegService.embedMetadataToOpus( opusPath: ffmpegTarget, @@ -3698,9 +5091,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to save metadata: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } finally { if (mounted) setState(() => _saving = false); @@ -3764,6 +5157,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { children: [ const SizedBox(height: 6), _buildCoverEditor(cs), + _buildAutoFillSection(cs), _field('Title', _titleCtrl), _field('Artist', _artistCtrl), _field('Album', _albumCtrl), @@ -3835,6 +5229,174 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } + Widget _buildAutoFillSection(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => setState(() => _showAutoFill = !_showAutoFill), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + Icon(Icons.travel_explore, size: 20, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.editMetadataAutoFill, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: cs.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + Icon( + _showAutoFill ? Icons.expand_less : Icons.expand_more, + size: 20, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + if (_showAutoFill) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + context.l10n.editMetadataAutoFillDesc, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), + const SizedBox(height: 8), + // Quick select buttons + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + _quickSelectButton( + label: context.l10n.editMetadataSelectAll, + onTap: _selectAllFields, + cs: cs, + ), + const SizedBox(width: 8), + _quickSelectButton( + label: context.l10n.editMetadataSelectEmpty, + onTap: _selectEmptyFields, + cs: cs, + ), + ], + ), + ), + const SizedBox(height: 8), + // Field chips + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Wrap( + spacing: 6, + runSpacing: 4, + children: _fieldDefs.keys.map((key) { + final selected = _autoFillFields.contains(key); + return FilterChip( + label: Text(_fieldLabel(key)), + selected: selected, + onSelected: _fetching + ? null + : (val) { + setState(() { + if (val) { + _autoFillFields.add(key); + } else { + _autoFillFields.remove(key); + } + }); + }, + selectedColor: cs.primaryContainer, + checkmarkColor: cs.onPrimaryContainer, + labelStyle: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: selected + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }).toList(), + ), + ), + const SizedBox(height: 10), + // Fetch button + Padding( + padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: (_fetching || _saving || _autoFillFields.isEmpty) + ? null + : _fetchAndFill, + icon: _fetching + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.auto_fix_high), + label: Text( + _fetching + ? context.l10n.editMetadataAutoFillSearching + : context.l10n.editMetadataAutoFillFetch, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _quickSelectButton({ + required String label, + required VoidCallback onTap, + required ColorScheme cs, + }) { + return InkWell( + onTap: _fetching ? null : onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.5)), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: cs.primary), + ), + ), + ); + } + Widget _buildCoverEditor(ColorScheme cs) { final hasSelectedCover = _hasValue(_selectedCoverPath); final hasCurrentCover = _hasValue(_currentCoverPath); diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index 81cb6c06..0af65464 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -37,7 +37,6 @@ class CoverCacheManager { final appDir = await getApplicationSupportDirectory(); _cachePath = p.join(appDir.path, 'cover_cache'); - // Ensure cache directory exists await Directory(_cachePath!).create(recursive: true); debugPrint('CoverCacheManager: Initializing at $_cachePath'); @@ -48,7 +47,6 @@ class CoverCacheManager { debugPrint('CoverCacheManager: Initialized successfully'); } catch (e) { debugPrint('CoverCacheManager: Failed to initialize: $e'); - // Will fallback to DefaultCacheManager } } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 2b28ca9d..53efe84d 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -130,6 +130,25 @@ class FFmpegService { } } + static Future _executeWithArguments( + List arguments, + ) async { + try { + final session = await FFmpegKit.executeWithArguments(arguments); + final returnCode = await session.getReturnCode(); + final output = await session.getOutput() ?? ''; + + return FFmpegResult( + success: ReturnCode.isSuccess(returnCode), + returnCode: returnCode?.getValue() ?? -1, + output: output, + ); + } catch (e) { + _log.e('FFmpeg executeWithArguments error: $e'); + return FFmpegResult(success: false, returnCode: -1, output: e.toString()); + } + } + static Future convertM4aToFlac(String inputPath) async { final outputPath = _buildOutputPath(inputPath, '.flac'); @@ -1030,18 +1049,24 @@ class FFmpegService { }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus'); - - final StringBuffer cmdBuffer = StringBuffer(); - cmdBuffer.write('-i "$opusPath" '); - cmdBuffer.write('-map 0:a '); - cmdBuffer.write('-map_metadata -1 '); - cmdBuffer.write('-map_metadata:s:a -1 '); - cmdBuffer.write('-c:a copy '); + final arguments = [ + '-i', + opusPath, + '-map', + '0:a', + '-map_metadata', + '-1', + '-map_metadata:s:a', + '-1', + '-c:a', + 'copy', + ]; if (metadata != null) { metadata.forEach((key, value) { - final sanitizedValue = value.replaceAll('"', '\\"'); - cmdBuffer.write('-metadata $key="$sanitizedValue" '); + arguments + ..add('-metadata') + ..add('$key=$value'); }); } @@ -1049,8 +1074,9 @@ class FFmpegService { try { final pictureBlock = await _createMetadataBlockPicture(coverPath); if (pictureBlock != null) { - final escapedBlock = pictureBlock.replaceAll('"', '\\"'); - cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" '); + arguments + ..add('-metadata') + ..add('METADATA_BLOCK_PICTURE=$pictureBlock'); _log.d( 'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)', ); @@ -1062,12 +1088,12 @@ class FFmpegService { } } - cmdBuffer.write('"$tempOutput" -y'); - - final command = cmdBuffer.toString(); + arguments + ..add(tempOutput) + ..add('-y'); _log.d('Executing FFmpeg Opus embed command'); - final result = await _execute(command); + final result = await _executeWithArguments(arguments); if (result.success) { try { @@ -1106,6 +1132,88 @@ class FFmpegService { return null; } + static Future embedMetadataToM4a({ + required String m4aPath, + String? coverPath, + Map? metadata, + }) async { + final tempDir = await getTemporaryDirectory(); + final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a'); + + final cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$m4aPath" '); + + final hasCover = coverPath != null && await File(coverPath).exists(); + if (hasCover) { + cmdBuffer.write('-i "$coverPath" '); + } + + cmdBuffer.write('-map 0:a '); + cmdBuffer.write('-map_metadata -1 '); + + // For M4A/MP4, cover art is mapped as a video stream and stored in the + // 'covr' atom automatically by FFmpeg. The '-disposition attached_pic' + // flag is only valid for Matroska/WebM containers and must NOT be used here. + if (hasCover) { + cmdBuffer.write('-map 1:v -c:v copy '); + } + + cmdBuffer.write('-c:a copy '); + + if (metadata != null) { + final m4aMetadata = _convertToM4aTags(metadata); + for (final entry in m4aMetadata.entries) { + final sanitizedValue = entry.value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata ${entry.key}="$sanitizedValue" '); + } + } + + cmdBuffer.write('"$tempOutput" -y'); + + final command = cmdBuffer.toString(); + _log.d( + 'Executing FFmpeg M4A embed command: ${_previewCommandForLog(command)}', + ); + + final result = await _execute(command); + + if (result.success) { + try { + final tempFile = File(tempOutput); + final originalFile = File(m4aPath); + + if (await tempFile.exists()) { + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(m4aPath); + await tempFile.delete(); + + _log.d('M4A metadata embedded successfully'); + return m4aPath; + } else { + _log.e('Temp M4A output file not found: $tempOutput'); + return null; + } + } catch (e) { + _log.e('Failed to replace M4A 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 M4A file: $e'); + } + + _log.e('M4A Metadata embed failed: ${result.output}'); + return null; + } + static Future _createMetadataBlockPicture(String imagePath) async { try { final file = File(imagePath); @@ -1209,7 +1317,8 @@ class FFmpegService { } /// Unified audio format conversion with full metadata + cover preservation. - /// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format). + /// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC. + /// ALAC and FLAC targets are lossless (bitrate parameter is ignored). /// Returns the new file path on success, null on failure. static Future convertAudioFormat({ required String inputPath, @@ -1220,11 +1329,30 @@ class FFmpegService { bool deleteOriginal = true, }) async { final format = targetFormat.toLowerCase(); - if (format != 'mp3' && format != 'opus') { + if (!const {'mp3', 'opus', 'alac', 'flac'}.contains(format)) { _log.e('Unsupported target format: $targetFormat'); return null; } + // Lossless targets: dedicated single-pass methods + if (format == 'alac') { + return _convertToAlac( + inputPath: inputPath, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: deleteOriginal, + ); + } + if (format == 'flac') { + return _convertToFlac( + inputPath: inputPath, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: deleteOriginal, + ); + } + + // Lossy targets: MP3 / Opus final extension = format == 'opus' ? '.opus' : '.mp3'; final outputPath = _buildOutputPath(inputPath, extension); @@ -1296,6 +1424,266 @@ class FFmpegService { return outputPath; } + /// Convert any audio format to ALAC (Apple Lossless) in an M4A container. + /// Metadata and cover art are embedded in a single FFmpeg pass. + static Future _convertToAlac({ + required String inputPath, + required Map metadata, + String? coverPath, + bool deleteOriginal = true, + }) async { + final outputPath = _buildOutputPath(inputPath, '.m4a'); + + final cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$inputPath" '); + + // Cover art as second input for M4A attached picture + final hasCover = + coverPath != null && + coverPath.trim().isNotEmpty && + await File(coverPath).exists(); + if (hasCover) { + cmdBuffer.write('-i "$coverPath" '); + } + + cmdBuffer.write('-map 0:a '); + // M4A/MP4 containers store cover art in the 'covr' atom automatically. + // '-disposition attached_pic' is only for Matroska/WebM and must NOT be used here. + if (hasCover) { + cmdBuffer.write('-map 1:v -c:v copy '); + } + cmdBuffer.write('-c:a alac '); + cmdBuffer.write('-map_metadata -1 '); + + // Embed M4A metadata tags + final m4aTags = _convertToM4aTags(metadata); + for (final entry in m4aTags.entries) { + final sanitized = entry.value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); + } + + cmdBuffer.write('"$outputPath" -y'); + + _log.i( + 'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC', + ); + final result = await _execute(cmdBuffer.toString()); + + if (!result.success) { + _log.e('ALAC conversion failed: ${result.output}'); + return null; + } + + if (deleteOriginal) { + try { + await File(inputPath).delete(); + _log.i( + 'Deleted original: ${inputPath.split(Platform.pathSeparator).last}', + ); + } catch (e) { + _log.w('Failed to delete original: $e'); + } + } + + return outputPath; + } + + /// Convert any audio format to FLAC with metadata and cover art preservation. + static Future _convertToFlac({ + required String inputPath, + required Map metadata, + String? coverPath, + bool deleteOriginal = true, + }) async { + final outputPath = _buildOutputPath(inputPath, '.flac'); + + final cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$inputPath" '); + + final hasCover = + coverPath != null && + coverPath.trim().isNotEmpty && + await File(coverPath).exists(); + if (hasCover) { + cmdBuffer.write('-i "$coverPath" '); + } + + cmdBuffer.write('-map 0:a '); + if (hasCover) { + cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic '); + cmdBuffer.write('-metadata:s:v title="Album cover" '); + cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); + } + cmdBuffer.write('-c:a flac -compression_level 8 '); + cmdBuffer.write('-map_metadata 0 '); + + final vorbisComments = _normalizeToVorbisComments(metadata); + for (final entry in vorbisComments.entries) { + final sanitized = entry.value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); + } + + cmdBuffer.write('"$outputPath" -y'); + + _log.i( + 'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC', + ); + final result = await _execute(cmdBuffer.toString()); + + if (!result.success) { + _log.e('FLAC conversion failed: ${result.output}'); + return null; + } + + if (deleteOriginal) { + try { + await File(inputPath).delete(); + _log.i( + 'Deleted original: ${inputPath.split(Platform.pathSeparator).last}', + ); + } catch (e) { + _log.w('Failed to delete original: $e'); + } + } + + return outputPath; + } + + /// Normalize metadata keys to standard Vorbis comment names, filtering out + /// technical fields (bit_depth, sample_rate, duration, etc.). + static Map _normalizeToVorbisComments( + Map metadata, + ) { + final vorbis = {}; + + for (final entry in metadata.entries) { + final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + final value = entry.value; + if (value.trim().isEmpty) continue; + + switch (key) { + case 'TITLE': + vorbis['TITLE'] = value; + break; + case 'ARTIST': + vorbis['ARTIST'] = value; + break; + case 'ALBUM': + vorbis['ALBUM'] = value; + break; + case 'ALBUMARTIST': + vorbis['ALBUMARTIST'] = value; + break; + case 'TRACKNUMBER': + case 'TRACKNBR': + case 'TRACK': + case 'TRCK': + if (value != '0') vorbis['TRACKNUMBER'] = value; + break; + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + if (value != '0') vorbis['DISCNUMBER'] = value; + break; + case 'DATE': + case 'YEAR': + vorbis['DATE'] = value; + break; + case 'GENRE': + vorbis['GENRE'] = value; + break; + case 'ISRC': + vorbis['ISRC'] = value; + break; + case 'LABEL': + case 'ORGANIZATION': + vorbis['ORGANIZATION'] = value; + break; + case 'COPYRIGHT': + vorbis['COPYRIGHT'] = value; + break; + case 'COMPOSER': + vorbis['COMPOSER'] = value; + break; + case 'COMMENT': + vorbis['COMMENT'] = value; + break; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + vorbis['LYRICS'] = value; + vorbis['UNSYNCEDLYRICS'] = value; + break; + } + } + + return vorbis; + } + + /// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg. + static Map _convertToM4aTags(Map metadata) { + final m4aMap = {}; + + for (final entry in metadata.entries) { + final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + final value = entry.value; + if (value.trim().isEmpty) continue; + + switch (key) { + case 'TITLE': + m4aMap['title'] = value; + break; + case 'ARTIST': + m4aMap['artist'] = value; + break; + case 'ALBUM': + m4aMap['album'] = value; + break; + case 'ALBUMARTIST': + m4aMap['album_artist'] = value; + break; + case 'TRACKNUMBER': + case 'TRACK': + case 'TRCK': + m4aMap['track'] = value; + break; + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + m4aMap['disc'] = value; + break; + case 'DATE': + case 'YEAR': + m4aMap['date'] = value; + break; + case 'GENRE': + m4aMap['genre'] = value; + break; + case 'ISRC': + m4aMap['isrc'] = value; + break; + case 'COMPOSER': + m4aMap['composer'] = value; + break; + case 'COMMENT': + m4aMap['comment'] = value; + break; + case 'COPYRIGHT': + m4aMap['copyright'] = value; + break; + case 'LABEL': + case 'ORGANIZATION': + m4aMap['organization'] = value; + break; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + m4aMap['lyrics'] = value; + break; + } + } + + return m4aMap; + } + static Map _convertToId3Tags( Map vorbisMetadata, ) { @@ -1353,6 +1741,164 @@ class FFmpegService { return id3Map; } + + /// Split a CUE+audio file into individual track files using FFmpeg. + /// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded. + /// [audioPath] is the source audio file (FLAC, WAV, etc.) + /// [outputDir] is where individual track files will be saved + /// [tracks] is the list of track split info from the Go CUE parser + /// [albumMetadata] contains album-level metadata (artist, album, genre, date) + /// Returns list of output file paths on success, null on failure. + static Future?> splitCueToTracks({ + required String audioPath, + required String outputDir, + required List tracks, + required Map albumMetadata, + String? coverPath, + void Function(int current, int total)? onProgress, + }) async { + if (tracks.isEmpty) { + _log.e('No tracks to split'); + return null; + } + + final outputPaths = []; + final inputExt = audioPath.toLowerCase().split('.').last; + // For lossless formats, keep as FLAC; for others, keep original format + final outputExt = + (inputExt == 'flac' || + inputExt == 'wav' || + inputExt == 'ape' || + inputExt == 'wv') + ? 'flac' + : inputExt; + + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + onProgress?.call(i + 1, tracks.length); + + final sanitizedTitle = track.title + .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + final trackNumStr = track.number.toString().padLeft(2, '0'); + final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt'; + final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName'; + + final StringBuffer cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$audioPath" '); + + final startTime = _formatSecondsForFFmpeg(track.startSec); + cmdBuffer.write('-ss $startTime '); + + if (track.endSec > 0) { + final endTime = _formatSecondsForFFmpeg(track.endSec); + cmdBuffer.write('-to $endTime '); + } + + if (outputExt == 'flac') { + cmdBuffer.write('-c:a flac -compression_level 8 '); + } else { + cmdBuffer.write('-c:a copy '); + } + + final artist = track.artist.isNotEmpty + ? track.artist + : (albumMetadata['artist'] ?? ''); + final album = albumMetadata['album'] ?? ''; + final genre = albumMetadata['genre'] ?? ''; + final date = albumMetadata['date'] ?? ''; + + void addMeta(String key, String value) { + if (value.isNotEmpty) { + final sanitized = value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata $key="$sanitized" '); + } + } + + addMeta('TITLE', track.title); + addMeta('ARTIST', artist); + addMeta('ALBUM', album); + addMeta('ALBUMARTIST', albumMetadata['artist'] ?? ''); + addMeta('TRACKNUMBER', track.number.toString()); + addMeta('GENRE', genre); + addMeta('DATE', date); + if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc); + if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer); + + cmdBuffer.write('"$outputPath" -y'); + + final command = cmdBuffer.toString(); + _log.d( + 'CUE split track ${track.number}: ${_previewCommandForLog(command)}', + ); + + final result = await _execute(command); + if (!result.success) { + _log.e('CUE split failed for track ${track.number}: ${result.output}'); + // Continue with remaining tracks instead of failing completely + continue; + } + + // Embed cover art if available (for FLAC output) + if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') { + // Use the Go backend for FLAC cover embedding via PlatformBridge + // (handled by the caller) + } + + outputPaths.add(outputPath); + _log.i('CUE split: track ${track.number} -> $outputFileName'); + } + + if (outputPaths.isEmpty) { + _log.e('CUE split: no tracks were successfully extracted'); + return null; + } + + _log.i('CUE split complete: ${outputPaths.length}/${tracks.length} tracks'); + return outputPaths; + } + + static String _formatSecondsForFFmpeg(double seconds) { + if (seconds < 0) return '0'; + final hours = seconds ~/ 3600; + final mins = (seconds % 3600) ~/ 60; + final secs = seconds - (hours * 3600) - (mins * 60); + return '${hours.toString().padLeft(2, '0')}:${mins.toInt().toString().padLeft(2, '0')}:${secs.toStringAsFixed(3).padLeft(6, '0')}'; + } +} + +/// Track info for CUE splitting, passed from the CUE parser +class CueSplitTrackInfo { + final int number; + final String title; + final String artist; + final String isrc; + final String composer; + final double startSec; + final double endSec; + + CueSplitTrackInfo({ + required this.number, + required this.title, + required this.artist, + this.isrc = '', + this.composer = '', + required this.startSec, + required this.endSec, + }); + + factory CueSplitTrackInfo.fromJson(Map json) { + return CueSplitTrackInfo( + number: json['number'] as int? ?? 0, + title: json['title'] as String? ?? '', + artist: json['artist'] as String? ?? '', + isrc: json['isrc'] as String? ?? '', + composer: json['composer'] as String? ?? '', + startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0, + endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0, + ); + } } class FFmpegResult { diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 75bfaada..9e648bc7 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -12,7 +12,6 @@ final Future _prefs = SharedPreferences.getInstance(); /// Cached current iOS container path for path normalization String? _currentContainerPath; -/// SQLite database service for download history /// Provides O(1) lookups by spotify_id and isrc with proper indexing class HistoryDatabase { static final HistoryDatabase instance = HistoryDatabase._init(); @@ -78,7 +77,6 @@ class HistoryDatabase { ) '''); - // Indexes for fast lookups await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)'); await db.execute('CREATE INDEX idx_isrc ON history(isrc)'); await db.execute( @@ -171,7 +169,6 @@ class HistoryDatabase { try { final db = await database; - // Get all items with iOS paths final rows = await db.query('history', columns: ['id', 'file_path']); int updatedCount = 0; final batch = db.batch(); @@ -198,7 +195,6 @@ class HistoryDatabase { await batch.commit(noResult: true); } - // Save current container path await prefs.setString('ios_last_container_path', _currentContainerPath!); _log.i('iOS path migration complete: $updatedCount paths updated'); @@ -323,7 +319,6 @@ class HistoryDatabase { }; } - /// Insert or update a history item Future upsert(Map json) async { final db = await database; await db.insert( @@ -345,7 +340,6 @@ class HistoryDatabase { return rows.map(_dbRowToJson).toList(); } - /// Get item by ID Future?> getById(String id) async { final db = await database; final rows = await db.query( @@ -403,26 +397,22 @@ class HistoryDatabase { return rows.map((r) => r['spotify_id'] as String).toSet(); } - /// Delete by ID Future deleteById(String id) async { final db = await database; await db.delete('history', where: 'id = ?', whereArgs: [id]); } - /// Delete by Spotify ID Future deleteBySpotifyId(String spotifyId) async { final db = await database; await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]); } - /// Clear all history Future clearAll() async { final db = await database; await db.delete('history'); _log.i('Cleared all history'); } - /// Get total count Future getCount() async { final db = await database; final result = await db.rawQuery('SELECT COUNT(*) as count FROM history'); @@ -459,7 +449,6 @@ class HistoryDatabase { return null; } - /// Close database Future close() async { final db = await database; await db.close(); diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index c92fb7a3..47b3ab19 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; @@ -121,7 +123,7 @@ class LibraryDatabase { return await openDatabase( path, - version: 4, // Bumped version for bitrate column + version: 4, onConfigure: (db) async { await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); @@ -176,7 +178,6 @@ class LibraryDatabase { _log.i('Upgrading library database from v$oldVersion to v$newVersion'); if (oldVersion < 2) { - // Add cover_path column await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT'); _log.i('Added cover_path column'); } @@ -242,8 +243,6 @@ class LibraryDatabase { }; } - // CRUD Operations - Future upsert(Map json) async { final db = await database; await db.insert( @@ -332,13 +331,11 @@ class LibraryDatabase { String? trackName, String? artistName, }) async { - // First try ISRC if available if (isrc != null && isrc.isNotEmpty) { final byIsrc = await getByIsrc(isrc); if (byIsrc != null) return byIsrc; } - // Then try name matching if (trackName != null && artistName != null) { final matches = await findByTrackAndArtist(trackName, artistName); if (matches.isNotEmpty) return matches.first; @@ -473,6 +470,34 @@ class LibraryDatabase { return result; } + /// Export file modification times to a compact line-based snapshot that + /// native code can read without receiving a large method-channel payload. + Future writeFileModTimesSnapshot() async { + final db = await database; + final rows = await db.rawQuery( + 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library', + ); + final tempDir = await getTemporaryDirectory(); + final file = File( + join( + tempDir.path, + 'library_file_mod_times_${DateTime.now().microsecondsSinceEpoch}.tsv', + ), + ); + final buffer = StringBuffer(); + for (final row in rows) { + final path = row['file_path'] as String?; + if (path == null || path.isEmpty) continue; + final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0; + buffer + ..write(modTime) + ..write('\t') + ..writeln(path); + } + await file.writeAsString(buffer.toString(), flush: true); + return file.path; + } + /// Update file_mod_time for existing rows using file_path as key. Future updateFileModTimes(Map fileModTimes) async { if (fileModTimes.isEmpty) return; @@ -496,7 +521,6 @@ class LibraryDatabase { return rows.map((r) => r['file_path'] as String).toSet(); } - /// Delete multiple items by their file paths Future deleteByPaths(List filePaths) async { if (filePaths.isEmpty) return 0; final db = await database; diff --git a/lib/services/local_track_redownload_service.dart b/lib/services/local_track_redownload_service.dart new file mode 100644 index 00000000..d03c6c8e --- /dev/null +++ b/lib/services/local_track_redownload_service.dart @@ -0,0 +1,347 @@ +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +class LocalTrackRedownloadResolution { + final LocalLibraryItem localItem; + final Track? match; + final int score; + final String reason; + + const LocalTrackRedownloadResolution({ + required this.localItem, + required this.match, + required this.score, + required this.reason, + }); + + bool get canQueue => match != null; +} + +class LocalTrackRedownloadService { + static const int _minimumConfidenceScore = 85; + static const int _ambiguousScoreGap = 8; + + static bool isFlacUpgradeEligible(LocalLibraryItem item) { + final format = item.format?.trim().toLowerCase(); + if (format == 'flac') { + return false; + } + + return !item.filePath.toLowerCase().endsWith('.flac'); + } + + static Future resolveBestMatch( + LocalLibraryItem item, { + required bool includeExtensions, + }) async { + final query = _buildSearchQuery(item); + final rawResults = await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 10, + includeExtensions: includeExtensions, + ); + + if (rawResults.isEmpty) { + return LocalTrackRedownloadResolution( + localItem: item, + match: null, + score: 0, + reason: 'No candidates found', + ); + } + + final scored = + rawResults + .map( + (raw) => ( + track: _parseSearchTrack(raw), + score: _scoreMatch(item, raw), + ), + ) + .where((entry) => entry.track.name.trim().isNotEmpty) + .toList(growable: false) + ..sort((a, b) => b.score.compareTo(a.score)); + + if (scored.isEmpty) { + return LocalTrackRedownloadResolution( + localItem: item, + match: null, + score: 0, + reason: 'No usable candidates found', + ); + } + + final best = scored.first; + final runnerUp = scored.length > 1 ? scored[1] : null; + final exactIsrc = + _normalizedIsrc(item.isrc) != null && + _normalizedIsrc(item.isrc) == _normalizedIsrc(best.track.isrc); + final isAmbiguous = + !exactIsrc && + runnerUp != null && + best.score < (_minimumConfidenceScore + 10) && + (best.score - runnerUp.score) <= _ambiguousScoreGap; + + if (!exactIsrc && (best.score < _minimumConfidenceScore || isAmbiguous)) { + return LocalTrackRedownloadResolution( + localItem: item, + match: null, + score: best.score, + reason: isAmbiguous ? 'Ambiguous match' : 'Low-confidence match', + ); + } + + return LocalTrackRedownloadResolution( + localItem: item, + match: best.track, + score: best.score, + reason: exactIsrc ? 'Exact ISRC match' : 'High-confidence metadata match', + ); + } + + static String preferredFlacService(AppSettings settings) { + switch (settings.defaultService.toLowerCase()) { + case 'tidal': + case 'qobuz': + case 'deezer': + return settings.defaultService.toLowerCase(); + default: + return 'tidal'; + } + } + + static String preferredFlacQualityForService(String service) { + return service.toLowerCase() == 'deezer' ? 'FLAC' : 'LOSSLESS'; + } + + static String _buildSearchQuery(LocalLibraryItem item) { + final artist = _primaryArtist(item.artistName); + final album = item.albumName.trim(); + if (album.isNotEmpty && album.toLowerCase() != 'unknown album') { + return '${item.trackName} $artist $album'.trim(); + } + return '${item.trackName} $artist'.trim(); + } + + static Track _parseSearchTrack(Map data) { + final durationMs = _extractDurationMs(data); + final itemType = data['item_type']?.toString(); + + 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(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.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(), + totalTracks: data['total_tracks'] as int?, + source: data['source']?.toString() ?? data['provider_id']?.toString(), + albumType: data['album_type']?.toString(), + itemType: itemType, + ); + } + + static int _extractDurationMs(Map data) { + final durationMsRaw = data['duration_ms']; + if (durationMsRaw is num && durationMsRaw > 0) { + return durationMsRaw.toInt(); + } + if (durationMsRaw is String) { + final parsed = num.tryParse(durationMsRaw.trim()); + if (parsed != null && parsed > 0) { + return parsed.toInt(); + } + } + + final durationSecRaw = data['duration']; + if (durationSecRaw is num && durationSecRaw > 0) { + return (durationSecRaw * 1000).toInt(); + } + if (durationSecRaw is String) { + final parsed = num.tryParse(durationSecRaw.trim()); + if (parsed != null && parsed > 0) { + return (parsed * 1000).toInt(); + } + } + + return 0; + } + + static int _scoreMatch(LocalLibraryItem item, Map raw) { + final track = _parseSearchTrack(raw); + var score = 0; + + final localIsrc = _normalizedIsrc(item.isrc); + final candidateIsrc = _normalizedIsrc(track.isrc); + if (localIsrc != null && candidateIsrc != null) { + score += localIsrc == candidateIsrc ? 140 : -120; + } + + final localTitle = _normalizedTitle(item.trackName); + final candidateTitle = _normalizedTitle(track.name); + if (localTitle == candidateTitle) { + score += 45; + } else if (_tokenOverlap(localTitle, candidateTitle) >= 0.75) { + score += 24; + } else { + score -= 25; + } + + final localArtist = _normalizedArtistGroup(item.artistName); + final candidateArtist = _normalizedArtistGroup(track.artistName); + final artistOverlap = _tokenOverlap(localArtist, candidateArtist); + if (localArtist == candidateArtist) { + score += 30; + } else if (artistOverlap >= 0.6) { + score += 16; + } else { + score -= 20; + } + + final localAlbum = _normalizedText(item.albumName); + final candidateAlbum = _normalizedText(track.albumName); + if (localAlbum.isNotEmpty && candidateAlbum.isNotEmpty) { + if (localAlbum == candidateAlbum) { + score += 12; + } else if (_tokenOverlap(localAlbum, candidateAlbum) >= 0.7) { + score += 6; + } + } + + final localDuration = item.duration ?? 0; + final candidateDuration = track.duration; + if (localDuration > 0 && candidateDuration > 0) { + final diff = (localDuration - candidateDuration).abs(); + if (diff <= 2) { + score += 20; + } else if (diff <= 5) { + score += 12; + } else if (diff <= 10) { + score += 5; + } else if (diff > 20) { + score -= 30; + } + } + + if (item.trackNumber != null && + track.trackNumber != null && + item.trackNumber == track.trackNumber) { + score += 6; + } + if (item.discNumber != null && + track.discNumber != null && + item.discNumber == track.discNumber) { + score += 4; + } + + final localYear = _extractYear(item.releaseDate); + final candidateYear = _extractYear(track.releaseDate); + if (localYear != null && + candidateYear != null && + localYear == candidateYear) { + score += 4; + } + + score += _versionPenalty(item.trackName, track.name); + return score; + } + + static String? _normalizedIsrc(String? value) { + final normalized = value?.trim().toUpperCase(); + if (normalized == null || normalized.isEmpty) { + return null; + } + return normalized; + } + + static String _normalizedTitle(String value) { + final cleaned = _normalizedText(value) + .replaceAll(RegExp(r'\b(feat|ft|featuring)\b.*$'), ' ') + .replaceAll(RegExp(r'\b(remaster(?:ed)?|deluxe|bonus)\b'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return cleaned; + } + + static String _normalizedArtistGroup(String value) { + return _normalizedText( + value + .replaceAll(RegExp(r'\b(feat|ft|featuring|with|x)\b'), ',') + .replaceAll('&', ','), + ); + } + + static String _primaryArtist(String value) { + final parts = _normalizedArtistGroup( + value, + ).split(',').map((part) => part.trim()).where((part) => part.isNotEmpty); + return parts.isEmpty ? value.trim() : parts.first; + } + + static String _normalizedText(String value) { + return value + .toLowerCase() + .replaceAll(RegExp(r'[\(\)\[\]\{\}]'), ' ') + .replaceAll(RegExp(r'[^a-z0-9, ]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } + + static double _tokenOverlap(String left, String right) { + final leftTokens = left + .split(RegExp(r'[\s,]+')) + .where((token) => token.isNotEmpty) + .toSet(); + final rightTokens = right + .split(RegExp(r'[\s,]+')) + .where((token) => token.isNotEmpty) + .toSet(); + if (leftTokens.isEmpty || rightTokens.isEmpty) { + return 0; + } + final intersection = leftTokens.intersection(rightTokens).length; + final denominator = leftTokens.length > rightTokens.length + ? leftTokens.length + : rightTokens.length; + return intersection / denominator; + } + + static int _versionPenalty(String localTitle, String candidateTitle) { + const riskyMarkers = [ + 'live', + 'karaoke', + 'instrumental', + 'acoustic', + 'radio edit', + 'sped up', + 'slowed', + ]; + final local = _normalizedText(localTitle); + final candidate = _normalizedText(candidateTitle); + var penalty = 0; + for (final marker in riskyMarkers) { + final localHas = local.contains(marker); + final candidateHas = candidate.contains(marker); + if (!localHas && candidateHas) { + penalty -= 18; + } + } + return penalty; + } + + static int? _extractYear(String? date) { + if (date == null || date.length < 4) { + return null; + } + return int.tryParse(date.substring(0, 4)); + } +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 86ae6d0d..e2c1df83 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -14,57 +15,17 @@ class PlatformBridge { 'com.zarz.spotiflac/library_scan_progress_stream', ); + static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS; + + static bool get supportsExtensionSystem => + Platform.isAndroid || Platform.isIOS; + static Future> parseSpotifyUrl(String url) async { _log.d('parseSpotifyUrl: $url'); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); return jsonDecode(result as String) as Map; } - static Future> getSpotifyMetadata(String url) async { - _log.d('getSpotifyMetadata: $url'); - final result = await _channel.invokeMethod('getSpotifyMetadata', { - 'url': url, - }); - return jsonDecode(result as String) as Map; - } - - static Future> searchSpotify( - String query, { - int limit = 10, - }) async { - _log.d('searchSpotify: "$query" (limit: $limit)'); - final result = await _channel.invokeMethod('searchSpotify', { - 'query': query, - 'limit': limit, - }); - return jsonDecode(result as String) as Map; - } - - static Future> searchSpotifyAll( - String query, { - int trackLimit = 15, - int artistLimit = 3, - }) async { - _log.d('searchSpotifyAll: "$query"'); - final result = await _channel.invokeMethod('searchSpotifyAll', { - 'query': query, - 'track_limit': trackLimit, - 'artist_limit': artistLimit, - }); - return jsonDecode(result as String) as Map; - } - - static Future> getSpotifyRelatedArtists( - String artistId, { - int limit = 12, - }) async { - final result = await _channel.invokeMethod('getSpotifyRelatedArtists', { - 'artist_id': artistId, - 'limit': limit, - }); - return jsonDecode(result as String) as Map; - } - static Future> checkAvailability( String spotifyId, String isrc, @@ -517,21 +478,6 @@ class PlatformBridge { return result as bool; } - static Future setSpotifyCredentials( - String clientId, - String clientSecret, - ) async { - await _channel.invokeMethod('setSpotifyCredentials', { - 'client_id': clientId, - 'client_secret': clientSecret, - }); - } - - static Future hasSpotifyCredentials() async { - final result = await _channel.invokeMethod('hasSpotifyCredentials'); - return result as bool; - } - static Future preWarmTrackCache( List> tracks, ) async { @@ -563,6 +509,36 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> searchTidalAll( + String query, { + int trackLimit = 15, + int artistLimit = 2, + String? filter, + }) async { + final result = await _channel.invokeMethod('searchTidalAll', { + 'query': query, + 'track_limit': trackLimit, + 'artist_limit': artistLimit, + 'filter': filter ?? '', + }); + return jsonDecode(result as String) as Map; + } + + static Future> searchQobuzAll( + String query, { + int trackLimit = 15, + int artistLimit = 2, + String? filter, + }) async { + final result = await _channel.invokeMethod('searchQobuzAll', { + 'query': query, + 'track_limit': trackLimit, + 'artist_limit': artistLimit, + 'filter': filter ?? '', + }); + return jsonDecode(result as String) as Map; + } + static Future> getDeezerRelatedArtists( String artistId, { int limit = 12, @@ -595,11 +571,48 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> getQobuzMetadata( + String resourceType, + String resourceId, + ) async { + final result = await _channel.invokeMethod('getQobuzMetadata', { + 'resource_type': resourceType, + 'resource_id': resourceId, + }); + if (result == null) { + throw Exception( + 'getQobuzMetadata returned null for $resourceType:$resourceId', + ); + } + return jsonDecode(result as String) as Map; + } + + static Future> parseQobuzUrl(String url) async { + final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url}); + return jsonDecode(result as String) as Map; + } + static Future> parseTidalUrl(String url) async { final result = await _channel.invokeMethod('parseTidalUrl', {'url': url}); return jsonDecode(result as String) as Map; } + static Future> getTidalMetadata( + String resourceType, + String resourceId, + ) async { + final result = await _channel.invokeMethod('getTidalMetadata', { + 'resource_type': resourceType, + 'resource_id': resourceId, + }); + if (result == null) { + throw Exception( + 'getTidalMetadata returned null for $resourceType:$resourceId', + ); + } + return jsonDecode(result as String) as Map; + } + static Future> convertTidalToSpotifyDeezer( String tidalUrl, ) async { @@ -839,6 +852,22 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } + static Future>> searchTracksWithMetadataProviders( + String query, { + int limit = 20, + bool includeExtensions = true, + }) async { + _log.d( + 'searchTracksWithMetadataProviders: "$query", includeExtensions=$includeExtensions', + ); + final result = await _channel.invokeMethod( + 'searchTracksWithMetadataProviders', + {'query': query, 'limit': limit, 'include_extensions': includeExtensions}, + ); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + static Future cleanupExtensions() async { _log.d('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions'); @@ -1097,6 +1126,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> scanLibraryFolderIncrementalFromSnapshot( + String folderPath, + String snapshotPath, + ) async { + final result = await _channel.invokeMethod( + 'scanLibraryFolderIncrementalFromSnapshot', + {'folder_path': folderPath, 'snapshot_path': snapshotPath}, + ); + return jsonDecode(result as String) as Map; + } + static Future>> scanSafTree(String treeUri) async { _log.i('scanSafTree: $treeUri'); final result = await _channel.invokeMethod('scanSafTree', { @@ -1122,6 +1162,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> scanSafTreeIncrementalFromSnapshot( + String treeUri, + String snapshotPath, + ) async { + final result = await _channel.invokeMethod( + 'scanSafTreeIncrementalFromSnapshot', + {'tree_uri': treeUri, 'snapshot_path': snapshotPath}, + ); + return jsonDecode(result as String) as Map; + } + /// Get last-modified timestamps for a list of SAF file URIs. /// Returns map uri -> modTime (unix millis), only for files that still exist. static Future> getSafFileModTimes(List uris) async { @@ -1251,6 +1302,24 @@ class PlatformBridge { await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); } + static Future setStoreRegistryUrl(String registryUrl) async { + _log.d('setStoreRegistryUrl: $registryUrl'); + await _channel.invokeMethod('setStoreRegistryUrl', { + 'registry_url': registryUrl, + }); + } + + static Future getStoreRegistryUrl() async { + _log.d('getStoreRegistryUrl'); + final result = await _channel.invokeMethod('getStoreRegistryUrl'); + return result as String? ?? ''; + } + + static Future clearStoreRegistryUrl() async { + _log.d('clearStoreRegistryUrl'); + await _channel.invokeMethod('clearStoreRegistryUrl'); + } + static Future>> getStoreExtensions({ bool forceRefresh = false, }) async { @@ -1298,4 +1367,19 @@ class PlatformBridge { await _channel.invokeMethod('clearStoreCache'); } + /// Parse a .cue file and return split information (track listing, timing, metadata). + /// Returns a map with: cue_path, audio_path, album, artist, genre, date, tracks[] + /// Each track has: number, title, artist, isrc, composer, start_sec, end_sec + /// [audioDir] optionally overrides the directory for audio file resolution (used for SAF). + static Future> parseCueSheet( + String cuePath, { + String audioDir = '', + }) async { + _log.i('parseCueSheet: $cuePath (audioDir: $audioDir)'); + final result = await _channel.invokeMethod('parseCueSheet', { + 'cue_path': cuePath, + 'audio_dir': audioDir, + }); + return jsonDecode(result as String) as Map; + } } diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 4f8867b1..e958394f 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -10,8 +11,9 @@ class ShareIntentService { ShareIntentService._internal(); // Spotify patterns - static final RegExp _spotifyUriPattern = - RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+'); + static final RegExp _spotifyUriPattern = RegExp( + r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+', + ); static final RegExp _spotifyUrlPattern = RegExp( r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?', ); @@ -56,6 +58,11 @@ class ShareIntentService { if (_initialized) return; _initialized = true; + if (!Platform.isAndroid && !Platform.isIOS) { + _log.i('Share intent is not supported on this platform'); + return; + } + _mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen( _handleSharedMedia, onError: (err) => _log.e('Error: $err'), @@ -68,14 +75,14 @@ class ShareIntentService { } } - void _handleSharedMedia(List files, {bool isInitial = false}) { + void _handleSharedMedia( + List files, { + bool isInitial = false, + }) { for (final file in files) { // Check both path and message - apps may share URL in either field - final textsToCheck = [ - file.path, - if (file.message != null) file.message!, - ]; - + final textsToCheck = [file.path, if (file.message != null) file.message!]; + for (final textToCheck in textsToCheck) { final url = _extractMusicUrl(textToCheck); if (url != null) { diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 3a2fb50f..c24b47f0 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -24,20 +25,28 @@ class UpdateInfo { } class UpdateChecker { - static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; - static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; + static const String _latestApiUrl = + 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; + static const String _allReleasesApiUrl = + 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; /// Check for updates based on channel preference /// [channel] can be 'stable' or 'preview' static Future checkForUpdate({String channel = 'stable'}) async { + if (!Platform.isAndroid) { + return null; + } + try { Map? releaseData; - + if (channel == 'preview') { - final response = await http.get( - Uri.parse('$_allReleasesApiUrl?per_page=10'), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ).timeout(const Duration(seconds: 10)); + final response = await http + .get( + Uri.parse('$_allReleasesApiUrl?per_page=10'), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ) + .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { _log.w('GitHub API returned ${response.statusCode}'); @@ -49,13 +58,15 @@ class UpdateChecker { _log.i('No releases found'); return null; } - + releaseData = releases.first as Map; } else { - final response = await http.get( - Uri.parse(_latestApiUrl), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ).timeout(const Duration(seconds: 10)); + final response = await http + .get( + Uri.parse(_latestApiUrl), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ) + .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { _log.w('GitHub API returned ${response.statusCode}'); @@ -68,19 +79,24 @@ class UpdateChecker { final tagName = releaseData['tag_name'] as String? ?? ''; final latestVersion = tagName.replaceFirst('v', ''); final isPrerelease = releaseData['prerelease'] as bool? ?? false; - + if (!_isNewerVersion(latestVersion, AppInfo.version)) { - _log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)'); + _log.i( + 'No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)', + ); return null; } final body = releaseData['body'] as String? ?? 'No changelog available'; - final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; - final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); + final htmlUrl = + releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; + final publishedAt = + DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? + DateTime.now(); String? arm64Url; String? universalUrl; - + final assets = releaseData['assets'] as List? ?? []; for (final asset in assets) { final name = (asset['name'] as String? ?? '').toLowerCase(); @@ -98,12 +114,14 @@ class UpdateChecker { } } } - + // Only arm64 is supported; fall back to universal if available final apkUrl = arm64Url ?? universalUrl; - _log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl'); - + _log.i( + 'Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl', + ); + return UpdateInfo( version: latestVersion, changelog: body, @@ -122,7 +140,7 @@ class UpdateChecker { try { final latestBase = latest.split('-').first; final currentBase = current.split('-').first; - + final latestParts = latestBase.split('.').map(int.parse).toList(); final currentParts = currentBase.split('.').map(int.parse).toList(); @@ -137,12 +155,12 @@ class UpdateChecker { if (latestParts[i] > currentParts[i]) return true; if (latestParts[i] < currentParts[i]) return false; } - + final latestHasSuffix = latest.contains('-'); final currentHasSuffix = current.contains('-'); - + if (!latestHasSuffix && currentHasSuffix) return true; - + return false; } catch (e) { _log.e('Error comparing versions: $e'); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 2aba9e85..c13b54ee 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -35,7 +35,6 @@ class AppTheme { ); } - /// Create dark theme static ThemeData dark({ ColorScheme? dynamicScheme, Color? seedColor, @@ -88,12 +87,11 @@ class AppTheme { ), ); - /// Card theme static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - ), // 12 -> 16 + ), color: scheme.surfaceContainerLow, surfaceTintColor: scheme.surfaceTint, ); @@ -104,18 +102,17 @@ class AppTheme { elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - ), // 20 -> 16 + ), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ); - /// Filled button theme static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) => FilledButtonThemeData( style: FilledButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - ), // 20 -> 16 + ), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ); @@ -125,18 +122,17 @@ class AppTheme { style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - ), // 20 -> 16 + ), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ); - /// Text button theme static TextButtonThemeData _textButtonTheme(ColorScheme scheme) => TextButtonThemeData( style: TextButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - ), // 20 -> 16 + ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), ); @@ -149,40 +145,39 @@ class AppTheme { foregroundColor: scheme.onPrimaryContainer, ); - /// Input decoration theme static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) => InputDecorationTheme( filled: true, fillColor: scheme.surfaceContainerHighest.withValues( alpha: 0.3, - ), // Added transparency + ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), // 12 -> 16 + borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), // 12 -> 16 + borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), // 12 -> 16 + borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: scheme.primary, width: 2), ), errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), // 12 -> 16 + borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: scheme.error, width: 1), ), contentPadding: const EdgeInsets.symmetric( horizontal: 20, vertical: 16, - ), // consistent padding + ), ); static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - ), // 12 -> 16 + ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ); @@ -193,7 +188,6 @@ class AppTheme { surfaceTintColor: scheme.surfaceTint, ); - /// Navigation bar theme static NavigationBarThemeData _navigationBarTheme( ColorScheme scheme, { bool isAmoled = false, @@ -213,7 +207,6 @@ class AppTheme { contentTextStyle: TextStyle(color: scheme.onInverseSurface), ); - /// Progress indicator theme static ProgressIndicatorThemeData _progressIndicatorTheme( ColorScheme scheme, ) => ProgressIndicatorThemeData( @@ -243,7 +236,6 @@ class AppTheme { }), ); - /// Chip theme static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), backgroundColor: scheme.surfaceContainerLow, diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index 6ee84b86..77237502 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -4,7 +4,6 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; -/// Wrapper widget that provides dynamic color support from device wallpaper class DynamicColorWrapper extends ConsumerWidget { final Widget Function(ThemeData light, ThemeData dark, ThemeMode mode) builder; @@ -23,7 +22,6 @@ class DynamicColorWrapper extends ConsumerWidget { ColorScheme darkScheme; if (themeSettings.useDynamicColor && lightDynamic != null && darkDynamic != null) { - // Use dynamic colors from wallpaper (Android 12+) lightScheme = lightDynamic; darkScheme = darkDynamic; } else { @@ -38,7 +36,6 @@ class DynamicColorWrapper extends ConsumerWidget { ); } - // Apply AMOLED mode if enabled (pure black background) if (themeSettings.useAmoled) { darkScheme = _applyAmoledColors(darkScheme); } diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index 1dd4f1fc..f686bc46 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -20,6 +20,22 @@ final _iosLegacyRelativeDocumentsPattern = RegExp( r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$', caseSensitive: false, ); +final _iosNestedLegacyDocumentsPattern = RegExp( + r'/Documents/Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$', + caseSensitive: false, +); + +String _normalizeRecoveredIosSuffix(String suffix) { + final trimmed = suffix.trim(); + if (trimmed.isEmpty) return ''; + return trimmed.startsWith('/') ? trimmed.substring(1) : trimmed; +} + +String _joinRecoveredIosPath(String documentsPath, String suffix) { + final normalizedSuffix = _normalizeRecoveredIosSuffix(suffix); + if (normalizedSuffix.isEmpty) return documentsPath; + return '$documentsPath/$normalizedSuffix'; +} /// Checks if a path is a valid writable directory on iOS. /// Returns false if: @@ -43,6 +59,12 @@ bool isValidIosWritablePath(String path) { return false; } + // Reject stale paths where an old sandbox container path has been embedded + // inside the current Documents directory. + if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) { + return false; + } + // Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.) // This handles cases where FilePicker returns container root final containerPattern = RegExp( @@ -70,11 +92,19 @@ Future validateOrFixIosPath( if (!Platform.isIOS) return path; final trimmed = path.trim(); + final docDir = await getApplicationDocumentsDirectory(); + + final nestedLegacyMatch = _iosNestedLegacyDocumentsPattern.firstMatch( + trimmed, + ); + if (nestedLegacyMatch != null) { + return _joinRecoveredIosPath(docDir.path, nestedLegacyMatch.group(1) ?? ''); + } + if (isValidIosWritablePath(trimmed)) { return trimmed; } - final docDir = await getApplicationDocumentsDirectory(); final candidates = []; if (trimmed.isNotEmpty) { @@ -92,14 +122,8 @@ Future validateOrFixIosPath( trimmed, ); if (legacyRelativeMatch != null) { - final suffix = (legacyRelativeMatch.group(1) ?? '').trim(); - final normalizedSuffix = suffix.startsWith('/') - ? suffix.substring(1) - : suffix; candidates.add( - normalizedSuffix.isEmpty - ? docDir.path - : '${docDir.path}/$normalizedSuffix', + _joinRecoveredIosPath(docDir.path, legacyRelativeMatch.group(1) ?? ''), ); } @@ -109,7 +133,7 @@ Future validateOrFixIosPath( final index = trimmed.indexOf(documentsMarker); if (index >= 0) { final suffix = trimmed.substring(index + documentsMarker.length).trim(); - candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix'); + candidates.add(_joinRecoveredIosPath(docDir.path, suffix)); } } @@ -181,6 +205,14 @@ IosPathValidationResult validateIosPath(String path) { ); } + if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) { + return const IosPathValidationResult( + isValid: false, + errorReason: + 'Invalid iOS app folder path. Please choose App Documents or another local folder.', + ); + } + // Check for container root without subdirectory final containerPattern = RegExp( r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+', @@ -212,16 +244,39 @@ bool isContentUri(String? path) { return path != null && path.startsWith('content://'); } +/// Pattern matching CUE virtual path suffixes like #track01, #track12, etc. +final _cueTrackSuffix = RegExp(r'#track\d+$'); + +const cueVirtualTrackRequiresSplitMessage = + 'This CUE track is virtual. Use Split into Tracks first.'; + +/// Whether the path is a CUE virtual path (contains #trackNN suffix). +bool isCueVirtualPath(String? path) { + return path != null && _cueTrackSuffix.hasMatch(path); +} + +/// Strip the #trackNN suffix from a CUE virtual path to get the base .cue path. +/// Returns the path unchanged if it's not a CUE virtual path. +String stripCueTrackSuffix(String path) { + return path.replaceFirst(_cueTrackSuffix, ''); +} + Future fileExists(String? path) async { if (path == null || path.isEmpty) return false; - if (isContentUri(path)) { - return PlatformBridge.safExists(path); + // For CUE virtual paths, check if the base .cue file exists + final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path; + if (isContentUri(realPath)) { + return PlatformBridge.safExists(realPath); } - return File(path).exists(); + return File(realPath).exists(); } Future deleteFile(String? path) async { if (path == null || path.isEmpty) return; + // CUE virtual paths should NOT be deleted through this function — + // deleting album.cue would remove ALL tracks. Callers should handle + // CUE deletion specially (e.g. only delete when all tracks are removed). + if (isCueVirtualPath(path)) return; if (isContentUri(path)) { await PlatformBridge.safDelete(path); return; @@ -233,8 +288,10 @@ Future deleteFile(String? path) async { Future fileStat(String? path) async { if (path == null || path.isEmpty) return null; - if (isContentUri(path)) { - final stat = await PlatformBridge.safStat(path); + // For CUE virtual paths, stat the base .cue file + final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path; + if (isContentUri(realPath)) { + final stat = await PlatformBridge.safStat(realPath); final exists = stat['exists'] as bool? ?? true; if (!exists) return null; return FileAccessStat( @@ -245,18 +302,23 @@ Future fileStat(String? path) async { ); } - final stat = await FileStat.stat(path); + final stat = await FileStat.stat(realPath); if (stat.type == FileSystemEntityType.notFound) return null; return FileAccessStat(size: stat.size, modified: stat.modified); } Future openFile(String path) async { - if (isContentUri(path)) { - await PlatformBridge.openContentUri(path, mimeType: ''); + if (isCueVirtualPath(path)) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } + + final realPath = path; + if (isContentUri(realPath)) { + await PlatformBridge.openContentUri(realPath, mimeType: ''); return; } - final mimeType = audioMimeTypeForPath(path); - final result = await OpenFilex.open(path, type: mimeType); + final mimeType = audioMimeTypeForPath(realPath); + final result = await OpenFilex.open(realPath, type: mimeType); if (result.type != ResultType.done) { throw Exception(result.message); } diff --git a/lib/utils/local_library_scan_prefs.dart b/lib/utils/local_library_scan_prefs.dart new file mode 100644 index 00000000..1d544f8d --- /dev/null +++ b/lib/utils/local_library_scan_prefs.dart @@ -0,0 +1,29 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +const localLibraryLastScannedAtKey = 'local_library_last_scanned_at'; + +DateTime? readLocalLibraryLastScannedAt(SharedPreferences prefs) { + final lastScannedAtStr = prefs.getString(localLibraryLastScannedAtKey); + if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) { + return DateTime.tryParse(lastScannedAtStr); + } + + // Backward compatibility for older builds that may have stored epoch millis. + final lastScannedAtMs = prefs.getInt(localLibraryLastScannedAtKey); + if (lastScannedAtMs != null) { + return DateTime.fromMillisecondsSinceEpoch(lastScannedAtMs); + } + + return null; +} + +Future writeLocalLibraryLastScannedAt( + SharedPreferences prefs, + DateTime value, +) { + return prefs.setString(localLibraryLastScannedAtKey, value.toIso8601String()); +} + +Future clearLocalLibraryLastScannedAt(SharedPreferences prefs) { + return prefs.remove(localLibraryLastScannedAtKey); +} diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart index 2e17c397..c20eb411 100644 --- a/lib/utils/lyrics_metadata_helper.dart +++ b/lib/utils/lyrics_metadata_helper.dart @@ -74,3 +74,38 @@ Future ensureLyricsMetadataForConversion({ metadata['LYRICS'] = lyrics; metadata['UNSYNCEDLYRICS'] = lyrics; } + +void mergePlatformMetadataForTagEmbed({ + required Map target, + required Map source, +}) { + void put(String key, dynamic value) { + final normalized = value?.toString().trim(); + if (normalized == null || normalized.isEmpty) return; + target[key] = normalized; + } + + put('TITLE', source['title']); + put('ARTIST', source['artist']); + put('ALBUM', source['album']); + put('ALBUMARTIST', source['album_artist']); + put('DATE', source['date']); + put('ISRC', source['isrc']); + put('GENRE', source['genre']); + put('ORGANIZATION', source['label']); + put('COPYRIGHT', source['copyright']); + put('COMPOSER', source['composer']); + put('COMMENT', source['comment']); + put('LYRICS', source['lyrics']); + put('UNSYNCEDLYRICS', source['lyrics']); + + final trackNumber = source['track_number']; + if (trackNumber != null && trackNumber.toString() != '0') { + put('TRACKNUMBER', trackNumber); + } + + final discNumber = source['disc_number']; + if (discNumber != null && discNumber.toString() != '0') { + put('DISCNUMBER', discNumber); + } +} diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart new file mode 100644 index 00000000..0df1c023 --- /dev/null +++ b/lib/utils/path_match_keys.dart @@ -0,0 +1,143 @@ +import 'dart:io'; + +const _androidStoragePathAliases = [ + '/storage/emulated/0', + '/storage/emulated/legacy', + '/storage/self/primary', + '/sdcard', + '/mnt/sdcard', +]; + +/// Audio file extensions that the app commonly produces or converts between. +/// Used to generate extension-stripped match keys so that a file converted from +/// one format to another (e.g. .flac → .opus) is still recognised as the same +/// track. +const _audioExtensions = [ + '.flac', + '.m4a', + '.mp3', + '.opus', + '.ogg', + '.wav', + '.aac', +]; + +/// Strips a trailing audio extension from [path] if present. +/// Returns the path without extension, or `null` if no known audio extension +/// was found. +String? _stripAudioExtension(String path) { + final lower = path.toLowerCase(); + for (final ext in _audioExtensions) { + if (lower.endsWith(ext)) { + return path.substring(0, path.length - ext.length); + } + } + return null; +} + +Set buildPathMatchKeys(String? filePath) { + final raw = filePath?.trim() ?? ''; + if (raw.isEmpty) return const {}; + + final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw; + if (cleaned.isEmpty) return const {}; + + final keys = {}; + final visited = {}; + + void addNormalized(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return; + if (!visited.add(trimmed)) return; + + keys.add(trimmed); + keys.add(trimmed.toLowerCase()); + + if (trimmed.contains('\\')) { + final slash = trimmed.replaceAll('\\', '/'); + if (slash != trimmed) { + addNormalized(slash); + } + } + + if (trimmed.contains('%')) { + try { + final decoded = Uri.decodeFull(trimmed); + if (decoded != trimmed) { + addNormalized(decoded); + } + } catch (_) {} + } + + Uri? parsed; + try { + parsed = Uri.parse(trimmed); + } catch (_) {} + + if (parsed != null && parsed.hasScheme) { + final withoutQueryOrFragment = parsed.replace( + query: null, + fragment: null, + ); + final uriString = withoutQueryOrFragment.toString(); + keys.add(uriString); + keys.add(uriString.toLowerCase()); + + if (parsed.scheme == 'file') { + try { + addNormalized(parsed.toFilePath()); + } catch (_) {} + } + } else if (trimmed.startsWith('/')) { + try { + final asFileUri = Uri.file(trimmed).toString(); + keys.add(asFileUri); + keys.add(asFileUri.toLowerCase()); + } catch (_) {} + } + + if (Platform.isAndroid) { + for (final alias in _androidEquivalentPaths(trimmed)) { + if (alias != trimmed) { + addNormalized(alias); + } + } + } + } + + addNormalized(cleaned); + + // Add extension-stripped variants so that a file converted from one audio + // format to another (e.g. Song.flac → Song.opus) still matches. + final extensionStrippedKeys = {}; + for (final key in keys) { + final stripped = _stripAudioExtension(key); + if (stripped != null && stripped.isNotEmpty) { + extensionStrippedKeys.add(stripped); + } + } + keys.addAll(extensionStrippedKeys); + + return keys; +} + +Iterable _androidEquivalentPaths(String path) { + final normalized = path.replaceAll('\\', '/'); + final lower = normalized.toLowerCase(); + String? suffix; + + for (final prefix in _androidStoragePathAliases) { + if (lower == prefix) { + suffix = ''; + break; + } + final withSlash = '$prefix/'; + if (lower.startsWith(withSlash)) { + suffix = normalized.substring(prefix.length); + break; + } + } + + if (suffix == null) return const []; + return _androidStoragePathAliases.map((prefix) => '$prefix$suffix'); +} diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart index 9f4cc164..2e2b704c 100644 --- a/lib/utils/string_utils.dart +++ b/lib/utils/string_utils.dart @@ -6,6 +6,41 @@ String? normalizeOptionalString(String? value) { return trimmed; } +final RegExp _windowsAbsolutePathPattern = RegExp(r'^[A-Za-z]:[\\/]'); + +bool _looksLikeLocalReference(String value) { + return value.startsWith('/') || + value.startsWith('content://') || + value.startsWith('file://') || + _windowsAbsolutePathPattern.hasMatch(value); +} + +String? normalizeCoverReference(String? value) { + final normalized = normalizeOptionalString(value); + if (normalized == null) return null; + + if (normalized.startsWith('//')) { + return 'https:$normalized'; + } + + if (normalized.startsWith('http://') || + normalized.startsWith('https://') || + _looksLikeLocalReference(normalized)) { + return normalized; + } + + return null; +} + +String? normalizeRemoteHttpUrl(String? value) { + final normalized = normalizeCoverReference(value); + if (normalized == null) return null; + if (normalized.startsWith('http://') || normalized.startsWith('https://')) { + return normalized; + } + return null; +} + String formatSampleRateKHz(int sampleRate) { final khz = sampleRate / 1000; final precision = sampleRate % 1000 == 0 ? 0 : 1; diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 69eb4f1d..523970b9 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -22,8 +22,7 @@ class BuiltInService { }); } -/// Default quality options for built-in services -/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads +/// Default quality options for each built-in service const _builtInServices = [ BuiltInService( id: 'tidal', @@ -73,8 +72,8 @@ const _builtInServices = [ qualityOptions: [ QualityOption( id: 'FLAC', - label: 'FLAC Lossless', - description: '16-bit / 44.1kHz (CD Quality)', + label: 'FLAC Best Quality', + description: 'Up to 24-bit / 48kHz+', ), ], ), @@ -83,9 +82,9 @@ const _builtInServices = [ label: 'YouTube', qualityOptions: [ QualityOption( - id: 'opus_256', - label: 'Opus 256kbps', - description: 'Best quality lossy (~8MB per track)', + id: 'opus_320', + label: 'Opus 320kbps', + description: 'Best quality lossy (~10MB per track)', ), QualityOption( id: 'mp3_320', @@ -98,12 +97,12 @@ const _builtInServices = [ ), ]; -/// A reusable widget for selecting download service (built-in + extensions) class DownloadServicePicker extends ConsumerStatefulWidget { final String? trackName; final String? artistName; final String? coverUrl; final void Function(String quality, String service) onSelect; + final String? recommendedService; // Service to show as "(Recommended)" const DownloadServicePicker({ super.key, @@ -111,6 +110,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { this.artistName, this.coverUrl, required this.onSelect, + this.recommendedService, }); @override @@ -123,6 +123,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { String? trackName, String? artistName, String? coverUrl, + String? recommendedService, required void Function(String quality, String service) onSelect, }) { final colorScheme = Theme.of(context).colorScheme; @@ -140,13 +141,14 @@ class DownloadServicePicker extends ConsumerStatefulWidget { artistName: artistName, coverUrl: coverUrl, onSelect: onSelect, + recommendedService: recommendedService, ), ); } } class _DownloadServicePickerState extends ConsumerState { - static const List _youtubeOpusSupportedBitrates = [128, 256]; + static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; late String _selectedService; @@ -154,7 +156,13 @@ class _DownloadServicePickerState extends ConsumerState { @override void initState() { super.initState(); - _selectedService = ref.read(settingsProvider).defaultService; + // Default to recommended service if available, otherwise use default + final recommended = widget.recommendedService; + if (recommended != null && recommended.isNotEmpty) { + _selectedService = recommended; + } else { + _selectedService = ref.read(settingsProvider).defaultService; + } } /// Get quality options for the selected service @@ -284,6 +292,8 @@ class _DownloadServicePickerState extends ConsumerState { _ServiceChip( label: service.isDisabled ? '${service.label} (${service.disabledReason})' + : widget.recommendedService == service.id + ? '${service.label} (Recommended)' : service.label, isSelected: _selectedService == service.id, isDisabled: service.isDisabled, diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index 2b6fd162..238b5350 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -20,6 +20,7 @@ Future showAddTracksToPlaylistSheet( BuildContext context, WidgetRef ref, List tracks, + {String? playlistNamePrefill} ) async { if (tracks.isEmpty) return; @@ -31,15 +32,16 @@ Future showAddTracksToPlaylistSheet( showDragHandle: true, isScrollControlled: true, builder: (sheetContext) { - return _PlaylistPickerSheetContent(tracks: tracks); + return _PlaylistPickerSheetContent(tracks: tracks, playlistNamePrefill: playlistNamePrefill); }, ); } class _PlaylistPickerSheetContent extends ConsumerStatefulWidget { final List tracks; + final String? playlistNamePrefill; - const _PlaylistPickerSheetContent({required this.tracks}); + const _PlaylistPickerSheetContent({required this.tracks, this.playlistNamePrefill}); @override ConsumerState<_PlaylistPickerSheetContent> createState() => @@ -130,7 +132,7 @@ class _PlaylistPickerSheetContentState leading: const Icon(Icons.add_circle_outline), title: Text(context.l10n.collectionCreatePlaylist), onTap: () async { - final name = await _promptPlaylistName(context); + final name = await _promptPlaylistName(context, widget.playlistNamePrefill); if (name == null || name.trim().isEmpty || !context.mounted) { return; } @@ -221,8 +223,8 @@ class _PlaylistPickerSheetContentState } } -Future _promptPlaylistName(BuildContext context) async { - final controller = TextEditingController(); +Future _promptPlaylistName(BuildContext context, String? playlistNamePrefill) async { + final controller = TextEditingController(text: playlistNamePrefill); final formKey = GlobalKey(); final result = await showDialog( diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index f46c4d04..b015a293 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -157,7 +157,7 @@ class _UpdateDialogState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _VersionChip(version: AppInfo.version, label: context.l10n.updateCurrent, colorScheme: colorScheme), + _VersionChip(version: AppInfo.displayVersion, label: context.l10n.updateCurrent, colorScheme: colorScheme), const SizedBox(width: 12), Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary), const SizedBox(width: 12), diff --git a/pubspec.lock b/pubspec.lock index 4fc7941f..5f81e3da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: @@ -509,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" http: dependency: "direct main" description: @@ -557,14 +573,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -633,18 +641,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -669,6 +677,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.6.1" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nm: dependency: transitive description: @@ -1090,6 +1106,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.6" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff + url: "https://pub.dev" + source: hosted + version: "2.4.0+2" sqflite_darwin: dependency: transitive description: @@ -1106,6 +1130,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: caa693ad15a587a2b4fde093b728131a1827903872171089dedb16f7665d3a91 + url: "https://pub.dev" + source: hosted + version: "3.2.0" stack_trace: dependency: transitive description: @@ -1166,26 +1198,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.16" timezone: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 638e579d..2bbf902a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 3.7.2+105 +version: 3.9.0+115 environment: sdk: ^3.10.0 @@ -27,6 +27,7 @@ dependencies: path_provider: ^2.1.5 path: ^1.9.0 sqflite: ^2.4.1 + sqflite_common_ffi: ^2.3.6 # HTTP & Network http: ^1.6.0