Compare commits

...

27 Commits

Author SHA1 Message Date
zarzet 46afa6e733 fix: use HTML parse mode for Telegram notifications to handle special chars 2026-01-21 13:30:35 +07:00
zarzet c01b189477 fix: discography download context issue after quality picker closes 2026-01-21 13:04:48 +07:00
zarzet 966935b677 feat: add missing platform bridge functions for batch duplicate check and cross-platform IDs 2026-01-21 12:22:46 +07:00
zarzet f2f8ca4528 feat: artist navigation from album, UI improvements, concise changelog
- Add tappable artist name in album screen to navigate to artist page
- Show track number instead of cover image in album track list
- Add release date badge next to track count on album screen
- Modernize Download All buttons with rounded corners (borderRadius: 24)
- Add downloaded indicator for recent items (primary colored subtitle)
- Condense v3.2.0 changelog and add note about concise format
- Fix withOpacity deprecation and unnecessary null assertion in home_tab
- Go backend: add artist_id support for Spotify, Deezer, and extensions
2026-01-21 12:07:50 +07:00
zarzet 7844bd2f42 docs: add discography download to changelog as highly requested feature 2026-01-21 10:28:17 +07:00
zarzet ac3d51e2cd feat: add discography download with album selection support
- Download entire artist discography, albums only, or singles only
- Album selection mode with multi-select and batch download
- Progress dialog while fetching tracks from albums
- Skip already downloaded tracks (checks history)
- Works with Spotify, Deezer, and Extensions
- Add 18 localization strings for discography feature
2026-01-21 10:26:35 +07:00
zarzet b899b54bb8 perf: migrate history to SQLite and optimize palette extraction
- Add SQLite database for download history with O(1) indexed lookups
- Add in-memory Map indexes for O(1) getBySpotifyId/getByIsrc
- Automatic migration from SharedPreferences on first launch
- Fix PaletteService to use PaletteGenerator (isolate approach didn't work)
- Use small image size (64x64) and limited colors (8) for speed
- Add caching to avoid re-extraction
- All screens now use consistent PaletteService
- Update CHANGELOG with all v3.2.0 changes
2026-01-21 10:05:39 +07:00
zarzet 7a17de49b2 fix: add duration_ms to home feed items and bump version to 3.2.0
- Add duration_ms field to ExploreItem model
- Parse duration_ms from spotify-web and ytmusic home feed responses
- Update _downloadExploreTrack to use item.durationMs
- Fixes track duration showing 0:00 in metadata screen after download
- Bump version to 3.2.0+63
2026-01-21 09:16:11 +07:00
zarzet 79180dd918 feat: add Home Feed with pull-to-refresh and gobackend.getLocalTime() API
- Add Home Feed/Explore feature with extension capabilities system
- Add pull-to-refresh on home feed (replaces refresh button)
- Add gobackend.getLocalTime() API for accurate device timezone detection
- Add YT Music Quick Picks UI with swipeable vertical format
- Fix greeting time showing wrong time due to Goja getTimezoneOffset() returning 0
- Update spotify-web and ytmusic extensions to use getLocalTime()
- Add Turkish language support
- Update CHANGELOG for v3.2.0
2026-01-21 08:30:44 +07:00
zarzet e725a7be77 feat: convert GitHub Markdown to Telegram format in release notification 2026-01-20 10:12:01 +07:00
zarzet d960708dac feat: improve Telegram notification - upload IPA, remove redundant links, increase changelog limit 2026-01-20 10:08:35 +07:00
zarzet c62ad005f5 docs: update README and release workflow 2026-01-20 09:58:31 +07:00
zarzet 68fa1bfdae feat: improve providers, l10n updates, and UI enhancements (testing) 2026-01-20 09:55:46 +07:00
zarzet bd6b23400e Update screenshots, funding links, and VirusTotal hash 2026-01-20 05:57:43 +07:00
zarzet b6d2fea847 chore: bump version to 3.1.3+62 2026-01-20 04:55:02 +07:00
zarzet f356e53f7e feat: auto-enrich genre/label from Deezer for built-in providers
- Add GetExtendedMetadataByISRC function in deezer.go
  - Searches track by ISRC then fetches album extended metadata
- Call enrichment in DownloadWithExtensionFallback before built-in download
  - Only enriches if genre/label are empty and ISRC is available
  - Logs enrichment results for debugging
2026-01-20 04:09:41 +07:00
zarzet bb1ff187a3 fix: include genre, label, copyright in DownloadResponse
Extended metadata was being embedded into FLAC files but not returned
in the response to Flutter, causing history to not store these fields.

Fixed in 3 places in extension_providers.go:
- Source extension download response
- Extension fallback download response
- Built-in provider (Tidal/Qobuz/Amazon) response
2026-01-20 03:59:55 +07:00
zarzet d99a1b1c21 perf: streaming M4A metadata embedding and HTTP client refactor
- Refactor EmbedM4AMetadata to use streaming instead of loading entire file
- Use os.Open + ReadAt instead of os.ReadFile for memory efficiency
- Atomic file replacement via temp file + rename for safer writes
- New helper functions: findAtomInRange, readAtomHeaderAt, copyRange, buildUdtaAtom
- Refactor GetM4AQuality to use streaming with findAudioSampleEntry
- Use NewHTTPClientWithTimeout helper in lyrics.go, qobuz.go, tidal.go
- Update CHANGELOG with performance improvements and MP3 metadata support
2026-01-20 03:46:43 +07:00
zarzet c36497e87c perf: optimize widget rebuilds and reduce allocations
- Cache SharedPreferences instance in DownloadHistoryNotifier and DownloadQueueNotifier
- Precompile regex for folder sanitization and year extraction
- Use indexWhere instead of firstWhere with placeholder object
- Use selective watch for downloadQueueProvider (queuedCount, items)
- Pass Track directly to _buildTrackTile instead of index lookup
- Pass historyItems as parameter to _buildRecentAccess
- Add extended metadata (genre, label, copyright) support for MP3
2026-01-20 03:25:33 +07:00
zarzet 03027813c1 chore: cleanup unused code and dead imports 2026-01-20 02:10:10 +07:00
zarzet 8e9d0c3e9a fix: use path only for JsonCacheInfoRepository
JsonCacheInfoRepository assertion requires either path OR databaseName, not both.
Using path only to ensure database is stored in persistent directory.
2026-01-19 23:26:10 +07:00
zarzet 6c8813c9de fix: ensure CoverCacheManager initializes before app renders
- Move CoverCacheManager.initialize() to run BEFORE other services
- Add debug log to confirm initialization status
- Fixes race condition where widgets render before cache is ready
2026-01-19 23:22:53 +07:00
zarzet ec314eb479 fix: store cache database in persistent directory
- Add path parameter to JsonCacheInfoRepository
- Add fallback to DefaultCacheManager if initialization fails
- Add debug logging for troubleshooting
- Fix issue where cache database was in temp dir while files in persistent
2026-01-19 23:14:33 +07:00
zarzet 77e4457244 feat: add persistent cover image cache
- Add CoverCacheManager service for persistent image caching
- Cache stored in app_flutter/cover_cache/ (not cleared by system)
- Maximum 1000 images cached for up to 365 days
- Update all 11 screens to use persistent cache manager
- Add flutter_cache_manager and path dependencies
- Update CHANGELOG.md with all changes for v3.1.3
2026-01-19 22:55:53 +07:00
zarzet 0119db094d feat: add extended metadata (genre, label, copyright) support
- Add genre, label, copyright fields to ExtTrackMetadata and DownloadResponse
- Add utils.randomUserAgent() for extensions to get random User-Agent strings
- Fix VM race condition panic by adding mutex locks to all provider methods
- Fix Tidal release date fallback when req.ReleaseDate is empty
- Display genre, label, copyright in track metadata screen
- Store extended metadata in download history for persistence
- Add trackGenre, trackLabel, trackCopyright localization strings
2026-01-19 21:13:40 +07:00
zarzet 9c35515d6f docs: add code of conduct and contributing guidelines 2026-01-19 18:58:25 +07:00
zarzet 1546d7da22 feat: add external LRC lyrics file support and fix locale parsing
- Add lyrics mode setting (embed/external/both) for saving lyrics
- Implement SaveLRCFile() in Go backend for all providers (Tidal, Qobuz, Amazon)
- Fix locale parsing in app.dart to handle country codes (e.g., pt_PT -> Locale('pt', 'PT'))
- Change Portuguese label from Portugal to Brasil in language settings
2026-01-19 18:57:27 +07:00
106 changed files with 9558 additions and 3752 deletions
+4
View File
@@ -0,0 +1,4 @@
github: zarzet
ko_fi: zarzet
buy_me_a_coffee: zarzet
+122
View File
@@ -412,3 +412,125 @@ jobs:
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }} prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify-telegram:
runs-on: ubuntu-latest
needs: [get-version, create-release]
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download Android APK
uses: actions/download-artifact@v4
with:
name: android-apk
path: ./release
- name: Download iOS IPA
uses: actions/download-artifact@v4
with:
name: ios-ipa
path: ./release
- name: Extract changelog for version
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)
FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d')
if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details."
else
# Convert GitHub Markdown to Telegram HTML:
# - **text** → <b>text</b>
# - `code` → <code>code</code>
# - ### Header → <b>Header</b>
# - Escape HTML special chars first
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^- /• /g' | \
sed 's/^ - / ◦ /g')
# Take first 2500 characters, then cut at last complete line
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
fi
echo "$CHANGELOG" > /tmp/changelog.txt
- name: Send to Telegram Channel
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
run: |
VERSION=${{ needs.get-version.outputs.version }}
CHANGELOG=$(cat /tmp/changelog.txt)
# Find APK files
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
# Prepare message with changelog (HTML format)
printf '%s\n' \
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
"" \
"<b>What's New:</b>" \
"${CHANGELOG}" \
"" \
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
> /tmp/telegram_message.txt
MESSAGE=$(cat /tmp/telegram_message.txt)
# Send message first (using HTML parse mode)
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHANNEL_ID}" \
-d text="${MESSAGE}" \
-d parse_mode="HTML" \
-d disable_web_page_preview="true"
# Upload arm64 APK to channel
if [ -f "$ARM64_APK" ]; then
echo "Uploading arm64 APK to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM64_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
if [ -f "$ARM32_APK" ]; then
echo "Uploading arm32 APK to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM32_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
if [ -f "$IOS_IPA" ]; then
echo "Uploading iOS IPA to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${IOS_IPA}" \
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+210 -1648
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**[zarzet](https://github.com/zarzet)**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+268
View File
@@ -0,0 +1,268 @@
# Contributing to SpotiFLAC
First off, thank you for considering contributing to SpotiFLAC! 🎉
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [How Can I Contribute?](#how-can-i-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
- [Code Contributions](#code-contributions)
- [Translations](#translations)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Guidelines](#coding-guidelines)
- [Commit Guidelines](#commit-guidelines)
- [Pull Request Process](#pull-request-process)
## Code of Conduct
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
## How Can I Contribute?
### Reporting Bugs
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
When creating a bug report, please use the bug report template and include:
- **Clear and descriptive title**
- **Steps to reproduce** the issue
- **Expected behavior** vs **actual behavior**
- **Screenshots or screen recordings** if applicable
- **Device information** (model, OS version)
- **App version**
- **Logs** from Settings > About > View Logs
### Suggesting Features
Feature requests are welcome! Please use the feature request template and:
- **Check existing issues** to avoid duplicates
- **Describe the feature** clearly
- **Explain the use case** - why would this be useful?
- **Consider the scope** - is this a small enhancement or a major feature?
### Code Contributions
1. **Fork the repository** and create your branch from `dev`
2. **Make your changes** following our coding guidelines
3. **Test your changes** thoroughly
4. **Submit a pull request** to the `dev` branch
### Translations
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
2. Select your language or request a new one
3. Start translating!
Translation files are located in `lib/l10n/arb/`.
## Development Setup
### Prerequisites
- **Flutter SDK** 3.10.0 or higher
- **Dart SDK** 3.10.0 or higher
- **Android Studio** or **VS Code** with Flutter extensions
- **Git**
### Getting Started
1. **Clone your fork**
```bash
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
cd SpotiFLAC-Mobile
```
2. **Add upstream remote**
```bash
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Install dependencies**
```bash
flutter pub get
```
4. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
5. **Run the app**
```bash
flutter run
```
### Building
```bash
# Debug build
flutter build apk --debug
# Release build
flutter build apk --release
```
## Project Structure
```
lib/
├── l10n/ # Localization files
│ └── arb/ # ARB translation files
├── models/ # Data models
├── providers/ # Riverpod providers
├── screens/ # UI screens
│ └── settings/ # Settings sub-screens
├── services/ # Business logic services
├── theme/ # App theming
├── utils/ # Utility functions
├── widgets/ # Reusable widgets
├── app.dart # App configuration
└── main.dart # Entry point
```
## Coding Guidelines
### General
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
- Use meaningful variable and function names
- Keep functions small and focused
- Add comments for complex logic
### Formatting
- Use `dart format` before committing
- Maximum line length: 80 characters
- Use trailing commas for better formatting
```bash
dart format .
```
### Linting
Ensure your code passes all lints:
```bash
flutter analyze
```
### State Management
We use **Riverpod** for state management. Follow these patterns:
```dart
// Use code generation with riverpod_annotation
@riverpod
class MyNotifier extends _$MyNotifier {
@override
MyState build() => MyState();
// Methods to update state
}
```
### Localization
All user-facing strings should be localized:
```dart
// Good
Text(AppLocalizations.of(context)!.downloadComplete)
// Bad
Text('Download Complete')
```
To add new strings:
1. Add the key to `lib/l10n/arb/app_en.arb`
2. Run `flutter gen-l10n`
## Commit Guidelines
We follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting, etc.)
- `refactor`: Code refactoring
- `perf`: Performance improvements
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
### Examples
```
feat(download): add batch download support
fix(ui): resolve overflow on small screens
docs: update contributing guidelines
chore(deps): update flutter_riverpod to 3.1.0
```
## Pull Request Process
1. **Update your fork**
```bash
git fetch upstream
git rebase upstream/dev
```
2. **Create a feature branch**
```bash
git checkout -b feat/my-new-feature
```
3. **Make your changes** and commit following our guidelines
4. **Push to your fork**
```bash
git push origin feat/my-new-feature
```
5. **Create a Pull Request**
- Target the `dev` branch
- Fill in the PR template
- Link related issues
6. **Address review feedback**
- Make requested changes
- Push additional commits
- Request re-review when ready
### PR Requirements
- [ ] Code follows project conventions
- [ ] All tests pass
- [ ] No new linting errors
- [ ] Documentation updated (if needed)
- [ ] Commit messages follow guidelines
- [ ] PR description is clear and complete
## Questions?
If you have questions, feel free to:
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
Thank you for contributing! 💚
+17 -2
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](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/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/3257155286587a3596ad5d4380d4576a684aa3d37a5b19a615914a845fbe57f3)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center"> <div align="center">
@@ -52,6 +52,20 @@ Want to create your own extension? Check out the [Extension Development Guide](h
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
## Telegram
<p align="center">
<a href="https://t.me/spotiflac">
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflacchat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
## FAQ ## FAQ
**Q: Why is my download failing with "Song not found"?** **Q: Why is my download failing with "Song not found"?**
@@ -69,7 +83,8 @@ A: The app needs permission to save downloaded files to your device. On Android
**Q: Is this app safe?** **Q: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README). A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet) **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.
## Disclaimer ## Disclaimer
@@ -139,6 +139,28 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) result.success(response)
} }
"checkDuplicatesBatch" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
val tracksJson = call.argument<String>("tracks") ?: "[]"
val response = withContext(Dispatchers.IO) {
Gobackend.checkDuplicatesBatch(outputDir, tracksJson)
}
result.success(response)
}
"preBuildDuplicateIndex" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.preBuildDuplicateIndex(outputDir)
}
result.success(null)
}
"invalidateDuplicateIndex" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.invalidateDuplicateIndex(outputDir)
}
result.success(null)
}
"buildFilename" -> { "buildFilename" -> {
val template = call.argument<String>("template") ?: "" val template = call.argument<String>("template") ?: ""
val metadata = call.argument<String>("metadata") ?: "{}" val metadata = call.argument<String>("metadata") ?: "{}"
@@ -306,6 +328,43 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) result.success(response)
} }
"checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityFromDeezerID(deezerTrackId)
}
result.success(response)
}
"checkAvailabilityByPlatformID" -> {
val platform = call.argument<String>("platform") ?: ""
val entityType = call.argument<String>("entity_type") ?: ""
val entityId = call.argument<String>("entity_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId)
}
result.success(response)
}
"getSpotifyIDFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyIDFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
"getTidalURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
"getAmazonURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
// Log methods // Log methods
"getLogs" -> { "getLogs" -> {
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
@@ -468,6 +527,14 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) result.success(response)
} }
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
val response = withContext(Dispatchers.IO) {
Gobackend.enrichTrackWithExtensionJSON(extensionId, trackJson)
}
result.success(response)
}
"removeExtension" -> { "removeExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: "" val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -678,6 +745,21 @@ class MainActivity: FlutterActivity() {
} }
result.success(null) result.success(null)
} }
// Extension Home Feed (Explore)
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionHomeFeedJSON(extensionId)
}
result.success(response)
}
"getExtensionBrowseCategories" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionBrowseCategoriesJSON(extensionId)
}
result.success(response)
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} catch (e: Exception) { } catch (e: Exception) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 81 KiB

+25 -23
View File
@@ -17,13 +17,12 @@ import (
"time" "time"
) )
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct { type AmazonDownloader struct {
client *http.Client client *http.Client
regions []string // us, eu regions for DoubleDouble service regions []string
lastAPICallTime time.Time // Rate limiting: track last API call lastAPICallTime time.Time
apiCallCount int // Rate limiting: counter per minute apiCallCount int
apiCallResetTime time.Time // Rate limiting: reset time apiCallResetTime time.Time
} }
var ( var (
@@ -38,7 +37,6 @@ type DoubleDoubleSubmitResponse struct {
ID string `json:"id"` ID string `json:"id"`
} }
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
type DoubleDoubleStatusResponse struct { type DoubleDoubleStatusResponse struct {
Status string `json:"status"` Status string `json:"status"`
FriendlyStatus string `json:"friendlyStatus"` FriendlyStatus string `json:"friendlyStatus"`
@@ -49,7 +47,6 @@ type DoubleDoubleStatusResponse struct {
} `json:"current"` } `json:"current"`
} }
// amazonArtistsMatch checks if the artist names are similar enough
func amazonArtistsMatch(expectedArtist, foundArtist string) bool { func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
@@ -90,7 +87,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
return false return false
} }
// amazonIsASCIIString checks if a string contains only ASCII characters
func amazonIsASCIIString(s string) bool { func amazonIsASCIIString(s string) bool {
for _, r := range s { for _, r := range s {
if r > 127 { if r > 127 {
@@ -100,7 +96,6 @@ func amazonIsASCIIString(s string) bool {
return true return true
} }
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() { amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{ globalAmazonDownloader = &AmazonDownloader{
@@ -113,7 +108,6 @@ func NewAmazonDownloader() *AmazonDownloader {
} }
// waitForRateLimit implements rate limiting similar to PC version // waitForRateLimit implements rate limiting similar to PC version
// Max 9 requests per minute with 7 second delay between requests
func (a *AmazonDownloader) waitForRateLimit() { func (a *AmazonDownloader) waitForRateLimit() {
amazonRateLimitMu.Lock() amazonRateLimitMu.Lock()
defer amazonRateLimitMu.Unlock() defer amazonRateLimitMu.Unlock()
@@ -125,7 +119,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
a.apiCallResetTime = now a.apiCallResetTime = now
} }
// If we've hit the limit (9 requests per minute), wait until next minute
if a.apiCallCount >= 9 { if a.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(a.apiCallResetTime) waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 { if waitTime > 0 {
@@ -136,7 +129,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
} }
} }
// Add delay between requests (7 seconds like PC version)
if !a.lastAPICallTime.IsZero() { if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime) timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second minDelay := 7 * time.Second
@@ -151,7 +143,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
a.apiCallCount++ a.apiCallCount++
} }
// GetAvailableAPIs returns list of available DoubleDouble regions
// Uses same service as PC version (doubledouble.top) // Uses same service as PC version (doubledouble.top)
func (a *AmazonDownloader) GetAvailableAPIs() []string { func (a *AmazonDownloader) GetAvailableAPIs() []string {
// DoubleDouble service regions (same as PC) // DoubleDouble service regions (same as PC)
@@ -176,11 +167,9 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request with rate limiting
encodedURL := url.QueryEscape(amazonURL) encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
// Apply rate limiting before request (like PC version)
a.waitForRateLimit() a.waitForRateLimit()
req, err := http.NewRequest("GET", submitURL, nil) req, err := http.NewRequest("GET", submitURL, nil)
@@ -334,7 +323,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
} }
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background() ctx := context.Background()
@@ -434,7 +422,6 @@ type AmazonDownloadResult struct {
ISRC string ISRC string
} }
// downloadFromAmazon downloads a track using the request parameters
// Uses DoubleDouble service (same as PC version) // Uses DoubleDouble service (same as PC version)
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader() downloader := NewAmazonDownloader()
@@ -580,13 +567,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) fmt.Printf("Warning: failed to embed metadata: %v\n", err)
} }
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) lyricsMode := req.LyricsMode
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if lyricsMode == "" {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) lyricsMode = "embed" // default
} else { }
fmt.Println("[Amazon] Lyrics embedded successfully")
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Amazon] Lyrics embedded successfully")
}
} }
} else if req.EmbedLyrics { } else if req.EmbedLyrics {
fmt.Println("[Amazon] No lyrics available from parallel fetch") fmt.Println("[Amazon] No lyrics available from parallel fetch")
+3 -14
View File
@@ -8,18 +8,15 @@ import (
"strings" "strings"
) )
// Spotify image size codes (same as PC version)
const ( const (
spotifySize300 = "ab67616d00001e02" // 300x300 (small) spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273" // 640x640 (medium) spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000) spotifySizeMax = "ab67616d000082c1"
) )
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800 // Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
// Same logic as PC version for consistency
func convertSmallToMedium(imageURL string) string { func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) { if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -27,8 +24,6 @@ func convertSmallToMedium(imageURL string) string {
return imageURL return imageURL
} }
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
// This avoids file permission issues on Android
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
if coverURL == "" { if coverURL == "" {
return nil, fmt.Errorf("no cover URL provided") return nil, fmt.Errorf("no cover URL provided")
@@ -90,8 +85,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return data, nil return data, nil
} }
// upgradeToMaxQuality upgrades cover URL to maximum quality
// Supports both Spotify and Deezer CDNs
func upgradeToMaxQuality(coverURL string) string { func upgradeToMaxQuality(coverURL string) string {
// Spotify CDN upgrade // Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) { if strings.Contains(coverURL, spotifySize640) {
@@ -106,9 +99,6 @@ func upgradeToMaxQuality(coverURL string) string {
return coverURL return coverURL
} }
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
// Available sizes: 56, 250, 500, 1000, 1400, 1800
func upgradeDeezerCover(coverURL string) string { func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") { if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return coverURL return coverURL
@@ -122,7 +112,6 @@ func upgradeDeezerCover(coverURL string) string {
return upgraded return upgraded
} }
// GetCoverFromSpotify gets cover URL from Spotify metadata
func GetCoverFromSpotify(imageURL string, maxQuality bool) string { func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" { if imageURL == "" {
return "" return ""
+54 -37
View File
@@ -25,13 +25,12 @@ const (
deezerMaxParallelISRC = 10 deezerMaxParallelISRC = 10
) )
// DeezerClient handles Deezer API interactions (no auth required)
type DeezerClient struct { type DeezerClient struct {
httpClient *http.Client httpClient *http.Client
searchCache map[string]*cacheEntry searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry artistCache map[string]*cacheEntry
isrcCache map[string]string // trackID -> ISRC cache isrcCache map[string]string
cacheMu sync.RWMutex cacheMu sync.RWMutex
} }
@@ -40,7 +39,6 @@ var (
deezerClientOnce sync.Once deezerClientOnce sync.Once
) )
// GetDeezerClient returns singleton Deezer client
func GetDeezerClient() *DeezerClient { func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() { deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{ deezerClient = &DeezerClient{
@@ -54,7 +52,6 @@ func GetDeezerClient() *DeezerClient {
return deezerClient return deezerClient
} }
// Deezer API response types
type deezerTrack struct { type deezerTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -63,7 +60,7 @@ type deezerTrack struct {
DiskNumber int `json:"disk_number"` DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Link string `json:"link"` Link string `json:"link"`
ReleaseDate string `json:"release_date"` // Sometimes at track level ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"` Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"` Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"` Contributors []deezerArtist `json:"contributors"`
@@ -86,8 +83,8 @@ type deezerAlbumSimple struct {
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // Sometimes at album level ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"` // album, single, ep, compile RecordType string `json:"record_type"`
} }
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
@@ -146,8 +143,8 @@ type deezerAlbumFull struct {
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"` NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"` // album, single, ep, compile RecordType string `json:"record_type"`
Label string `json:"label"` // Record label name Label string `json:"label"`
Genres struct { Genres struct {
Data []deezerGenre `json:"data"` Data []deezerGenre `json:"data"`
} `json:"genres"` } `json:"genres"`
@@ -185,7 +182,6 @@ type deezerPlaylistFull struct {
} `json:"tracks"` } `json:"tracks"`
} }
// SearchAll searches for tracks and artists on Deezer
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download // NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit) GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
@@ -201,8 +197,8 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
result := &SearchAllResult{ result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0), Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0), Artists: make([]SearchArtistResult, 0, artistLimit),
} }
// Search tracks - NO ISRC fetch for performance // Search tracks - NO ISRC fetch for performance
@@ -230,11 +226,9 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data)) GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
for _, track := range trackResp.Data { for _, track := range trackResp.Data {
// Convert directly without fetching ISRC - much faster
result.Tracks = append(result.Tracks, c.convertTrack(track)) result.Tracks = append(result.Tracks, c.convertTrack(track))
} }
// Search artists
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL) GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
@@ -267,7 +261,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists)) GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
// Cache result
c.cacheMu.Lock() c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
data: result, data: result,
@@ -292,7 +285,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil }, nil
} }
// GetAlbum fetches album with tracks
// ISRC is fetched in parallel for better performance // ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) { func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
@@ -333,12 +325,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
Name: album.Title, Name: album.Title,
ReleaseDate: album.ReleaseDate, ReleaseDate: album.ReleaseDate,
Artists: artistName, Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage, Images: albumImage,
Genre: genreStr, // From Deezer album Genre: genreStr, // From Deezer album
Label: album.Label, // From Deezer album Label: album.Label, // From Deezer album
} }
// Fetch ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data) isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
@@ -386,7 +378,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
return result, nil return result, nil
} }
// GetArtist fetches artist with albums
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) { func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
@@ -472,8 +463,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
return result, nil return result, nil
} }
// GetPlaylist fetches playlist with tracks
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
@@ -496,7 +485,6 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
info.Owner.Name = playlist.Title info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage info.Owner.Images = playlistImage
// Fetch ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data) isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data)) tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
@@ -535,15 +523,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
}, nil }, nil
} }
// SearchByISRC searches for a track by ISRC using direct endpoint
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) { func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
// Use direct ISRC endpoint (API 2.0)
// https://api.deezer.com/2.0/track/isrc:{ISRC}
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc) directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
var track deezerTrack var track deezerTrack
if err := c.getJSON(ctx, directURL, &track); err != nil { if err := c.getJSON(ctx, directURL, &track); err != nil {
// Fallback to search if direct endpoint fails
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc) searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
var resp struct { var resp struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
@@ -577,13 +561,24 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching // fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
result := make(map[string]string) result := make(map[string]string, len(tracks))
var resultMu sync.Mutex var resultMu sync.Mutex
var tracksToFetch []deezerTrack var tracksToFetch []deezerTrack
var directISRCs map[string]string
c.cacheMu.RLock() c.cacheMu.RLock()
for _, track := range tracks { for _, track := range tracks {
trackIDStr := fmt.Sprintf("%d", track.ID) trackIDStr := fmt.Sprintf("%d", track.ID)
if track.ISRC != "" {
result[trackIDStr] = track.ISRC
if _, ok := c.isrcCache[trackIDStr]; !ok {
if directISRCs == nil {
directISRCs = make(map[string]string)
}
directISRCs[trackIDStr] = track.ISRC
}
continue
}
if isrc, ok := c.isrcCache[trackIDStr]; ok { if isrc, ok := c.isrcCache[trackIDStr]; ok {
result[trackIDStr] = isrc result[trackIDStr] = isrc
} else { } else {
@@ -591,6 +586,13 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
} }
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
if len(directISRCs) > 0 {
c.cacheMu.Lock()
for trackIDStr, isrc := range directISRCs {
c.isrcCache[trackIDStr] = isrc
}
c.cacheMu.Unlock()
}
if len(tracksToFetch) == 0 { if len(tracksToFetch) == 0 {
return result return result
@@ -605,7 +607,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
go func(t deezerTrack) { go func(t deezerTrack) {
defer wg.Done() defer wg.Done()
// Acquire semaphore
select { select {
case sem <- struct{}{}: case sem <- struct{}{}:
defer func() { <-sem }() defer func() { <-sem }()
@@ -634,7 +635,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result return result
} }
// GetTrackISRC fetches ISRC for a single track (with caching)
// Use this when you need ISRC for download // Use this when you need ISRC for download
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) { func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
@@ -644,13 +644,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch from API
fullTrack, err := c.fetchFullTrack(ctx, trackID) fullTrack, err := c.fetchFullTrack(ctx, trackID)
if err != nil { if err != nil {
return "", err return "", err
} }
// Cache the result
c.cacheMu.Lock() c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC c.isrcCache[trackID] = fullTrack.ISRC
c.cacheMu.Unlock() c.cacheMu.Unlock()
@@ -697,20 +695,17 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
return album.Cover return album.Cover
} }
// AlbumExtendedMetadata contains genre and label information from an album
type AlbumExtendedMetadata struct { type AlbumExtendedMetadata struct {
Genre string // Comma-separated list of genres Genre string // Comma-separated list of genres
Label string // Record label name Label string // Record label name
} }
// GetAlbumExtendedMetadata fetches genre and label from a Deezer album
// Uses the album ID from a track to fetch extended metadata // Uses the album ID from a track to fetch extended metadata
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
if albumID == "" { if albumID == "" {
return nil, fmt.Errorf("empty album ID") return nil, fmt.Errorf("empty album ID")
} }
// Check cache first
cacheKey := fmt.Sprintf("album_meta:%s", albumID) cacheKey := fmt.Sprintf("album_meta:%s", albumID)
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
@@ -726,7 +721,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
return nil, fmt.Errorf("failed to fetch album: %w", err) return nil, fmt.Errorf("failed to fetch album: %w", err)
} }
// Extract genres as comma-separated string
var genres []string var genres []string
for _, g := range album.Genres.Data { for _, g := range album.Genres.Data {
if g.Name != "" { if g.Name != "" {
@@ -739,7 +733,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
Label: album.Label, Label: album.Label,
} }
// Cache the result
c.cacheMu.Lock() c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
data: result, data: result,
@@ -764,7 +757,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str
return fmt.Sprintf("%d", track.Album.ID), nil return fmt.Sprintf("%d", track.Album.ID), nil
} }
// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID
// This is a convenience function that first gets the album ID, then fetches album metadata // This is a convenience function that first gets the album ID, then fetches album metadata
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) { func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
albumID, err := c.GetTrackAlbumID(ctx, trackID) albumID, err := c.GetTrackAlbumID(ctx, trackID)
@@ -775,6 +767,32 @@ func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID
return c.GetAlbumExtendedMetadata(ctx, albumID) return c.GetAlbumExtendedMetadata(ctx, albumID)
} }
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
if isrc == "" {
return nil, fmt.Errorf("empty ISRC")
}
// First, search for track by ISRC
track, err := c.SearchByISRC(ctx, isrc)
if err != nil {
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
}
// SpotifyID contains "deezer:123" format, extract the ID
deezerID := track.SpotifyID
if strings.HasPrefix(deezerID, "deezer:") {
deezerID = strings.TrimPrefix(deezerID, "deezer:")
}
if deezerID == "" {
return nil, fmt.Errorf("track found but no Deezer ID")
}
// Then fetch extended metadata using the Deezer track ID
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
@@ -819,7 +837,6 @@ func parseDeezerURL(input string) (string, string, error) {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Skip language prefix if present (e.g., /en/, /fr/)
if len(parts) > 0 && len(parts[0]) == 2 { if len(parts) > 0 && len(parts[0]) == 2 {
parts = parts[1:] parts = parts[1:]
} }
-6
View File
@@ -158,7 +158,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
return "", false return "", false
} }
// Use index for fast lookup
idx := GetISRCIndex(outputDir) idx := GetISRCIndex(outputDir)
filePath, exists := idx.lookup(isrc) filePath, exists := idx.lookup(isrc)
if !exists { if !exists {
@@ -175,7 +174,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
} }
// CheckISRCExists is the exported version for gomobile (returns string, error) // CheckISRCExists is the exported version for gomobile (returns string, error)
// Returns the filepath if exists, empty string if not
func CheckISRCExists(outputDir, isrc string) (string, error) { func CheckISRCExists(outputDir, isrc string) (string, error) {
filepath, _ := checkISRCExistsInternal(outputDir, isrc) filepath, _ := checkISRCExistsInternal(outputDir, isrc)
return filepath, nil return filepath, nil
@@ -199,9 +197,6 @@ type FileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"` ArtistName string `json:"artist_name,omitempty"`
} }
// CheckFilesExistParallel checks if multiple files exist in parallel
// It builds an ISRC index from the output directory once, then checks all tracks against it
// Same implementation as PC version for consistency
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) { func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
var tracks []struct { var tracks []struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
@@ -266,7 +261,6 @@ func PreBuildISRCIndex(outputDir string) error {
} }
// AddToISRCIndex adds a new file to the ISRC index after successful download // AddToISRCIndex adds a new file to the ISRC index after successful download
// This avoids rebuilding the entire index
func AddToISRCIndex(outputDir, isrc, filePath string) { func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" { if outputDir == "" || isrc == "" || filePath == "" {
return return
+84 -118
View File
@@ -13,8 +13,6 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
// ParseSpotifyURL parses and validates a Spotify URL
// Returns JSON with type (track/album/playlist) and ID
func ParseSpotifyURL(url string) (string, error) { func ParseSpotifyURL(url string) (string, error) {
parsed, err := parseSpotifyURI(url) parsed, err := parseSpotifyURI(url)
if err != nil { if err != nil {
@@ -34,19 +32,14 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
func SetSpotifyAPICredentials(clientID, clientSecret string) { func SetSpotifyAPICredentials(clientID, clientSecret string) {
SetSpotifyCredentials(clientID, clientSecret) SetSpotifyCredentials(clientID, clientSecret)
} }
// CheckSpotifyCredentials checks if Spotify credentials are configured
// Returns true if credentials are available (custom or env vars)
func CheckSpotifyCredentials() bool { func CheckSpotifyCredentials() bool {
return HasSpotifyCredentials() return HasSpotifyCredentials()
} }
// GetSpotifyMetadata fetches metadata from Spotify URL
// Returns JSON with track/album/playlist data
func GetSpotifyMetadata(spotifyURL string) (string, error) { func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -68,8 +61,6 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SearchSpotify searches for tracks on Spotify
// Returns JSON array of track results
func SearchSpotify(query string, limit int) (string, error) { func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -91,8 +82,6 @@ func SearchSpotify(query string, limit int) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SearchSpotifyAll searches for tracks and artists on Spotify
// Returns JSON with tracks and artists arrays
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -114,8 +103,6 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// CheckAvailability checks track availability on streaming services
// Returns JSON with availability info for Tidal, Qobuz, Amazon
func CheckAvailability(spotifyID, isrc string) (string, error) { func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc) availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -131,7 +118,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// DownloadRequest represents a download request from Flutter
type DownloadRequest struct { type DownloadRequest struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Service string `json:"service"` Service string `json:"service"`
@@ -143,52 +129,51 @@ type DownloadRequest struct {
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS Quality string `json:"quality"`
EmbedLyrics bool `json:"embed_lyrics"` EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
TrackNumber int `json:"track_number"` TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"` DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking ItemID string `json:"item_id"`
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) DurationMS int `json:"duration_ms"`
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension) Source string `json:"source"`
// Extended metadata from Deezer for FLAC tagging Genre string `json:"genre,omitempty"`
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated Label string `json:"label,omitempty"`
Label string `json:"label,omitempty"` // Record label name Copyright string `json:"copyright,omitempty"`
Copyright string `json:"copyright,omitempty"` // Copyright information TidalID string `json:"tidal_id,omitempty"`
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch QobuzID string `json:"qobuz_id,omitempty"`
TidalID string `json:"tidal_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"` LyricsMode string `json:"lyrics_mode,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
} }
// DownloadResponse represents the result of a download // DownloadResponse represents the result of a download
type DownloadResponse struct { type DownloadResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
FilePath string `json:"file_path,omitempty"` FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown" ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
AlreadyExists bool `json:"already_exists,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"`
// Actual quality info from the source ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"` ActualSampleRate int `json:"actual_sample_rate,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"` Service string `json:"service,omitempty"` // Actual service used (for fallback)
Service string `json:"service,omitempty"` // Actual service used (for fallback) Title string `json:"title,omitempty"`
Title string `json:"title,omitempty"` Artist string `json:"artist,omitempty"`
Artist string `json:"artist,omitempty"` Album string `json:"album,omitempty"`
Album string `json:"album,omitempty"` AlbumArtist string `json:"album_artist,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` TrackNumber int `json:"track_number,omitempty"`
TrackNumber int `json:"track_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` ISRC string `json:"isrc,omitempty"`
ISRC string `json:"isrc,omitempty"` CoverURL string `json:"cover_url,omitempty"`
CoverURL string `json:"cover_url,omitempty"` Genre string `json:"genre,omitempty"`
// If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata) Label string `json:"label,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` Copyright string `json:"copyright,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
} }
// DownloadResult is a generic result type for all downloaders
type DownloadResult struct { type DownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
@@ -202,9 +187,6 @@ type DownloadResult struct {
ISRC string ISRC string
} }
// DownloadTrack downloads a track from the specified service
// requestJSON is a JSON string of DownloadRequest
// Returns JSON string of DownloadResponse
func DownloadTrack(requestJSON string) (string, error) { func DownloadTrack(requestJSON string) (string, error) {
var req DownloadRequest var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -218,7 +200,6 @@ func DownloadTrack(requestJSON string) (string, error) {
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir) req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" { if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir) AddAllowedDownloadDir(req.OutputDir)
} }
@@ -342,22 +323,18 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// DownloadWithFallback tries to download from services in order
// Starts with the preferred service from request, then tries others
func DownloadWithFallback(requestJSON string) (string, error) { func DownloadWithFallback(requestJSON string) (string, error) {
var req DownloadRequest var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error()) return errorResponse("Invalid request: " + err.Error())
} }
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName) req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName) req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir) req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" { if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir) AddAllowedDownloadDir(req.OutputDir)
} }
@@ -514,47 +491,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return errorResponse("All services failed. Last error: " + lastErr.Error()) return errorResponse("All services failed. Last error: " + lastErr.Error())
} }
// GetDownloadProgress returns current download progress
func GetDownloadProgress() string { func GetDownloadProgress() string {
progress := getProgress() progress := getProgress()
jsonBytes, _ := json.Marshal(progress) jsonBytes, _ := json.Marshal(progress)
return string(jsonBytes) return string(jsonBytes)
} }
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
func GetAllDownloadProgress() string { func GetAllDownloadProgress() string {
return GetMultiProgress() return GetMultiProgress()
} }
// InitItemProgress initializes progress tracking for a download item
func InitItemProgress(itemID string) { func InitItemProgress(itemID string) {
StartItemProgress(itemID) StartItemProgress(itemID)
} }
// FinishItemProgress marks a download item as complete and removes tracking
func FinishItemProgress(itemID string) { func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID) CompleteItemProgress(itemID)
} }
// ClearItemProgress removes progress tracking for a specific item
func ClearItemProgress(itemID string) { func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID) RemoveItemProgress(itemID)
} }
// CancelDownload cancels an in-progress download for the given item.
func CancelDownload(itemID string) { func CancelDownload(itemID string) {
cancelDownload(itemID) cancelDownload(itemID)
} }
// CleanupConnections closes idle HTTP connections
// Call this periodically during large batch downloads to prevent TCP exhaustion
func CleanupConnections() { func CleanupConnections() {
CloseIdleConnections() CloseIdleConnections()
} }
// ReadFileMetadata reads metadata directly from a FLAC file
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
// This is useful for displaying accurate metadata in the UI without relying on cached data
func ReadFileMetadata(filePath string) (string, error) { func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath) metadata, err := ReadMetadata(filePath)
if err != nil { if err != nil {
@@ -594,12 +560,10 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SetDownloadDirectory sets the default download directory
func SetDownloadDirectory(path string) error { func SetDownloadDirectory(path string) error {
return setDownloadDir(path) return setDownloadDir(path)
} }
// CheckDuplicate checks if a file with the given ISRC exists
func CheckDuplicate(outputDir, isrc string) (string, error) { func CheckDuplicate(outputDir, isrc string) (string, error) {
existingFile, exists := CheckISRCExists(outputDir, isrc) existingFile, exists := CheckISRCExists(outputDir, isrc)
@@ -616,26 +580,18 @@ func CheckDuplicate(outputDir, isrc string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// CheckDuplicatesBatch checks multiple files for duplicates in parallel
// Uses ISRC index for fast lookup (builds index once, checks all tracks)
// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...]
// Returns JSON array of results
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) { func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
return CheckFilesExistParallel(outputDir, tracksJSON) return CheckFilesExistParallel(outputDir, tracksJSON)
} }
// PreBuildDuplicateIndex pre-builds the ISRC index for a directory
// Call this when entering album/playlist screen for faster duplicate checking
func PreBuildDuplicateIndex(outputDir string) error { func PreBuildDuplicateIndex(outputDir string) error {
return PreBuildISRCIndex(outputDir) return PreBuildISRCIndex(outputDir)
} }
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
func InvalidateDuplicateIndex(outputDir string) { func InvalidateDuplicateIndex(outputDir string) {
InvalidateISRCCache(outputDir) InvalidateISRCCache(outputDir)
} }
// BuildFilename builds a filename from template and metadata
func BuildFilename(template string, metadataJSON string) (string, error) { func BuildFilename(template string, metadataJSON string) (string, error) {
var metadata map[string]interface{} var metadata map[string]interface{}
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
@@ -646,14 +602,10 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
return filename, nil return filename, nil
} }
// SanitizeFilename removes invalid characters from filename
func SanitizeFilename(filename string) string { func SanitizeFilename(filename string) string {
return sanitizeFilename(filename) return sanitizeFilename(filename)
} }
// FetchLyrics fetches lyrics for a track from LRCLIB
// Returns JSON with lyrics data
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) { func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
client := NewLyricsClient() client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0 durationSec := float64(durationMs) / 1000.0
@@ -677,9 +629,6 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
// First tries to extract from file, then falls back to fetching from internet
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" { if filePath != "" {
lyrics, err := ExtractLyrics(filePath) lyrics, err := ExtractLyrics(filePath)
@@ -699,7 +648,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return lrcContent, nil return lrcContent, nil
} }
// EmbedLyricsToFile embeds lyrics into an existing FLAC file
func EmbedLyricsToFile(filePath, lyrics string) (string, error) { func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
err := EmbedLyrics(filePath, lyrics) err := EmbedLyrics(filePath, lyrics)
if err != nil { if err != nil {
@@ -715,9 +663,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
// This runs in background and returns immediately
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
var tracks []struct { var tracks []struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
@@ -753,20 +698,14 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetTrackCacheSize returns the current track ID cache size
func GetTrackCacheSize() int { func GetTrackCacheSize() int {
return GetCacheSize() return GetCacheSize()
} }
// ClearTrackIDCache clears the track ID cache
func ClearTrackIDCache() { func ClearTrackIDCache() {
ClearTrackCache() ClearTrackCache()
} }
// ==================== DEEZER API ====================
// SearchDeezerAll searches for tracks and artists on Deezer (no API key required)
// Returns JSON with tracks and artists arrays
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) { func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -984,10 +923,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API") return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
} }
// ==================== SONGLINK DEEZER SUPPORT ====================
// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source
// Returns JSON with availability info for Spotify, Tidal, Amazon, etc.
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID) availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
@@ -1171,14 +1106,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return "", err return "", err
} }
// Initialize with saved settings
settingsStore := GetExtensionSettingsStore() settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID) settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 { if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings) manager.InitializeExtension(ext.ID, settings)
} }
// Return extension info as JSON
result := map[string]interface{}{ result := map[string]interface{}{
"id": ext.ID, "id": ext.ID,
"display_name": ext.Manifest.DisplayName, "display_name": ext.Manifest.DisplayName,
@@ -1342,8 +1275,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION AUTH API ====================
// GetExtensionPendingAuthJSON returns pending auth request for an extension // GetExtensionPendingAuthJSON returns pending auth request for an extension
func GetExtensionPendingAuthJSON(extensionID string) (string, error) { func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
req := GetPendingAuthRequest(extensionID) req := GetPendingAuthRequest(extensionID)
@@ -1423,9 +1354,6 @@ func GetAllPendingAuthRequestsJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION FFMPEG API ====================
// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute
func GetPendingFFmpegCommandJSON(commandID string) (string, error) { func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
cmd := GetPendingFFmpegCommand(commandID) cmd := GetPendingFFmpegCommand(commandID)
if cmd == nil { if cmd == nil {
@@ -1485,7 +1413,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID) ext, err := manager.GetExtension(extensionID)
if err != nil { if err != nil {
// Extension not found, return original track
return trackJSON, nil return trackJSON, nil
} }
@@ -1589,10 +1516,6 @@ func GetSearchProvidersJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION URL HANDLER ====================
// HandleURLWithExtensionJSON tries to handle a URL with any matching extension
// Returns JSON with type, tracks, album info, etc.
func HandleURLWithExtensionJSON(url string) (string, error) { func HandleURLWithExtensionJSON(url string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
resultWithID, err := manager.HandleURLWithExtension(url) resultWithID, err := manager.HandleURLWithExtension(url)
@@ -1797,6 +1720,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"id": album.ID, "id": album.ID,
"name": album.Name, "name": album.Name,
"artists": album.Artists, "artists": album.Artists,
"artist_id": album.ArtistID,
"cover_url": album.CoverURL, "cover_url": album.CoverURL,
"release_date": album.ReleaseDate, "release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks, "total_tracks": album.TotalTracks,
@@ -1854,7 +1778,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
return "", fmt.Errorf("failed to marshal result: %w", err) return "", fmt.Errorf("failed to marshal result: %w", err)
} }
// Parse into album metadata (same structure)
var album ExtAlbumMetadata var album ExtAlbumMetadata
if err := json.Unmarshal(jsonBytes, &album); err != nil { if err := json.Unmarshal(jsonBytes, &album); err != nil {
return "", fmt.Errorf("failed to parse playlist: %w", err) return "", fmt.Errorf("failed to parse playlist: %w", err)
@@ -1955,7 +1878,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
response["header_image"] = artist.HeaderImage response["header_image"] = artist.HeaderImage
} }
// Add listeners if present
if artist.Listeners > 0 { if artist.Listeners > 0 {
response["listeners"] = artist.Listeners response["listeners"] = artist.Listeners
} }
@@ -2013,9 +1935,6 @@ func GetURLHandlersJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION POST-PROCESSING ====================
// RunPostProcessingJSON runs post-processing hooks on a file
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
var metadata map[string]interface{} var metadata map[string]interface{}
if metadataJSON != "" { if metadataJSON != "" {
@@ -2071,8 +1990,6 @@ func GetPostProcessingProvidersJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION STORE ====================
// InitExtensionStoreJSON initializes the extension store with cache directory // InitExtensionStoreJSON initializes the extension store with cache directory
func InitExtensionStoreJSON(cacheDir string) error { func InitExtensionStoreJSON(cacheDir string) error {
InitExtensionStore(cacheDir) InitExtensionStore(cacheDir)
@@ -2086,7 +2003,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return "", fmt.Errorf("extension store not initialized") return "", fmt.Errorf("extension store not initialized")
} }
// Force refresh if requested
if forceRefresh { if forceRefresh {
store.FetchRegistry(true) store.FetchRegistry(true)
} }
@@ -2167,3 +2083,53 @@ func ClearStoreCacheJSON() error {
store.ClearCache() store.ClearCache()
return nil return nil
} }
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
return extension.%s();
}
return null;
})()
`, functionName, functionName)
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("%s returned null", functionName)
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(jsonBytes), nil
}
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
}
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
+25 -46
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension management functionality
package gobackend package gobackend
import ( import (
@@ -15,8 +14,6 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
// compareVersions compares two semantic version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int { func compareVersions(v1, v2 string) int {
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
@@ -46,11 +43,11 @@ func compareVersions(v1, v2 string) int {
return 0 return 0
} }
// LoadedExtension represents an extension that has been loaded into memory
type LoadedExtension struct { type LoadedExtension struct {
ID string `json:"id"` ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"` Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"` DataDir string `json:"data_dir"`
@@ -71,7 +68,6 @@ var (
globalExtManagerOnce sync.Once globalExtManagerOnce sync.Once
) )
// GetExtensionManager returns the global extension manager instance
func GetExtensionManager() *ExtensionManager { func GetExtensionManager() *ExtensionManager {
globalExtManagerOnce.Do(func() { globalExtManagerOnce.Do(func() {
globalExtManager = &ExtensionManager{ globalExtManager = &ExtensionManager{
@@ -81,7 +77,6 @@ func GetExtensionManager() *ExtensionManager {
return globalExtManager return globalExtManager
} }
// SetDirectories sets the extensions and data directories
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -99,9 +94,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
return nil return nil
} }
// LoadExtensionFromFile loads an extension from a .spotiflac-ext file
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) { func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
@@ -180,14 +173,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("failed to create extension directory: %w", err) return nil, fmt.Errorf("failed to create extension directory: %w", err)
} }
// Extract all files (preserving directory structure)
for _, file := range zipReader.File { for _, file := range zipReader.File {
if file.FileInfo().IsDir() { if file.FileInfo().IsDir() {
continue continue
} }
// Preserve relative path within the zip (support subdirectories)
// Clean the path to prevent path traversal attacks
relPath := filepath.Clean(file.Name) relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
@@ -245,7 +235,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil return ext, nil
} }
// initializeVM creates and initializes the Goja VM for an extension
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
vm := goja.New() vm := goja.New()
ext.VM = vm ext.VM = vm
@@ -322,7 +311,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return nil return nil
} }
// GetExtension returns a loaded extension by ID
// Returns error if extension not found (gomobile compatible) // Returns error if extension not found (gomobile compatible)
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) { func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
m.mu.RLock() m.mu.RLock()
@@ -347,7 +335,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
return result return result
} }
// SetExtensionEnabled enables or disables an extension
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -408,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
return loaded, errors return loaded, errors
} }
// loadExtensionFromDirectory loads an extension from an already extracted directory
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) { func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -497,7 +483,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
return nil return nil
} }
// UpgradeExtension upgrades an existing extension from a new package file
// Only allows upgrades (new version > current version), not downgrades // Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
// Validate file extension // Validate file extension
@@ -644,7 +629,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return ext, nil return ext, nil
} }
// ExtensionUpgradeInfo holds information about extension upgrade check
type ExtensionUpgradeInfo struct { type ExtensionUpgradeInfo struct {
ExtensionID string `json:"extension_id"` ExtensionID string `json:"extension_id"`
CurrentVersion string `json:"current_version"` CurrentVersion string `json:"current_version"`
@@ -716,7 +700,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return info, nil return info, nil
} }
// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath) info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil { if err != nil {
@@ -736,27 +719,28 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions() extensions := m.GetAllExtensions()
type ExtensionInfo struct { type ExtensionInfo struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"` Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"` IconPath string `json:"icon_path,omitempty"`
Types []ExtensionType `json:"types"` Types []ExtensionType `json:"types"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error_message,omitempty"` Error string `json:"error_message,omitempty"`
Settings []ExtensionSetting `json:"settings,omitempty"` Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"quality_options,omitempty"` QualityOptions []QualityOption `json:"quality_options,omitempty"`
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"` HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"` HasDownloadProvider bool `json:"has_download_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
} }
infos := make([]ExtensionInfo, len(extensions)) infos := make([]ExtensionInfo, len(extensions))
@@ -813,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
SearchBehavior: ext.Manifest.SearchBehavior, SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching, TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing, PostProcessing: ext.Manifest.PostProcessing,
Capabilities: ext.Manifest.Capabilities,
} }
} }
@@ -826,7 +811,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
// ==================== Extension Lifecycle ==================== // ==================== Extension Lifecycle ====================
// InitializeExtension calls the extension's initialize method with settings
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -888,7 +872,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return nil return nil
} }
// CleanupExtension calls the extension's cleanup method
func (m *ExtensionManager) CleanupExtension(extensionID string) error { func (m *ExtensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -899,10 +882,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
} }
if ext.VM == nil { if ext.VM == nil {
return nil // No VM, nothing to cleanup return nil
} }
// Call cleanup function
script := ` script := `
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
@@ -951,16 +933,13 @@ func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Unlock() m.mu.Unlock()
for _, id := range extensionIDs { for _, id := range extensionIDs {
// Call cleanup first
m.CleanupExtension(id) m.CleanupExtension(id)
// Then unload
m.UnloadExtension(id) m.UnloadExtension(id)
} }
GoLog("[Extension] All extensions unloaded\n") GoLog("[Extension] All extensions unloaded\n")
} }
// InvokeAction calls a custom action function on an extension (e.g., for button settings)
// The function is called as extension.<actionName>() and can return a result // The function is called as extension.<actionName>() and can return a result
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) { func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock() m.mu.Lock()
+19 -38
View File
@@ -107,24 +107,25 @@ type PostProcessingConfig struct {
// ExtensionManifest represents the manifest.json of an extension // ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct { type ExtensionManifest struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"` Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
Types []ExtensionType `json:"type"` Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"` Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"` Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
MinAppVersion string `json:"minAppVersion,omitempty"` MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
} }
// ManifestValidationError represents a validation error in the manifest // ManifestValidationError represents a validation error in the manifest
@@ -151,9 +152,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) {
return &manifest, nil return &manifest, nil
} }
// Validate checks if the manifest has all required fields and valid values
func (m *ExtensionManifest) Validate() error { func (m *ExtensionManifest) Validate() error {
// Check required fields
if strings.TrimSpace(m.Name) == "" { if strings.TrimSpace(m.Name) == "" {
return &ManifestValidationError{Field: "name", Message: "name is required"} return &ManifestValidationError{Field: "name", Message: "name is required"}
} }
@@ -174,7 +173,6 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "type", Message: "at least one type is required"} return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
} }
// Validate extension types
for _, t := range m.Types { for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider { if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
return &ManifestValidationError{ return &ManifestValidationError{
@@ -200,21 +198,6 @@ func (m *ExtensionManifest) Validate() error {
} }
} }
// Validate setting type
validTypes := map[SettingType]bool{
SettingTypeString: true,
SettingTypeNumber: true,
SettingTypeBool: true,
SettingTypeSelect: true,
SettingTypeButton: true,
}
if !validTypes[setting.Type] {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].type", i),
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
}
}
// Select type requires options // Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 { if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{ return &ManifestValidationError{
@@ -223,7 +206,6 @@ func (m *ExtensionManifest) Validate() error {
} }
} }
// Button type requires action
if setting.Type == SettingTypeButton && setting.Action == "" { if setting.Type == SettingTypeButton && setting.Action == "" {
return &ManifestValidationError{ return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].action", i), Field: fmt.Sprintf("settings[%d].action", i),
@@ -300,7 +282,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
return false return false
} }
// Parse URL to get host
urlStr = strings.ToLower(strings.TrimSpace(urlStr)) urlStr = strings.ToLower(strings.TrimSpace(urlStr))
for _, pattern := range m.URLHandler.Patterns { for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern)) pattern = strings.ToLower(strings.TrimSpace(pattern))
+114
View File
@@ -2,6 +2,7 @@
package gobackend package gobackend
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -38,6 +39,10 @@ type ExtTrackMetadata struct {
DeezerID string `json:"deezer_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
Label string `json:"label,omitempty"` // Record label
Copyright string `json:"copyright,omitempty"` // Copyright information
Genre string `json:"genre,omitempty"` // Music genre(s)
} }
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields // ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
@@ -53,6 +58,7 @@ type ExtAlbumMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Artists string `json:"artists"` Artists string `json:"artists"`
ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
@@ -144,6 +150,10 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Call extension's searchTracks function // Call extension's searchTracks function
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
@@ -206,6 +216,10 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') { if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') {
@@ -252,6 +266,10 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
@@ -301,6 +319,10 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') { if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') {
@@ -349,6 +371,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return track, nil // Extension disabled, return as-is return track, nil // Extension disabled, return as-is
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert track to JSON for passing to JS // Convert track to JSON for passing to JS
trackJSON, err := json.Marshal(track) trackJSON, err := json.Marshal(track)
if err != nil { if err != nil {
@@ -415,6 +441,10 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
@@ -460,6 +490,10 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') { if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') {
@@ -508,6 +542,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Set up progress callback in VM // Set up progress callback in VM
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 { if len(call.Arguments) > 0 {
@@ -758,6 +796,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if enrichedTrack.Artists != "" { if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists req.ArtistName = enrichedTrack.Artists
} }
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
if enrichedTrack.Label != "" && req.Label == "" {
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
req.Label = enrichedTrack.Label
}
if enrichedTrack.Copyright != "" && req.Copyright == "" {
GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright)
req.Copyright = enrichedTrack.Copyright
}
if enrichedTrack.Genre != "" && req.Genre == "" {
GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre)
req.Genre = enrichedTrack.Genre
}
if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" {
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
req.ReleaseDate = enrichedTrack.ReleaseDate
}
} }
} }
} }
@@ -795,6 +850,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: req.Source, Service: req.Source,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
} }
// Embed genre and label if provided (from Deezer metadata) // Embed genre and label if provided (from Deezer metadata)
@@ -887,10 +945,44 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerID) { if isBuiltInProvider(providerID) {
// For built-in providers, enrich with Deezer metadata if not already present
if (req.Genre == "" || req.Label == "") && 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()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre)
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
}
// Use built-in provider // Use built-in provider
result, err := tryBuiltInProvider(providerID, req) result, err := tryBuiltInProvider(providerID, req)
if err == nil && result.Success { if err == nil && result.Success {
result.Service = providerID result.Service = providerID
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
if req.Label != "" {
result.Label = req.Label
}
if req.Copyright != "" {
result.Copyright = req.Copyright
}
if req.Genre != "" {
result.Genre = req.Genre
}
if req.ReleaseDate != "" && result.ReleaseDate == "" {
result.ReleaseDate = req.ReleaseDate
}
return result, nil return result, nil
} }
if err != nil { if err != nil {
@@ -944,6 +1036,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: providerID, Service: providerID,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
} }
// Embed genre and label if provided (from Deezer metadata) // Embed genre and label if provided (from Deezer metadata)
@@ -1103,6 +1198,9 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: result.TrackNumber, TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber, DiscNumber: result.DiscNumber,
ISRC: result.ISRC, ISRC: result.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}, nil }, nil
} }
@@ -1138,6 +1236,10 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert options to JSON // Convert options to JSON
optionsJSON, _ := json.Marshal(options) optionsJSON, _ := json.Marshal(options)
@@ -1209,6 +1311,10 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') { if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') {
@@ -1290,6 +1396,10 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
sourceJSON, _ := json.Marshal(sourceTrack) sourceJSON, _ := json.Marshal(sourceTrack)
candidatesJSON, _ := json.Marshal(candidates) candidatesJSON, _ := json.Marshal(candidates)
@@ -1353,6 +1463,10 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata) metadataJSON, _ := json.Marshal(metadata)
script := fmt.Sprintf(` script := fmt.Sprintf(`
+5 -25
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension runtime with sandboxed execution
package gobackend package gobackend
import ( import (
@@ -17,7 +16,6 @@ var (
extensionAuthStateMu sync.RWMutex extensionAuthStateMu sync.RWMutex
) )
// ExtensionAuthState holds auth state for an extension
type ExtensionAuthState struct { type ExtensionAuthState struct {
PendingAuthURL string PendingAuthURL string
AuthCode string AuthCode string
@@ -30,7 +28,6 @@ type ExtensionAuthState struct {
PKCEChallenge string PKCEChallenge string
} }
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
type PendingAuthRequest struct { type PendingAuthRequest struct {
ExtensionID string ExtensionID string
AuthURL string AuthURL string
@@ -55,7 +52,6 @@ func ClearPendingAuthRequest(extensionID string) {
delete(pendingAuthRequests, extensionID) delete(pendingAuthRequests, extensionID)
} }
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
func SetExtensionAuthCode(extensionID string, authCode string) { func SetExtensionAuthCode(extensionID string, authCode string) {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock() defer extensionAuthStateMu.Unlock()
@@ -68,7 +64,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) {
state.AuthCode = authCode state.AuthCode = authCode
} }
// SetExtensionTokens sets access/refresh tokens for an extension
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) { func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock() defer extensionAuthStateMu.Unlock()
@@ -84,7 +79,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != "" state.IsAuthenticated = accessToken != ""
} }
// ExtensionRuntime provides sandboxed APIs for extensions
type ExtensionRuntime struct { type ExtensionRuntime struct {
extensionID string extensionID string
manifest *ExtensionManifest manifest *ExtensionManifest
@@ -95,7 +89,6 @@ type ExtensionRuntime struct {
vm *goja.Runtime vm *goja.Runtime
} }
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
jar, _ := newSimpleCookieJar() jar, _ := newSimpleCookieJar()
@@ -108,7 +101,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
vm: ext.VM, vm: ext.VM,
} }
// Create HTTP client with redirect validation to prevent SSRF via open redirect
client := &http.Client{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Jar: jar, Jar: jar,
@@ -119,7 +111,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain} return &RedirectBlockedError{Domain: domain}
} }
// Also block redirects to private/local networks (SSRF protection)
if isPrivateIP(domain) { if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true} return &RedirectBlockedError{Domain: domain, IsPrivate: true}
@@ -136,7 +127,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
return runtime return runtime
} }
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
type RedirectBlockedError struct { type RedirectBlockedError struct {
Domain string Domain string
IsPrivate bool IsPrivate bool
@@ -162,10 +152,10 @@ func isPrivateIP(host string) bool {
"172.24.", "172.25.", "172.26.", "172.27.", "172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.", "172.28.", "172.29.", "172.30.", "172.31.",
"192.168.", "192.168.",
"169.254.", // Link-local "169.254.",
"::1", // IPv6 localhost "::1",
"fc00:", // IPv6 private "fc00:",
"fe80:", // IPv6 link-local "fe80:",
} }
hostLower := host hostLower := host
@@ -183,7 +173,6 @@ func isPrivateIP(host string) bool {
return false return false
} }
// simpleCookieJar is a simple in-memory cookie jar
type simpleCookieJar struct { type simpleCookieJar struct {
cookies map[string][]*http.Cookie cookies map[string][]*http.Cookie
mu sync.RWMutex mu sync.RWMutex
@@ -208,7 +197,6 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host] return j.cookies[u.Host]
} }
// SetSettings updates the runtime settings
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings r.settings = settings
} }
@@ -228,7 +216,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
httpObj.Set("clearCookies", r.httpClearCookies) httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj) vm.Set("http", httpObj)
// Storage API
storageObj := vm.NewObject() storageObj := vm.NewObject()
storageObj.Set("get", r.storageGet) storageObj.Set("get", r.storageGet)
storageObj.Set("set", r.storageSet) storageObj.Set("set", r.storageSet)
@@ -243,7 +230,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
credentialsObj.Set("has", r.credentialsHas) credentialsObj.Set("has", r.credentialsHas)
vm.Set("credentials", credentialsObj) vm.Set("credentials", credentialsObj)
// Auth API (for OAuth and other auth flows)
authObj := vm.NewObject() authObj := vm.NewObject()
authObj.Set("openAuthUrl", r.authOpenUrl) authObj.Set("openAuthUrl", r.authOpenUrl)
authObj.Set("getAuthCode", r.authGetCode) authObj.Set("getAuthCode", r.authGetCode)
@@ -270,7 +256,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("getSize", r.fileGetSize) fileObj.Set("getSize", r.fileGetSize)
vm.Set("file", fileObj) vm.Set("file", fileObj)
// FFmpeg API (for post-processing)
ffmpegObj := vm.NewObject() ffmpegObj := vm.NewObject()
ffmpegObj.Set("execute", r.ffmpegExecute) ffmpegObj.Set("execute", r.ffmpegExecute)
ffmpegObj.Set("getInfo", r.ffmpegGetInfo) ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
@@ -284,7 +269,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
matchingObj.Set("normalizeString", r.matchingNormalizeString) matchingObj.Set("normalizeString", r.matchingNormalizeString)
vm.Set("matching", matchingObj) vm.Set("matching", matchingObj)
// Utilities
utilsObj := vm.NewObject() utilsObj := vm.NewObject()
utilsObj.Set("base64Encode", r.base64Encode) utilsObj.Set("base64Encode", r.base64Encode)
utilsObj.Set("base64Decode", r.base64Decode) utilsObj.Set("base64Decode", r.base64Decode)
@@ -299,6 +283,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt) utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("generateKey", r.cryptoGenerateKey) utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
vm.Set("utils", utilsObj) vm.Set("utils", utilsObj)
// Log object (already set in extension_manager.go, but we can enhance it) // Log object (already set in extension_manager.go, but we can enhance it)
@@ -309,7 +294,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
logObj.Set("error", r.logError) logObj.Set("error", r.logError)
vm.Set("log", logObj) vm.Set("log", logObj)
// Go backend functions
gobackendObj := vm.NewObject() gobackendObj := vm.NewObject()
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
vm.Set("gobackend", gobackendObj) vm.Set("gobackend", gobackendObj)
@@ -320,16 +304,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
// Global fetch() - Promise-style HTTP API (browser-compatible) // Global fetch() - Promise-style HTTP API (browser-compatible)
vm.Set("fetch", r.fetchPolyfill) vm.Set("fetch", r.fetchPolyfill)
// Global atob/btoa - Base64 encoding (browser-compatible)
vm.Set("atob", r.atobPolyfill) vm.Set("atob", r.atobPolyfill)
vm.Set("btoa", r.btoaPolyfill) vm.Set("btoa", r.btoaPolyfill)
// TextEncoder/TextDecoder constructors
r.registerTextEncoderDecoder(vm) r.registerTextEncoderDecoder(vm)
// URL class for URL parsing
r.registerURLClass(vm) r.registerURLClass(vm)
// JSON global (browser-compatible)
r.registerJSONGlobal(vm) r.registerJSONGlobal(vm)
} }
+1 -28
View File
@@ -18,7 +18,6 @@ import (
// ==================== Auth API (OAuth Support) ==================== // ==================== Auth API (OAuth Support) ====================
// authOpenUrl requests Flutter to open an OAuth URL
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
callbackURL = call.Arguments[1].String() callbackURL = call.Arguments[1].String()
} }
// Store pending auth request for Flutter to pick up
pendingAuthRequestsMu.Lock() pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID, ExtensionID: r.extensionID,
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
} }
pendingAuthRequestsMu.Unlock() pendingAuthRequestsMu.Unlock()
// Update auth state
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
extensionAuthState[r.extensionID] = state extensionAuthState[r.extensionID] = state
} }
state.PendingAuthURL = authURL state.PendingAuthURL = authURL
state.AuthCode = "" // Clear any previous auth code state.AuthCode = ""
extensionAuthStateMu.Unlock() extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL) GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
@@ -61,7 +58,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
}) })
} }
// authGetCode gets the auth code (set by Flutter after OAuth callback)
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -114,7 +110,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// authClear clears all auth state for the extension
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID) delete(extensionAuthState, r.extensionID)
@@ -138,7 +133,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
// Check if token is expired
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -146,7 +140,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(state.IsAuthenticated) return r.vm.ToValue(state.IsAuthenticated)
} }
// authGetTokens returns current tokens (for extension to use in API calls)
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -182,16 +175,13 @@ func generatePKCEVerifier(length int) (string, error) {
length = 128 length = 128
} }
// Generate random bytes
bytes := make([]byte, length) bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {
return "", err return "", err
} }
// Use base64url encoding without padding (RFC 7636 compliant)
verifier := base64.RawURLEncoding.EncodeToString(bytes) verifier := base64.RawURLEncoding.EncodeToString(bytes)
// Trim to exact length
if len(verifier) > length { if len(verifier) > length {
verifier = verifier[:length] verifier = verifier[:length]
} }
@@ -199,15 +189,12 @@ func generatePKCEVerifier(length int) (string, error) {
return verifier, nil return verifier, nil
} }
// generatePKCEChallenge generates a code challenge from verifier using S256 method
func generatePKCEChallenge(verifier string) string { func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier)) hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636) // Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:]) return base64.RawURLEncoding.EncodeToString(hash[:])
} }
// authGeneratePKCE generates a PKCE code verifier and challenge pair
// Returns: { verifier: string, challenge: string, method: "S256" }
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
// Default length is 64 characters // Default length is 64 characters
length := 64 length := 64
@@ -227,7 +214,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
challenge := generatePKCEChallenge(verifier) challenge := generatePKCEChallenge(verifier)
// Store in auth state for later use
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -247,7 +233,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
}) })
} }
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -405,7 +390,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Get stored PKCE verifier
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
var verifier string var verifier string
@@ -421,7 +405,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Validate domain
if err := r.validateDomain(tokenURL); err != nil { if err := r.validateDomain(tokenURL); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -429,7 +412,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Build token request body
formData := url.Values{} formData := url.Values{}
formData.Set("grant_type", "authorization_code") formData.Set("grant_type", "authorization_code")
formData.Set("client_id", clientID) formData.Set("client_id", clientID)
@@ -439,14 +421,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
formData.Set("redirect_uri", redirectURI) formData.Set("redirect_uri", redirectURI)
} }
// Add extra params
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok { if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
for k, v := range extraParams { for k, v := range extraParams {
formData.Set(k, fmt.Sprintf("%v", v)) formData.Set(k, fmt.Sprintf("%v", v))
} }
} }
// Make token request
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode())) req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -475,7 +455,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Parse response
var tokenResp map[string]interface{} var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil { if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -485,7 +464,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Check for error in response
if errMsg, ok := tokenResp["error"].(string); ok { if errMsg, ok := tokenResp["error"].(string); ok {
errDesc, _ := tokenResp["error_description"].(string) errDesc, _ := tokenResp["error_description"].(string)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -495,7 +473,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Extract tokens
accessToken, _ := tokenResp["access_token"].(string) accessToken, _ := tokenResp["access_token"].(string)
refreshToken, _ := tokenResp["refresh_token"].(string) refreshToken, _ := tokenResp["refresh_token"].(string)
expiresIn, _ := tokenResp["expires_in"].(float64) expiresIn, _ := tokenResp["expires_in"].(float64)
@@ -508,7 +485,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Store tokens in auth state
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists = extensionAuthState[r.extensionID] state, exists = extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -521,14 +497,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 { if expiresIn > 0 {
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
} }
// Clear PKCE after successful exchange
state.PKCEVerifier = "" state.PKCEVerifier = ""
state.PKCEChallenge = "" state.PKCEChallenge = ""
extensionAuthStateMu.Unlock() extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID) GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
// Return full token response
result := map[string]interface{}{ result := map[string]interface{}{
"success": true, "success": true,
"access_token": accessToken, "access_token": accessToken,
@@ -538,7 +512,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 { if expiresIn > 0 {
result["expires_in"] = expiresIn result["expires_in"] = expiresIn
} }
// Include any additional fields from response
if scope, ok := tokenResp["scope"].(string); ok { if scope, ok := tokenResp["scope"].(string); ok {
result["scope"] = scope result["scope"] = scope
} }
-6
View File
@@ -31,14 +31,12 @@ var (
ffmpegCommandID int64 ffmpegCommandID int64
) )
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand { func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
ffmpegCommandsMu.RLock() ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock() defer ffmpegCommandsMu.RUnlock()
return ffmpegCommands[commandID] return ffmpegCommands[commandID]
} }
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) { func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
ffmpegCommandsMu.Lock() ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock() defer ffmpegCommandsMu.Unlock()
@@ -50,14 +48,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str
} }
} }
// ClearFFmpegCommand removes a completed FFmpeg command
func ClearFFmpegCommand(commandID string) { func ClearFFmpegCommand(commandID string) {
ffmpegCommandsMu.Lock() ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock() defer ffmpegCommandsMu.Unlock()
delete(ffmpegCommands, commandID) delete(ffmpegCommands, commandID)
} }
// ffmpegExecute queues an FFmpeg command for execution by Flutter
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -118,7 +114,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
} }
} }
// ffmpegGetInfo gets audio file information using FFprobe
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -147,7 +142,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
}) })
} }
// ffmpegConvert is a helper for common conversion operations
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
+1 -39
View File
@@ -21,8 +21,6 @@ var (
allowedDownloadDirsMu sync.RWMutex allowedDownloadDirsMu sync.RWMutex
) )
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
// This should be called by the Go backend when setting up download paths
func SetAllowedDownloadDirs(dirs []string) { func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock() allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock() defer allowedDownloadDirsMu.Unlock()
@@ -30,7 +28,6 @@ func SetAllowedDownloadDirs(dirs []string) {
GoLog("[Extension] Allowed download directories set: %v\n", dirs) GoLog("[Extension] Allowed download directories set: %v\n", dirs)
} }
// AddAllowedDownloadDir adds a directory to the allowed list
func AddAllowedDownloadDir(dir string) { func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock() allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock() defer allowedDownloadDirsMu.Unlock()
@@ -40,7 +37,6 @@ func AddAllowedDownloadDir(dir string) {
} }
} }
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
func isPathInAllowedDirs(absPath string) bool { func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock() allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock() defer allowedDownloadDirsMu.RUnlock()
@@ -62,36 +58,28 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission") return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
} }
// Clean and resolve the path
cleanPath := filepath.Clean(path) cleanPath := filepath.Clean(path)
// SECURITY: Block absolute paths by default
// Only allow if path is in explicitly allowed download directories
if filepath.IsAbs(cleanPath) { if filepath.IsAbs(cleanPath) {
absPath, err := filepath.Abs(cleanPath) absPath, err := filepath.Abs(cleanPath)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid path: %w", err) return "", fmt.Errorf("invalid path: %w", err)
} }
// Check if path is in allowed download directories
if isPathInAllowedDirs(absPath) { if isPathInAllowedDirs(absPath) {
return absPath, nil return absPath, nil
} }
// Block all other absolute paths
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox") return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
} }
// For relative paths, join with data directory (extension's sandbox)
fullPath := filepath.Join(r.dataDir, cleanPath) fullPath := filepath.Join(r.dataDir, cleanPath)
// Resolve to absolute path
absPath, err := filepath.Abs(fullPath) absPath, err := filepath.Abs(fullPath)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid path: %w", err) return "", fmt.Errorf("invalid path: %w", err)
} }
// Ensure path is within data directory (prevent path traversal)
absDataDir, _ := filepath.Abs(r.dataDir) absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) { if !strings.HasPrefix(absPath, absDataDir) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
@@ -100,8 +88,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return absPath, nil return absPath, nil
} }
// fileDownload downloads a file from URL to the specified path
// Supports progress callback via options.onProgress
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -113,7 +99,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
outputPath := call.Arguments[1].String() outputPath := call.Arguments[1].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -121,7 +106,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Validate output path (allows absolute paths for download queue)
fullPath, err := r.validatePath(outputPath) fullPath, err := r.validatePath(outputPath)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -130,20 +114,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Get options if provided
var onProgress goja.Callable var onProgress goja.Callable
var headers map[string]string var headers map[string]string
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export() optionsObj := call.Arguments[2].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok { if opts, ok := optionsObj.(map[string]interface{}); ok {
// Extract headers
if h, ok := opts["headers"].(map[string]interface{}); ok { if h, ok := opts["headers"].(map[string]interface{}); ok {
headers = make(map[string]string) headers = make(map[string]string)
for k, v := range h { for k, v := range h {
headers[k] = fmt.Sprintf("%v", v) headers[k] = fmt.Sprintf("%v", v)
} }
} }
// Extract onProgress callback
if progressVal, ok := opts["onProgress"]; ok { if progressVal, ok := opts["onProgress"]; ok {
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok { if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
onProgress = callable onProgress = callable
@@ -152,7 +133,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
} }
// Create directory if needed
dir := filepath.Dir(fullPath) dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -161,7 +141,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Create HTTP request
req, err := http.NewRequest("GET", urlStr, nil) req, err := http.NewRequest("GET", urlStr, nil)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -170,7 +149,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
@@ -178,7 +156,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
} }
// Download file
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -195,7 +172,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Create output file
out, err := os.Create(fullPath) out, err := os.Create(fullPath)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -205,12 +181,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
defer out.Close() defer out.Close()
// Get content length for progress
contentLength := resp.ContentLength contentLength := resp.ContentLength
// Copy content with progress reporting
var written int64 var written int64
buf := make([]byte, 32*1024) // 32KB buffer buf := make([]byte, 32*1024)
for { for {
nr, er := resp.Body.Read(buf) nr, er := resp.Body.Read(buf)
if nr > 0 { if nr > 0 {
@@ -235,7 +209,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Report progress
if onProgress != nil && contentLength > 0 { if onProgress != nil && contentLength > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
} }
@@ -260,7 +233,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// fileExists checks if a file exists in the sandbox
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -276,7 +248,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(err == nil) return r.vm.ToValue(err == nil)
} }
// fileDelete deletes a file in the sandbox
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -306,7 +277,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
}) })
} }
// fileRead reads a file from the sandbox
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -338,7 +308,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
}) })
} }
// fileWrite writes data to a file in the sandbox
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -380,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
}) })
} }
// fileCopy copies a file within the sandbox
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -408,7 +376,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// Read source file
data, err := os.ReadFile(fullSrc) data, err := os.ReadFile(fullSrc)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -417,7 +384,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// Create destination directory if needed
dir := filepath.Dir(fullDst) dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -426,7 +392,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// Write to destination
if err := os.WriteFile(fullDst, data, 0644); err != nil { if err := os.WriteFile(fullDst, data, 0644); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -440,7 +405,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// fileMove moves/renames a file within the sandbox
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -468,7 +432,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
}) })
} }
// Create destination directory if needed
dir := filepath.Dir(fullDst) dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -490,7 +453,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
}) })
} }
// fileGetSize returns the size of a file in bytes
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
-16
View File
@@ -52,7 +52,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -60,7 +59,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
// Get headers if provided
headers := make(map[string]string) headers := make(map[string]string)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export() headersObj := call.Arguments[1].Export()
@@ -71,7 +69,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
} }
} }
// Create request
req, err := http.NewRequest("GET", urlStr, nil) req, err := http.NewRequest("GET", urlStr, nil)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -97,7 +94,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -134,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -175,7 +170,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
} }
} }
// Create request
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr)) req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -204,7 +198,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -231,8 +224,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
// Usage: http.request(url, options) where options = { method, body, headers }
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -242,7 +233,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -326,7 +316,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -354,7 +343,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call) return r.httpMethodShortcut("PUT", call)
} }
@@ -364,7 +352,6 @@ func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call) return r.httpMethodShortcut("DELETE", call)
} }
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call) return r.httpMethodShortcut("PATCH", call)
} }
@@ -380,7 +367,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -465,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -492,7 +477,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}) })
} }
// httpClearCookies clears all cookies for this extension
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok { if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock() jar.mu.Lock()
-4
View File
@@ -143,19 +143,16 @@ func (r *ExtensionRuntime) getSaltPath() string {
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath() saltPath := r.getSaltPath()
// Try to read existing salt
salt, err := os.ReadFile(saltPath) salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 { if err == nil && len(salt) == 32 {
return salt, nil return salt, nil
} }
// Generate new random salt (32 bytes)
salt = make([]byte, 32) salt = make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil { if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err) return nil, fmt.Errorf("failed to generate salt: %w", err)
} }
// Save salt to file
if err := os.WriteFile(saltPath, salt, 0600); err != nil { if err := os.WriteFile(saltPath, salt, 0600); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err) return nil, fmt.Errorf("failed to save salt: %w", err)
} }
@@ -214,7 +211,6 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
return err return err
} }
// Encrypt the data
key, err := r.getEncryptionKey() key, err := r.getEncryptionKey()
if err != nil { if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err) return fmt.Errorf("failed to get encryption key: %w", err)
+26 -3
View File
@@ -12,6 +12,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@@ -94,7 +95,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
} }
// Get key - can be string or array of bytes
var keyBytes []byte var keyBytes []byte
keyArg := call.Arguments[0].Export() keyArg := call.Arguments[0].Export()
switch k := keyArg.(type) { switch k := keyArg.(type) {
@@ -113,7 +113,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
} }
// Get message - can be string or array of bytes
var msgBytes []byte var msgBytes []byte
msgArg := call.Arguments[1].Export() msgArg := call.Arguments[1].Export()
switch m := msgArg.(type) { switch m := msgArg.(type) {
@@ -136,7 +135,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
mac.Write(msgBytes) mac.Write(msgBytes)
result := mac.Sum(nil) result := mac.Sum(nil)
// Convert to array of numbers for JavaScript
jsArray := make([]interface{}, len(result)) jsArray := make([]interface{}, len(result))
for i, b := range result { for i, b := range result {
jsArray[i] = int(b) jsArray[i] = int(b)
@@ -268,6 +266,11 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
}) })
} }
// randomUserAgent returns a random Chrome User-Agent string
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent())
}
// ==================== Logging Functions ==================== // ==================== Logging Functions ====================
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
@@ -369,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(buildFilenameFromTemplate(template, metadata)) return vm.ToValue(buildFilenameFromTemplate(template, metadata))
}) })
// Expose getLocalTime - returns device local time info
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
now := time.Now()
_, offsetSeconds := now.Zone()
offsetMinutes := offsetSeconds / 60
return vm.ToValue(map[string]interface{}{
"year": now.Year(),
"month": int(now.Month()),
"day": now.Day(),
"hour": now.Hour(),
"minute": now.Minute(),
"second": now.Second(),
"weekday": int(now.Weekday()),
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
"timezone": now.Location().String(),
"timestamp": now.Unix(),
})
})
} }
-4
View File
@@ -42,7 +42,6 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
return fmt.Errorf("failed to create settings directory: %w", err) return fmt.Errorf("failed to create settings directory: %w", err)
} }
// Load all existing settings
return s.loadAllSettings() return s.loadAllSettings()
} }
@@ -99,7 +98,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error { func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
// Create directory if needed
dir := filepath.Dir(settingsPath) dir := filepath.Dir(settingsPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
@@ -160,7 +158,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
s.settings[extensionID][key] = value s.settings[extensionID][key] = value
// Persist to disk
return s.saveSettings(extensionID, s.settings[extensionID]) return s.saveSettings(extensionID, s.settings[extensionID])
} }
@@ -198,7 +195,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
delete(s.settings, extensionID) delete(s.settings, extensionID)
// Remove settings file
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) { if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
return err return err
-2
View File
@@ -35,7 +35,6 @@ type StoreExtension struct {
Downloads int `json:"downloads"` Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"` MinAppVersion string `json:"min_app_version,omitempty"`
// Alternative camelCase fields (for flexibility)
DisplayNameAlt string `json:"displayName,omitempty"` DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"` DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"` IconURLAlt string `json:"iconUrl,omitempty"`
@@ -332,7 +331,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return fmt.Errorf("download returned HTTP %d", resp.StatusCode) return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
} }
// Create destination file
out, err := os.Create(destPath) out, err := os.Create(destPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file: %w", err) return fmt.Errorf("failed to create file: %w", err)
-4
View File
@@ -6,10 +6,8 @@ import (
"strings" "strings"
) )
// Invalid filename characters for Android/Windows/Linux
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(filename string) string { func sanitizeFilename(filename string) string {
sanitized := invalidChars.ReplaceAllString(filename, "_") sanitized := invalidChars.ReplaceAllString(filename, "_")
@@ -30,7 +28,6 @@ func sanitizeFilename(filename string) string {
return sanitized return sanitized
} }
// buildFilenameFromTemplate builds a filename from template and metadata
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string { func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
if template == "" { if template == "" {
template = "{artist} - {title}" template = "{artist} - {title}"
@@ -91,7 +88,6 @@ func formatDiscNumber(n int) string {
return fmt.Sprintf("%d", n) return fmt.Sprintf("%d", n)
} }
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
func extractYear(date string) string { func extractYear(date string) string {
if len(date) >= 4 { if len(date) >= 4 {
return date[:4] return date[:4]
+7 -54
View File
@@ -15,61 +15,23 @@ import (
"time" "time"
) )
// HTTP utility functions for consistent request handling across all downloaders
// getRandomUserAgent generates a random Windows Chrome User-Agent string // getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility // Uses modern Chrome format with build and patch numbers
// Windows 11 still reports as "Windows NT 10.0" for compatibility
func getRandomUserAgent() string { func getRandomUserAgent() string {
winMajor := rand.Intn(2) + 10 // Chrome version 120-145 (modern range)
chromeVersion := rand.Intn(26) + 120
chromeVersion := rand.Intn(25) + 100 chromeBuild := rand.Intn(1500) + 6000
chromeBuild := rand.Intn(1500) + 3000 chromePatch := rand.Intn(200) + 100
chromePatch := rand.Intn(65) + 60
return fmt.Sprintf( return fmt.Sprintf(
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
winMajor,
chromeVersion, chromeVersion,
chromeBuild, chromeBuild,
chromePatch, chromePatch,
) )
} }
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
// func getRandomMacUserAgent() string {
// macMajor := rand.Intn(4) + 11 // macOS 11-14
// macMinor := rand.Intn(5) + 4 // Minor 4-8
// webkitMajor := rand.Intn(7) + 530
// webkitMinor := rand.Intn(7) + 30
// chromeMajor := rand.Intn(25) + 80
// chromeBuild := rand.Intn(1500) + 3000
// chromePatch := rand.Intn(65) + 60
// safariMajor := rand.Intn(7) + 530
// safariMinor := rand.Intn(6) + 30
//
// return fmt.Sprintf(
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
// macMajor,
// macMinor,
// webkitMajor,
// webkitMinor,
// chromeMajor,
// chromeBuild,
// chromePatch,
// safariMajor,
// safariMinor,
// )
// }
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
// func getRandomDesktopUserAgent() string {
// if rand.Intn(2) == 0 {
// return getRandomUserAgent() // Windows
// }
// return getRandomMacUserAgent() // Mac
// }
const ( const (
DefaultTimeout = 60 * time.Second DefaultTimeout = 60 * time.Second
DownloadTimeout = 120 * time.Second DownloadTimeout = 120 * time.Second
@@ -107,7 +69,6 @@ var downloadClient = &http.Client{
Timeout: DownloadTimeout, Timeout: DownloadTimeout,
} }
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{ return &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
@@ -128,7 +89,6 @@ func CloseIdleConnections() {
sharedTransport.CloseIdleConnections() sharedTransport.CloseIdleConnections()
} }
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
// Also checks for ISP blocking on errors // Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
@@ -147,7 +107,6 @@ type RetryConfig struct {
BackoffFactor float64 BackoffFactor float64
} }
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig { func DefaultRetryConfig() RetryConfig {
return RetryConfig{ return RetryConfig{
MaxRetries: DefaultMaxRetries, MaxRetries: DefaultMaxRetries,
@@ -253,13 +212,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr) return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
} }
// calculateNextDelay calculates the next delay with exponential backoff
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration { func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor) nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
return min(nextDelay, config.MaxDelay) return min(nextDelay, config.MaxDelay)
} }
// getRetryAfterDuration parses Retry-After header and returns duration
// Returns 60 seconds as default if header is missing or invalid // Returns 60 seconds as default if header is missing or invalid
func getRetryAfterDuration(resp *http.Response) time.Duration { func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After") retryAfter := resp.Header.Get("Retry-After")
@@ -302,7 +259,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
return body, nil return body, nil
} }
// ValidateResponse checks if response is valid (non-nil, status 2xx)
func ValidateResponse(resp *http.Response) error { func ValidateResponse(resp *http.Response) error {
if resp == nil { if resp == nil {
return fmt.Errorf("response is nil") return fmt.Errorf("response is nil")
@@ -331,7 +287,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
return msg return msg
} }
// ISPBlockingError represents an error caused by ISP blocking
type ISPBlockingError struct { type ISPBlockingError struct {
Domain string Domain string
Reason string Reason string
@@ -447,7 +402,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil return nil
} }
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
// Returns true if ISP blocking was detected // Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL) ispErr := IsISPBlocking(err, requestURL)
@@ -485,7 +439,6 @@ func extractDomain(rawURL string) string {
return "unknown" return "unknown"
} }
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
// If ISP blocking is detected, returns a more descriptive error // If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil { if err == nil {
-4
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
) )
// LogEntry represents a single log entry
type LogEntry struct { type LogEntry struct {
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
Level string `json:"level"` Level string `json:"level"`
@@ -16,7 +15,6 @@ type LogEntry struct {
Message string `json:"message"` Message string `json:"message"`
} }
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
type LogBuffer struct { type LogBuffer struct {
entries []LogEntry entries []LogEntry
maxSize int maxSize int
@@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer {
return globalLogBuffer return globalLogBuffer
} }
// SetLoggingEnabled enables or disables logging
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
@@ -55,7 +52,6 @@ func (lb *LogBuffer) IsLoggingEnabled() bool {
return lb.loggingEnabled return lb.loggingEnabled
} }
// Add adds a log entry to the buffer
func (lb *LogBuffer) Add(level, tag, message string) { func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
+24 -29
View File
@@ -6,6 +6,8 @@ import (
"math" "math"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -13,13 +15,9 @@ import (
"time" "time"
) )
// ========================================
// Lyrics Cache with TTL
// ========================================
const ( const (
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours lyricsCacheTTL = 24 * time.Hour
durationToleranceSec = 10.0 // Duration matching tolerance in seconds durationToleranceSec = 10.0
) )
type lyricsCacheEntry struct { type lyricsCacheEntry struct {
@@ -37,10 +35,8 @@ var globalLyricsCache = &lyricsCache{
} }
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string { func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
// Normalize key: lowercase, trim spaces
normalizedArtist := strings.ToLower(strings.TrimSpace(artist)) normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
normalizedTrack := strings.ToLower(strings.TrimSpace(track)) normalizedTrack := strings.ToLower(strings.TrimSpace(track))
// Round duration to nearest 10 seconds for cache key
roundedDuration := math.Round(durationSec/10) * 10 roundedDuration := math.Round(durationSec/10) * 10
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration) return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
} }
@@ -55,7 +51,6 @@ func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsRes
return nil, false return nil, false
} }
// Check if expired
if time.Now().After(entry.expiresAt) { if time.Now().After(entry.expiresAt) {
return nil, false return nil, false
} }
@@ -74,7 +69,6 @@ func (c *lyricsCache) Set(artist, track string, durationSec float64, response *L
} }
} }
// CleanExpired removes expired entries from cache
func (c *lyricsCache) CleanExpired() int { func (c *lyricsCache) CleanExpired() int {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -90,7 +84,6 @@ func (c *lyricsCache) CleanExpired() int {
return cleaned return cleaned
} }
// Size returns current cache size
func (c *lyricsCache) Size() int { func (c *lyricsCache) Size() int {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -130,9 +123,7 @@ type LyricsClient struct {
func NewLyricsClient() *LyricsClient { func NewLyricsClient() *LyricsClient {
return &LyricsClient{ return &LyricsClient{
httpClient: &http.Client{ httpClient: NewHTTPClientWithTimeout(15 * time.Second),
Timeout: 15 * time.Second,
},
} }
} }
@@ -172,8 +163,6 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
return c.parseLRCLibResponse(&lrcResp), nil return c.parseLRCLibResponse(&lrcResp), nil
} }
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
// durationSec: track duration in seconds, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
baseURL := "https://lrclib.net/api/search" baseURL := "https://lrclib.net/api/search"
params := url.Values{} params := url.Values{}
@@ -206,13 +195,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return nil, fmt.Errorf("no lyrics found") return nil, fmt.Errorf("no lyrics found")
} }
// Filter and score results based on duration matching and synced lyrics
bestMatch := c.findBestMatch(results, durationSec) bestMatch := c.findBestMatch(results, durationSec)
if bestMatch != nil { if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil return c.parseLRCLibResponse(bestMatch), nil
} }
// Fallback: return first result with synced lyrics
for _, result := range results { for _, result := range results {
if result.SyncedLyrics != "" { if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil return c.parseLRCLibResponse(&result), nil
@@ -222,7 +209,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return c.parseLRCLibResponse(&results[0]), nil return c.parseLRCLibResponse(&results[0]), nil
} }
// findBestMatch finds the best matching lyrics based on duration and sync status
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse var bestPlain *LRCLibResponse
@@ -230,11 +216,9 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
for i := range results { for i := range results {
result := &results[i] result := &results[i]
// Check duration match if target duration is provided
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec) durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches { if durationMatches {
// Prefer synced lyrics over plain
if result.SyncedLyrics != "" && bestSynced == nil { if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil { } else if result.PlainLyrics != "" && bestPlain == nil {
@@ -243,20 +227,17 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
} }
} }
// Return synced first, then plain
if bestSynced != nil { if bestSynced != nil {
return bestSynced return bestSynced
} }
return bestPlain return bestPlain
} }
// durationMatches checks if two durations are within tolerance
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool { func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration) diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec return diff <= durationToleranceSec
} }
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
// durationSec: track duration in seconds for matching, use 0 to skip duration matching // durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Check cache first // Check cache first
@@ -394,7 +375,6 @@ func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
} }
// convertToLRC converts lyrics to LRC format string (without metadata headers)
// Use convertToLRCWithMetadata for full LRC with headers // Use convertToLRCWithMetadata for full LRC with headers
// Kept for potential future use // Kept for potential future use
// func convertToLRC(lyrics *LyricsResponse) string { // func convertToLRC(lyrics *LyricsResponse) string {
@@ -421,8 +401,6 @@ func msToLRCTimestamp(ms int64) string {
// return builder.String() // return builder.String()
// } // }
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
// Includes [ti:], [ar:], [by:] headers
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 { if lyrics == nil || len(lyrics.Lines) == 0 {
return "" return ""
@@ -430,13 +408,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
var builder strings.Builder var builder strings.Builder
// Add metadata headers
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n") builder.WriteString("[by:SpotiFLAC-Mobile]\n")
builder.WriteString("\n") builder.WriteString("\n")
// Add lyrics lines
if lyrics.SyncType == "LINE_SYNCED" { if lyrics.SyncType == "LINE_SYNCED" {
for _, line := range lyrics.Lines { for _, line := range lyrics.Lines {
if line.Words == "" { if line.Words == "" {
@@ -485,3 +461,22 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result) return strings.TrimSpace(result)
} }
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" {
return "", fmt.Errorf("empty LRC content")
}
dir := filepath.Dir(audioFilePath)
ext := filepath.Ext(audioFilePath)
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
lrcFilePath := filepath.Join(dir, baseName+".lrc")
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
return "", fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath)
return lrcFilePath, nil
}
+367 -89
View File
@@ -1,7 +1,10 @@
package gobackend package gobackend
import ( import (
"bytes"
"encoding/binary"
"fmt" "fmt"
"io"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -11,7 +14,6 @@ import (
"github.com/go-flac/go-flac" "github.com/go-flac/go-flac"
) )
// Metadata represents track metadata for embedding
type Metadata struct { type Metadata struct {
Title string Title string
Artist string Artist string
@@ -24,12 +26,11 @@ type Metadata struct {
ISRC string ISRC string
Description string Description string
Lyrics string Lyrics string
Genre string // Music genre (e.g., "Rock", "Pop", "Electronic") Genre string
Label string // Record label (ORGANIZATION tag in Vorbis) Label string
Copyright string // Copyright information Copyright string
} }
// EmbedMetadata embeds metadata into a FLAC file
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -138,8 +139,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
return f.Save(filePath) return f.Save(filePath)
} }
// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes
// This avoids file permission issues on Android by not requiring a temp file
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error { func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -337,7 +336,6 @@ func fileExists(path string) bool {
return err == nil return err == nil
} }
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation
func EmbedLyrics(filePath string, lyrics string) error { func EmbedLyrics(filePath string, lyrics string) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -375,11 +373,9 @@ func EmbedLyrics(filePath string, lyrics string) error {
return f.Save(filePath) return f.Save(filePath)
} }
// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation
// This is used for extension downloads where the file is already downloaded
func EmbedGenreLabel(filePath string, genre, label string) error { func EmbedGenreLabel(filePath string, genre, label string) error {
if genre == "" && label == "" { if genre == "" && label == "" {
return nil // Nothing to embed return nil
} }
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
@@ -451,16 +447,12 @@ func ExtractLyrics(filePath string) (string, error) {
return "", fmt.Errorf("no lyrics found in file") return "", fmt.Errorf("no lyrics found in file")
} }
// AudioQuality represents audio quality info from a FLAC file
type AudioQuality struct { type AudioQuality struct {
BitDepth int `json:"bit_depth"` BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"` TotalSamples int64 `json:"total_samples"`
} }
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
// For M4A files, it delegates to GetM4AQuality
func GetAudioQuality(filePath string) (AudioQuality, error) { func GetAudioQuality(filePath string) (AudioQuality, error) {
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -526,78 +518,170 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms // EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error { func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
data, err := os.ReadFile(filePath) input, err := os.Open(filePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to read M4A file: %w", err) return fmt.Errorf("failed to open M4A file: %w", err)
} }
defer input.Close()
moovPos := findAtom(data, "moov", 0) info, err := input.Stat()
if moovPos < 0 { if err != nil {
return fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
if err != nil {
return fmt.Errorf("failed to find moov atom: %w", err)
}
if !moovFound {
return fmt.Errorf("moov atom not found in M4A file") return fmt.Errorf("moov atom not found in M4A file")
} }
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3])) moovContentStart := moovHeader.offset + moovHeader.headerSize
udtaPos := findAtom(data, "udta", moovPos+8) moovContentSize := moovHeader.size - moovHeader.headerSize
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate udta atom: %w", err)
}
var metaHeader atomHeader
metaFound := false
if udtaFound {
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate meta atom: %w", err)
}
}
metaAtom := buildMetaAtom(metadata, coverData) metaAtom := buildMetaAtom(metadata, coverData)
metaSize := int64(len(metaAtom))
var newData []byte var delta int64
if udtaPos >= 0 && udtaPos < moovPos+moovSize { var newUdtaSize int64
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3])) switch {
metaPos := findAtom(data, "meta", udtaPos+8) case udtaFound && metaFound:
delta = metaSize - metaHeader.size
newUdtaSize = udtaHeader.size + delta
case udtaFound && !metaFound:
delta = metaSize
newUdtaSize = udtaHeader.size + delta
case !udtaFound:
newUdtaSize = int64(8 + len(metaAtom))
delta = newUdtaSize
}
if metaPos >= 0 && metaPos < udtaPos+udtaSize { newMoovSize := moovHeader.size + delta
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3])) if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
newData = append(newData, data[:metaPos]...) return fmt.Errorf("moov atom exceeds 32-bit size after update")
newData = append(newData, metaAtom...) }
newData = append(newData, data[metaPos+metaSize:]...) if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
} else { return fmt.Errorf("udta atom exceeds 32-bit size after update")
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...) }
newUdtaSize := 8 + len(newUdtaContent) if !udtaFound && newUdtaSize > int64(^uint32(0)) {
newUdta := make([]byte, 4) return fmt.Errorf("udta atom exceeds 32-bit size after update")
newUdta[0] = byte(newUdtaSize >> 24) }
newUdta[1] = byte(newUdtaSize >> 16)
newUdta[2] = byte(newUdtaSize >> 8)
newUdta[3] = byte(newUdtaSize)
newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, newUdtaContent...)
newData = append(newData, data[:udtaPos]...) tempPath := filePath + ".tmp"
newData = append(newData, newUdta...) output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
newData = append(newData, data[udtaPos+udtaSize:]...) if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
cleanupTemp := true
defer func() {
_ = output.Close()
if cleanupTemp {
_ = os.Remove(tempPath)
} }
} else { }()
udtaContent := metaAtom
udtaSize := 8 + len(udtaContent)
newUdta := make([]byte, 4)
newUdta[0] = byte(udtaSize >> 24)
newUdta[1] = byte(udtaSize >> 16)
newUdta[2] = byte(udtaSize >> 8)
newUdta[3] = byte(udtaSize)
newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, udtaContent...)
insertPos := moovPos + moovSize switch {
newData = append(newData, data[:insertPos]...) case udtaFound && metaFound:
newData = append(newData, newUdta...) if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
newData = append(newData, data[insertPos:]...) return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
metaEnd := metaHeader.offset + metaHeader.size
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
return err
}
case udtaFound && !metaFound:
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
insertPos := udtaHeader.offset + udtaHeader.size
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
return err
}
case !udtaFound:
newUdtaAtom := buildUdtaAtom(metaAtom)
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
moovEnd := moovHeader.offset + moovHeader.size
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(newUdtaAtom); err != nil {
return fmt.Errorf("failed to write udta atom: %w", err)
}
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
return err
}
} }
newMoovSize := moovSize + len(newData) - len(data) if err := output.Close(); err != nil {
newData[moovPos] = byte(newMoovSize >> 24) return fmt.Errorf("failed to close temp file: %w", err)
newData[moovPos+1] = byte(newMoovSize >> 16)
newData[moovPos+2] = byte(newMoovSize >> 8)
newData[moovPos+3] = byte(newMoovSize)
if err := os.WriteFile(filePath, newData, 0644); err != nil {
return fmt.Errorf("failed to write M4A file: %w", err)
} }
_ = input.Close()
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
if err := os.Rename(tempPath, filePath); err != nil {
return fmt.Errorf("failed to move temp file: %w", err)
}
cleanupTemp = false
fmt.Printf("[M4A] Metadata embedded successfully\n") fmt.Printf("[M4A] Metadata embedded successfully\n")
return nil return nil
} }
// findAtom finds an atom by name starting from offset
func findAtom(data []byte, name string, offset int) int { func findAtom(data []byte, name string, offset int) int {
for i := offset; i < len(data)-8; { for i := offset; i < len(data)-8; {
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3])) size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
@@ -689,7 +773,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
return metaAtom return metaAtom
} }
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
func buildTextAtom(name, value string) []byte { func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value) valueBytes := []byte(value)
@@ -741,7 +824,6 @@ func buildTrackNumberAtom(track, total int) []byte {
return atom return atom
} }
// buildDiscNumberAtom builds disk atom
func buildDiscNumberAtom(disc, total int) []byte { func buildDiscNumberAtom(disc, total int) []byte {
dataAtom := []byte{ dataAtom := []byte{
0, 0, 0, 22, // size 0, 0, 0, 22, // size
@@ -767,9 +849,9 @@ func buildDiscNumberAtom(disc, total int) []byte {
// buildCoverAtom builds covr atom with image data // buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte { func buildCoverAtom(coverData []byte) []byte {
imageType := byte(13) // default JPEG imageType := byte(13)
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' { if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14 // PNG imageType = 14
} }
dataSize := 16 + len(coverData) dataSize := 16 + len(coverData)
@@ -779,8 +861,8 @@ func buildCoverAtom(coverData []byte) []byte {
dataAtom[2] = byte(dataSize >> 8) dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize) dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...) dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG dataAtom = append(dataAtom, 0, 0, 0, imageType)
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale dataAtom = append(dataAtom, 0, 0, 0, 0)
dataAtom = append(dataAtom, coverData...) dataAtom = append(dataAtom, coverData...)
atomSize := 8 + len(dataAtom) atomSize := 8 + len(dataAtom)
@@ -795,30 +877,226 @@ func buildCoverAtom(coverData []byte) []byte {
return atom return atom
} }
// GetM4AQuality reads audio quality from M4A file
func GetM4AQuality(filePath string) (AudioQuality, error) { func GetM4AQuality(filePath string) (AudioQuality, error) {
data, err := os.ReadFile(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err) return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err)
} }
defer f.Close()
moovPos := findAtom(data, "moov", 0) info, err := f.Stat()
if moovPos < 0 { if err != nil {
return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err)
}
if !moovFound {
return AudioQuality{}, fmt.Errorf("moov atom not found") return AudioQuality{}, fmt.Errorf("moov atom not found")
} }
for i := moovPos; i < len(data)-20; i++ { moovStart := moovHeader.offset
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" { moovEnd := moovHeader.offset + moovHeader.size
if i+24 < len(data) {
sampleRate := int(data[i+22])<<8 | int(data[i+23]) sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
bitDepth := 16 if err != nil {
if string(data[i:i+4]) == "alac" { return AudioQuality{}, err
bitDepth = 24
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
}
} }
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file") buf := make([]byte, 24)
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
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
type atomHeader struct {
offset int64
size int64
headerSize int64
typ string
}
func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) {
if offset+8 > fileSize {
return atomHeader{}, io.ErrUnexpectedEOF
}
headerBuf := make([]byte, 8)
if _, err := f.ReadAt(headerBuf, offset); err != nil {
return atomHeader{}, err
}
size32 := binary.BigEndian.Uint32(headerBuf[0:4])
typ := string(headerBuf[4:8])
if size32 == 1 {
if offset+16 > fileSize {
return atomHeader{}, io.ErrUnexpectedEOF
}
extBuf := make([]byte, 8)
if _, err := f.ReadAt(extBuf, offset+8); err != nil {
return atomHeader{}, err
}
size64 := binary.BigEndian.Uint64(extBuf)
return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil
}
return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil
}
func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) {
if size <= 0 {
return atomHeader{}, false, nil
}
end := start + size
pos := start
for pos+8 <= end {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return atomHeader{}, false, err
}
atomSize := header.size
if atomSize == 0 {
atomSize = end - pos
}
if atomSize < header.headerSize {
return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ)
}
header.size = atomSize
if header.typ == target {
return header, true, nil
}
pos += atomSize
}
return atomHeader{}, false, nil
}
func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
if len(typ) != 4 {
return fmt.Errorf("invalid atom type: %s", typ)
}
if headerSize == 16 {
header := make([]byte, 16)
binary.BigEndian.PutUint32(header[0:4], 1)
copy(header[4:8], []byte(typ))
binary.BigEndian.PutUint64(header[8:16], uint64(size))
_, err := w.Write(header)
return err
}
if size > int64(^uint32(0)) {
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
}
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte(typ))
_, err := w.Write(header)
return err
}
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
if length <= 0 {
return nil
}
if _, err := src.Seek(offset, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek source: %w", err)
}
if _, err := io.CopyN(dst, src, length); err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
func buildUdtaAtom(metaAtom []byte) []byte {
size := 8 + len(metaAtom)
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte("udta"))
return append(header, metaAtom...)
}
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
const chunkSize = 64 * 1024
patternMP4A := []byte("mp4a")
patternALAC := []byte("alac")
var tail []byte
readPos := start
for readPos < end {
toRead := end - readPos
if toRead > chunkSize {
toRead = chunkSize
}
buf := make([]byte, toRead)
n, err := f.ReadAt(buf, readPos)
if err != nil && err != io.EOF {
return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err)
}
if n == 0 {
break
}
data := append(tail, buf[:n]...)
mp4aIdx := bytes.Index(data, patternMP4A)
alacIdx := bytes.Index(data, patternALAC)
bestIdx := -1
bestType := ""
switch {
case mp4aIdx >= 0 && alacIdx >= 0:
if mp4aIdx <= alacIdx {
bestIdx = mp4aIdx
bestType = "mp4a"
} else {
bestIdx = alacIdx
bestType = "alac"
}
case mp4aIdx >= 0:
bestIdx = mp4aIdx
bestType = "mp4a"
case alacIdx >= 0:
bestIdx = alacIdx
bestType = "alac"
}
if bestIdx >= 0 {
absolute := readPos - int64(len(tail)) + int64(bestIdx)
if absolute+24 > fileSize {
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
return absolute, bestType, nil
}
if len(data) >= 3 {
tail = append([]byte{}, data[len(data)-3:]...)
} else {
tail = append([]byte{}, data...)
}
readPos += int64(n)
}
return 0, "", fmt.Errorf("audio info not found in M4A file")
} }
+2 -38
View File
@@ -6,11 +6,6 @@ import (
"time" "time"
) )
// ========================================
// ISRC to Track ID Cache
// ========================================
// TrackIDCacheEntry holds cached track ID with metadata
type TrackIDCacheEntry struct { type TrackIDCacheEntry struct {
TidalTrackID int64 TidalTrackID int64
QobuzTrackID int64 QobuzTrackID int64
@@ -18,7 +13,6 @@ type TrackIDCacheEntry struct {
ExpiresAt time.Time ExpiresAt time.Time
} }
// TrackIDCache caches ISRC to track ID mappings
type TrackIDCache struct { type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry cache map[string]*TrackIDCacheEntry
mu sync.RWMutex mu sync.RWMutex
@@ -30,7 +24,6 @@ var (
trackIDCacheOnce sync.Once trackIDCacheOnce sync.Once
) )
// GetTrackIDCache returns the global track ID cache
func GetTrackIDCache() *TrackIDCache { func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() { trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{ globalTrackIDCache = &TrackIDCache{
@@ -41,7 +34,6 @@ func GetTrackIDCache() *TrackIDCache {
return globalTrackIDCache return globalTrackIDCache
} }
// Get retrieves a cached entry by ISRC
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -53,7 +45,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
return entry return entry
} }
// SetTidal caches Tidal track ID for an ISRC
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -67,7 +58,6 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
entry.ExpiresAt = time.Now().Add(c.ttl) entry.ExpiresAt = time.Now().Add(c.ttl)
} }
// SetQobuz caches Qobuz track ID for an ISRC
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -81,7 +71,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
entry.ExpiresAt = time.Now().Add(c.ttl) entry.ExpiresAt = time.Now().Add(c.ttl)
} }
// SetAmazon caches Amazon track ID for an ISRC
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -95,24 +84,18 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
entry.ExpiresAt = time.Now().Add(c.ttl) entry.ExpiresAt = time.Now().Add(c.ttl)
} }
// Clear removes all cached entries
func (c *TrackIDCache) Clear() { func (c *TrackIDCache) Clear() {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.cache = make(map[string]*TrackIDCacheEntry) c.cache = make(map[string]*TrackIDCacheEntry)
} }
// Size returns the number of cached entries
func (c *TrackIDCache) Size() int { func (c *TrackIDCache) Size() int {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return len(c.cache) return len(c.cache)
} }
// ========================================
// Parallel Download Helper
// ========================================
// ParallelDownloadResult holds results from parallel operations // ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct { type ParallelDownloadResult struct {
CoverData []byte CoverData []byte
@@ -122,9 +105,6 @@ type ParallelDownloadResult struct {
LyricsErr error LyricsErr error
} }
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
// durationMs: track duration in milliseconds for lyrics matching
func FetchCoverAndLyricsParallel( func FetchCoverAndLyricsParallel(
coverURL string, coverURL string,
maxQualityCover bool, maxQualityCover bool,
@@ -153,7 +133,6 @@ func FetchCoverAndLyricsParallel(
}() }()
} }
// Fetch lyrics in parallel
if embedLyrics { if embedLyrics {
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -180,11 +159,6 @@ func FetchCoverAndLyricsParallel(
return result return result
} }
// ========================================
// Pre-warm Cache for Album/Playlist
// ========================================
// PreWarmCacheRequest represents a track to pre-warm cache for
type PreWarmCacheRequest struct { type PreWarmCacheRequest struct {
ISRC string ISRC string
TrackName string TrackName string
@@ -193,8 +167,6 @@ type PreWarmCacheRequest struct {
Service string // "tidal", "qobuz", "amazon" Service string // "tidal", "qobuz", "amazon"
} }
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
// This runs in background while user is viewing the track list
func PreWarmTrackCache(requests []PreWarmCacheRequest) { func PreWarmTrackCache(requests []PreWarmCacheRequest) {
if len(requests) == 0 { if len(requests) == 0 {
return return
@@ -214,8 +186,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
wg.Add(1) wg.Add(1)
go func(r PreWarmCacheRequest) { go func(r PreWarmCacheRequest) {
defer wg.Done() defer wg.Done()
semaphore <- struct{}{} // Acquire semaphore <- struct{}{}
defer func() { <-semaphore }() // Release defer func() { <-semaphore }()
switch r.Service { switch r.Service {
case "tidal": case "tidal":
@@ -259,12 +231,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
} }
} }
// ========================================
// Exported Functions for Flutter
// ========================================
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error { func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest var requests []PreWarmCacheRequest
@@ -272,13 +238,11 @@ func PreWarmCache(tracksJSON string) error {
return nil return nil
} }
// ClearTrackCache clears the track ID cache
func ClearTrackCache() { func ClearTrackCache() {
GetTrackIDCache().Clear() GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared") fmt.Println("[Cache] Track ID cache cleared")
} }
// GetCacheSize returns the current cache size
func GetCacheSize() int { func GetCacheSize() int {
return GetTrackIDCache().Size() return GetTrackIDCache().Size()
} }
+4 -20
View File
@@ -6,8 +6,6 @@ import (
"time" "time"
) )
// DownloadProgress represents current download progress
// Now unified - returns data from multi-progress system
type DownloadProgress struct { type DownloadProgress struct {
CurrentFile string `json:"current_file"` CurrentFile string `json:"current_file"`
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
@@ -15,21 +13,19 @@ type DownloadProgress struct {
BytesTotal int64 `json:"bytes_total"` BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"` BytesReceived int64 `json:"bytes_received"`
IsDownloading bool `json:"is_downloading"` IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed" Status string `json:"status"`
} }
// ItemProgress represents progress for a single download item
type ItemProgress struct { type ItemProgress struct {
ItemID string `json:"item_id"` ItemID string `json:"item_id"`
BytesTotal int64 `json:"bytes_total"` BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"` BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0 Progress float64 `json:"progress"`
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s SpeedMBps float64 `json:"speed_mbps"`
IsDownloading bool `json:"is_downloading"` IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed" Status string `json:"status"`
} }
// MultiProgress holds progress for multiple concurrent downloads
type MultiProgress struct { type MultiProgress struct {
Items map[string]*ItemProgress `json:"items"` Items map[string]*ItemProgress `json:"items"`
} }
@@ -38,12 +34,10 @@ var (
downloadDir string downloadDir string
downloadDirMu sync.RWMutex downloadDirMu sync.RWMutex
// Multi-download progress tracking (unified system)
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex multiMu sync.RWMutex
) )
// getProgress returns current download progress from multi-progress system
func getProgress() DownloadProgress { func getProgress() DownloadProgress {
multiMu.RLock() multiMu.RLock()
defer multiMu.RUnlock() defer multiMu.RUnlock()
@@ -62,7 +56,6 @@ func getProgress() DownloadProgress {
return DownloadProgress{} return DownloadProgress{}
} }
// GetMultiProgress returns progress for all active downloads as JSON
func GetMultiProgress() string { func GetMultiProgress() string {
multiMu.RLock() multiMu.RLock()
defer multiMu.RUnlock() defer multiMu.RUnlock()
@@ -74,7 +67,6 @@ func GetMultiProgress() string {
return string(jsonBytes) return string(jsonBytes)
} }
// GetItemProgress returns progress for a specific item as JSON
func GetItemProgress(itemID string) string { func GetItemProgress(itemID string) string {
multiMu.RLock() multiMu.RLock()
defer multiMu.RUnlock() defer multiMu.RUnlock()
@@ -201,14 +193,6 @@ func setDownloadDir(path string) error {
return nil return nil
} }
// getDownloadDir returns the default download directory
// Kept for potential future use
// func getDownloadDir() string {
// downloadDirMu.RLock()
// defer downloadDirMu.RUnlock()
// return downloadDir
// }
// ItemProgressWriter wraps io.Writer to track download progress for a specific item // ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct { type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) } writer interface{ Write([]byte) (int, error) }
+30 -47
View File
@@ -17,7 +17,6 @@ import (
"time" "time"
) )
// QobuzDownloader handles Qobuz downloads
type QobuzDownloader struct { type QobuzDownloader struct {
client *http.Client client *http.Client
appID string appID string
@@ -29,7 +28,6 @@ var (
qobuzDownloaderOnce sync.Once qobuzDownloaderOnce sync.Once
) )
// QobuzTrack represents a Qobuz track
type QobuzTrack struct { type QobuzTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -50,7 +48,6 @@ type QobuzTrack struct {
} `json:"performer"` } `json:"performer"`
} }
// qobuzArtistsMatch checks if the artist names are similar enough
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
@@ -93,9 +90,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return false return false
} }
// qobuzSplitArtists splits artist string by common separators
func qobuzSplitArtists(artists string) []string { func qobuzSplitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|")
@@ -154,7 +149,6 @@ func qobuzSameWordsUnordered(a, b string) bool {
return true return true
} }
// qobuzTitlesMatch checks if track titles are similar enough
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle))
@@ -164,12 +158,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
cleanExpected := qobuzCleanTitle(normExpected) cleanExpected := qobuzCleanTitle(normExpected)
cleanFound := qobuzCleanTitle(normFound) cleanFound := qobuzCleanTitle(normFound)
@@ -177,14 +169,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Check if cleaned versions contain each other
if cleanExpected != "" && cleanFound != "" { if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
return true return true
} }
} }
// Extract core title (before any parentheses/brackets)
coreExpected := qobuzExtractCoreTitle(normExpected) coreExpected := qobuzExtractCoreTitle(normExpected)
coreFound := qobuzExtractCoreTitle(normFound) coreFound := qobuzExtractCoreTitle(normFound)
@@ -225,19 +215,15 @@ func qobuzExtractCoreTitle(title string) string {
return strings.TrimSpace(title[:cutIdx]) return strings.TrimSpace(title[:cutIdx])
} }
// qobuzCleanTitle removes common suffixes from track titles for comparison
func qobuzCleanTitle(title string) string { func qobuzCleanTitle(title string) string {
cleaned := title cleaned := title
// Remove content in parentheses/brackets that are version indicators
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
versionPatterns := []string{ versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single", "remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended", "album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo", "club mix", "remix", "live", "acoustic", "demo",
} }
// Remove parenthetical content if it contains version indicators
for { for {
startParen := strings.LastIndex(cleaned, "(") startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")") endParen := strings.LastIndex(cleaned, ")")
@@ -258,7 +244,6 @@ func qobuzCleanTitle(title string) string {
break break
} }
// Same for brackets
for { for {
startBracket := strings.LastIndex(cleaned, "[") startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]") endBracket := strings.LastIndex(cleaned, "]")
@@ -279,7 +264,6 @@ func qobuzCleanTitle(title string) string {
break break
} }
// Remove trailing " - version" patterns
dashPatterns := []string{ dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit", " - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix", " - live", " - acoustic", " - demo", " - remix",
@@ -290,7 +274,6 @@ func qobuzCleanTitle(title string) string {
} }
} }
// Remove multiple spaces
for strings.Contains(cleaned, " ") { for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ") cleaned = strings.ReplaceAll(cleaned, " ", " ")
} }
@@ -350,7 +333,6 @@ func containsQueryQobuz(queries []string, query string) bool {
return false return false
} }
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
func NewQobuzDownloader() *QobuzDownloader { func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() { qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{ globalQobuzDownloader = &QobuzDownloader{
@@ -361,7 +343,6 @@ func NewQobuzDownloader() *QobuzDownloader {
return globalQobuzDownloader return globalQobuzDownloader
} }
// GetTrackByID fetches track info directly by Qobuz track ID
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Qobuz API: /track/get?track_id=XXX // Qobuz API: /track/get?track_id=XXX
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
@@ -412,7 +393,6 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
// SearchTrackByISRC searches for a track by ISRC
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
@@ -455,7 +435,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification) // expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
@@ -500,7 +479,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches)) GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
if len(isrcMatches) > 0 { if len(isrcMatches) > 0 {
// Verify duration if provided
if expectedDurationSec > 0 { if expectedDurationSec > 0 {
var durationVerifiedMatches []*QobuzTrack var durationVerifiedMatches []*QobuzTrack
for _, track := range isrcMatches { for _, track := range isrcMatches {
@@ -508,7 +486,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
} }
// Allow 10 seconds tolerance
if durationDiff <= 10 { if durationDiff <= 10 {
durationVerifiedMatches = append(durationVerifiedMatches, track) durationVerifiedMatches = append(durationVerifiedMatches, track)
} }
@@ -520,14 +497,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return durationVerifiedMatches[0], nil return durationVerifiedMatches[0], nil
} }
// ISRC matches but duration doesn't
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration) isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration) expectedDurationSec, isrcMatches[0].Duration)
} }
// No duration to verify, return first match
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil return isrcMatches[0], nil
} }
@@ -539,17 +514,14 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, 0) return q.SearchTrackByISRCWithDuration(isrc, 0)
} }
// SearchTrackByMetadata searches for a track using artist name and track name
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
} }
// SearchTrackByMetadataWithDuration searches for a track with duration verification
// Now includes romaji conversion for Japanese text (same as Tidal) // Now includes romaji conversion for Japanese text (same as Tidal)
// Also includes title verification to prevent wrong song downloads // Also includes title verification to prevent wrong song downloads
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
@@ -688,7 +660,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
if len(durationMatches) > 0 { if len(durationMatches) > 0 {
// Return best quality among duration matches
for _, track := range durationMatches { for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n", GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
@@ -701,7 +672,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return durationMatches[0], nil return durationMatches[0], nil
} }
// No duration match found
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec) return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
} }
@@ -731,8 +701,6 @@ type qobuzAPIResult struct {
duration time.Duration duration time.Duration
} }
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 { if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available") return "", "", fmt.Errorf("no APIs available")
@@ -748,9 +716,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
go func(api string) { go func(api string) {
reqStart := time.Now() reqStart := time.Now()
client := &http.Client{ client := NewHTTPClientWithTimeout(15 * time.Second)
Timeout: 15 * time.Second,
}
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
@@ -839,8 +805,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
} }
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
apis := q.GetAvailableAPIs() apis := q.GetAvailableAPIs()
if len(apis) == 0 { if len(apis) == 0 {
@@ -938,7 +902,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return nil return nil
} }
// QobuzDownloadResult contains download result with quality info
type QobuzDownloadResult struct { type QobuzDownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
@@ -952,7 +915,6 @@ type QobuzDownloadResult struct {
ISRC string ISRC string
} }
// downloadFromQobuz downloads a track using the request parameters
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader() downloader := NewQobuzDownloader()
@@ -1110,13 +1072,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
albumName = req.AlbumName albumName = req.AlbumName
} }
// Use track number from request if available, otherwise from Qobuz API
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber
}
metadata := Metadata{ metadata := Metadata{
Title: track.Title, Title: track.Title,
Artist: track.Performer.Name, Artist: track.Performer.Name,
Album: albumName, Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
Date: track.Album.ReleaseDate, Date: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
ISRC: track.ISRC, ISRC: track.ISRC,
@@ -1135,13 +1103,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) fmt.Printf("Warning: failed to embed metadata: %v\n", err)
} }
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) lyricsMode := req.LyricsMode
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if lyricsMode == "" {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) lyricsMode = "embed"
} else { }
fmt.Println("[Qobuz] Lyrics embedded successfully")
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Qobuz] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
} }
} else if req.EmbedLyrics { } else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch") fmt.Println("[Qobuz] No lyrics available from parallel fetch")
@@ -1158,7 +1141,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name, Artist: track.Performer.Name,
Album: track.Album.Title, Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate, ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations DiscNumber: req.DiscNumber, // Qobuz track struct limitations
ISRC: track.ISRC, ISRC: track.ISRC,
}, nil }, nil
-8
View File
@@ -5,7 +5,6 @@ import (
"time" "time"
) )
// RateLimiter implements a sliding window rate limiter
type RateLimiter struct { type RateLimiter struct {
mu sync.Mutex mu sync.Mutex
maxRequests int maxRequests int
@@ -13,7 +12,6 @@ type RateLimiter struct {
timestamps []time.Time timestamps []time.Time
} }
// NewRateLimiter creates a new rate limiter with specified max requests per window
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter { func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
return &RateLimiter{ return &RateLimiter{
maxRequests: maxRequests, maxRequests: maxRequests,
@@ -22,8 +20,6 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
} }
} }
// WaitForSlot blocks until a request is allowed under the rate limit
// Returns immediately if under the limit, otherwise waits until a slot is available
func (r *RateLimiter) WaitForSlot() { func (r *RateLimiter) WaitForSlot() {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -70,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
} }
} }
// TryAcquire attempts to acquire a slot without blocking
// Returns true if successful, false if rate limit would be exceeded
func (r *RateLimiter) TryAcquire() bool { func (r *RateLimiter) TryAcquire() bool {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -87,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
return false return false
} }
// Available returns the number of requests available in the current window
func (r *RateLimiter) Available() int { func (r *RateLimiter) Available() int {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -99,7 +92,6 @@ func (r *RateLimiter) Available() int {
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10) // Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
var songLinkRateLimiter = NewRateLimiter(9, time.Minute) var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
// GetSongLinkRateLimiter returns the global SongLink rate limiter
func GetSongLinkRateLimiter() *RateLimiter { func GetSongLinkRateLimiter() *RateLimiter {
return songLinkRateLimiter return songLinkRateLimiter
} }
-11
View File
@@ -5,7 +5,6 @@ import (
"unicode" "unicode"
) )
// Hiragana to Romaji mapping
var hiraganaToRomaji = map[rune]string{ var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o", 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko", 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
} }
// Katakana to Romaji mapping
var katakanaToRomaji = map[rune]string{ var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o", 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko", 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
'ヴ': "vu", 'ヴ': "vu",
} }
// Combination mappings for きゃ, しゃ, etc.
var combinationHiragana = map[string]string{ var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo", "きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho", "しゃ": "sha", "しゅ": "shu", "しょ": "sho",
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
"ウィ": "wi", "ウェ": "we", "ウォ": "wo", "ウィ": "wi", "ウェ": "we", "ウォ": "wo",
} }
// ContainsJapanese checks if a string contains Japanese characters
func ContainsJapanese(s string) bool { func ContainsJapanese(s string) bool {
for _, r := range s { for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) { if isHiragana(r) || isKatakana(r) || isKanji(r) {
@@ -114,8 +110,6 @@ func isKanji(r rune) bool {
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A (r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
} }
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
func JapaneseToRomaji(text string) string { func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) { if !ContainsJapanese(text) {
return text return text
@@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string {
return result.String() return result.String()
} }
// BuildSearchQuery creates a search query from track name and artist
// Converts Japanese to romaji if present
func BuildSearchQuery(trackName, artistName string) string { func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji // Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName) trackRomaji := JapaneseToRomaji(trackName)
@@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string {
return strings.TrimSpace(artistClean + " " + trackClean) return strings.TrimSpace(artistClean + " " + trackClean)
} }
// cleanSearchQuery removes special characters that might interfere with search
func cleanSearchQuery(s string) string { func cleanSearchQuery(s string) string {
var result strings.Builder var result strings.Builder
for _, r := range s { for _, r := range s {
@@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string {
return strings.TrimSpace(result.String()) return strings.TrimSpace(result.String())
} }
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
// This is useful for creating search queries that work better with Tidal's search
func CleanToASCII(s string) string { func CleanToASCII(s string) string {
var result strings.Builder var result strings.Builder
for _, r := range s { for _, r := range s {
-15
View File
@@ -11,12 +11,10 @@ import (
"time" "time"
) )
// SongLinkClient handles song.link API interactions
type SongLinkClient struct { type SongLinkClient struct {
client *http.Client client *http.Client
} }
// TrackAvailability represents track availability on different platforms
type TrackAvailability struct { type TrackAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
@@ -35,7 +33,6 @@ var (
songLinkClientOnce sync.Once songLinkClientOnce sync.Once
) )
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
func NewSongLinkClient() *SongLinkClient { func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() { songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{ globalSongLinkClient = &SongLinkClient{
@@ -45,7 +42,6 @@ func NewSongLinkClient() *SongLinkClient {
return globalSongLinkClient return globalSongLinkClient
} }
// CheckTrackAvailability checks track availability on streaming platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
if spotifyTrackID == "" { if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty") return nil, fmt.Errorf("spotify track ID is empty")
@@ -126,7 +122,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return availability, nil return availability, nil
} }
// GetStreamingURLs gets streaming URLs for a Spotify track
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) { func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "") availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil { if err != nil {
@@ -191,7 +186,6 @@ func extractDeezerIDFromURL(deezerURL string) string {
return "" return ""
} }
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "") availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil { if err != nil {
@@ -213,7 +207,6 @@ type AlbumAvailability struct {
DeezerID string `json:"deezer_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
} }
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter // Use global rate limiter
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
@@ -283,11 +276,6 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
return availability.DeezerID, nil return availability.DeezerID, nil
} }
// ========================================
// Deezer ID Support - Query SongLink using Deezer as source
// ========================================
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
// This is useful when we have Deezer metadata and want to find the track on other platforms // This is useful when we have Deezer metadata and want to find the track on other platforms
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
if deezerTrackID == "" { if deezerTrackID == "" {
@@ -374,7 +362,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return availability, nil return availability, nil
} }
// CheckAvailabilityByPlatform checks track availability using any supported platform
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc. // platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
// entityType: "song" or "album" // entityType: "song" or "album"
// entityID: the ID on that platform // entityID: the ID on that platform
@@ -472,7 +459,6 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
return "" return ""
} }
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) { func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil { if err != nil {
@@ -500,7 +486,6 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
return availability.TidalURL, nil return availability.TidalURL, nil
} }
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) { func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil { if err != nil {
+20 -62
View File
@@ -24,7 +24,6 @@ const (
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search" searchBaseURL = "https://api.spotify.com/v1/search"
// Cache TTL settings
artistCacheTTL = 10 * time.Minute artistCacheTTL = 10 * time.Minute
searchCacheTTL = 5 * time.Minute searchCacheTTL = 5 * time.Minute
albumCacheTTL = 10 * time.Minute albumCacheTTL = 10 * time.Minute
@@ -32,7 +31,6 @@ const (
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
// cacheEntry holds cached data with expiration
type cacheEntry struct { type cacheEntry struct {
data interface{} data interface{}
expiresAt time.Time expiresAt time.Time
@@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt) return time.Now().After(e.expiresAt)
} }
// SpotifyMetadataClient handles Spotify API interactions
type SpotifyMetadataClient struct { type SpotifyMetadataClient struct {
httpClient *http.Client httpClient *http.Client
clientID string clientID string
clientSecret string clientSecret string
cachedToken string cachedToken string
tokenExpiresAt time.Time tokenExpiresAt time.Time
tokenMu sync.Mutex // Protects token cache for concurrent access tokenMu sync.Mutex
rng *rand.Rand rng *rand.Rand
rngMu sync.Mutex rngMu sync.Mutex
userAgent string userAgent string
// Caches to reduce API calls artistCache map[string]*cacheEntry
artistCache map[string]*cacheEntry // key: artistID searchCache map[string]*cacheEntry
searchCache map[string]*cacheEntry // key: query+type albumCache map[string]*cacheEntry
albumCache map[string]*cacheEntry // key: albumID
cacheMu sync.RWMutex cacheMu sync.RWMutex
} }
// Custom credentials storage (set from Flutter)
var ( var (
customClientID string customClientID string
customClientSecret string customClientSecret string
@@ -79,7 +74,6 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
customClientSecret = clientSecret customClientSecret = clientSecret
} }
// HasSpotifyCredentials checks if Spotify credentials are configured
func HasSpotifyCredentials() bool { func HasSpotifyCredentials() bool {
credentialsMu.RLock() credentialsMu.RLock()
defer credentialsMu.RUnlock() defer credentialsMu.RUnlock()
@@ -114,8 +108,6 @@ func getCredentials() (string, string, error) {
return "", "", ErrNoSpotifyCredentials return "", "", ErrNoSpotifyCredentials
} }
// NewSpotifyMetadataClient creates a new Spotify client
// Returns error if credentials are not configured
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
clientID, clientSecret, err := getCredentials() clientID, clientSecret, err := getCredentials()
if err != nil { if err != nil {
@@ -137,7 +129,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
return c, nil return c, nil
} }
// TrackMetadata represents track information
type TrackMetadata struct { type TrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"` Artists string `json:"artists"`
@@ -155,7 +146,6 @@ type TrackMetadata struct {
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
} }
// AlbumTrackMetadata holds per-track info for album/playlist
type AlbumTrackMetadata struct { type AlbumTrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"` Artists string `json:"artists"`
@@ -172,28 +162,26 @@ type AlbumTrackMetadata struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"` AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"` AlbumURL string `json:"album_url,omitempty"`
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation AlbumType string `json:"album_type,omitempty"`
} }
// AlbumInfoMetadata holds album information
type AlbumInfoMetadata struct { type AlbumInfoMetadata struct {
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
Name string `json:"name"` Name string `json:"name"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
Artists string `json:"artists"` Artists string `json:"artists"`
ArtistId string `json:"artist_id,omitempty"`
Images string `json:"images"` Images string `json:"images"`
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"` // Record label name Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"` // Copyright information Copyright string `json:"copyright,omitempty"`
} }
// AlbumResponsePayload is the response for album requests
type AlbumResponsePayload struct { type AlbumResponsePayload struct {
AlbumInfo AlbumInfoMetadata `json:"album_info"` AlbumInfo AlbumInfoMetadata `json:"album_info"`
TrackList []AlbumTrackMetadata `json:"track_list"` TrackList []AlbumTrackMetadata `json:"track_list"`
} }
// PlaylistInfoMetadata holds playlist information
type PlaylistInfoMetadata struct { type PlaylistInfoMetadata struct {
Tracks struct { Tracks struct {
Total int `json:"total"` Total int `json:"total"`
@@ -205,13 +193,11 @@ type PlaylistInfoMetadata struct {
} `json:"owner"` } `json:"owner"`
} }
// PlaylistResponsePayload is the response for playlist requests
type PlaylistResponsePayload struct { type PlaylistResponsePayload struct {
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
TrackList []AlbumTrackMetadata `json:"track_list"` TrackList []AlbumTrackMetadata `json:"track_list"`
} }
// ArtistInfoMetadata holds artist information
type ArtistInfoMetadata struct { type ArtistInfoMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -220,7 +206,6 @@ type ArtistInfoMetadata struct {
Popularity int `json:"popularity"` Popularity int `json:"popularity"`
} }
// ArtistAlbumMetadata holds album info for artist discography
type ArtistAlbumMetadata struct { type ArtistAlbumMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -231,24 +216,20 @@ type ArtistAlbumMetadata struct {
Artists string `json:"artists"` Artists string `json:"artists"`
} }
// ArtistResponsePayload is the response for artist requests
type ArtistResponsePayload struct { type ArtistResponsePayload struct {
ArtistInfo ArtistInfoMetadata `json:"artist_info"` ArtistInfo ArtistInfoMetadata `json:"artist_info"`
Albums []ArtistAlbumMetadata `json:"albums"` Albums []ArtistAlbumMetadata `json:"albums"`
} }
// TrackResponse is the response for single track requests
type TrackResponse struct { type TrackResponse struct {
Track TrackMetadata `json:"track"` Track TrackMetadata `json:"track"`
} }
// SearchResult represents search results
type SearchResult struct { type SearchResult struct {
Tracks []TrackMetadata `json:"tracks"` Tracks []TrackMetadata `json:"tracks"`
Total int `json:"total"` Total int `json:"total"`
} }
// SearchArtistResult represents an artist in search results
type SearchArtistResult struct { type SearchArtistResult struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -257,7 +238,6 @@ type SearchArtistResult struct {
Popularity int `json:"popularity"` Popularity int `json:"popularity"`
} }
// SearchAllResult represents combined search results for tracks and artists
type SearchAllResult struct { type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"` Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"` Artists []SearchArtistResult `json:"artists"`
@@ -274,7 +254,6 @@ type accessTokenResponse struct {
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
} }
// Internal API response types
type image struct { type image struct {
URL string `json:"url"` URL string `json:"url"`
} }
@@ -300,7 +279,7 @@ type albumSimplified struct {
Images []image `json:"images"` Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"` ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"` Artists []artist `json:"artists"`
AlbumType string `json:"album_type"` // album, single, compilation AlbumType string `json:"album_type"`
} }
type trackFull struct { type trackFull struct {
@@ -315,7 +294,6 @@ type trackFull struct {
Artists []artist `json:"artists"` Artists []artist `json:"artists"`
} }
// GetFilteredData fetches and formats Spotify data
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL) parsed, err := parseSpotifyURI(spotifyURL)
if err != nil { if err != nil {
@@ -341,7 +319,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
} }
} }
// SearchTracks searches for tracks on Spotify
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) { func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
token, err := c.getAccessToken(ctx) token, err := c.getAccessToken(ctx)
if err != nil { if err != nil {
@@ -388,7 +365,6 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
return result, nil return result, nil
} }
// SearchAll searches for tracks and artists on Spotify
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit) cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
@@ -510,7 +486,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Track item structure for pagination
type trackItem struct { type trackItem struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -538,19 +513,25 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
} }
albumImage := firstImageURL(data.Images) albumImage := firstImageURL(data.Images)
// Get first artist ID
var firstArtistId string
if len(data.Artists) > 0 {
firstArtistId = data.Artists[0].ID
}
info := AlbumInfoMetadata{ info := AlbumInfoMetadata{
TotalTracks: data.TotalTracks, TotalTracks: data.TotalTracks,
Name: data.Name, Name: data.Name,
ReleaseDate: data.ReleaseDate, ReleaseDate: data.ReleaseDate,
Artists: joinArtists(data.Artists), Artists: joinArtists(data.Artists),
ArtistId: firstArtistId,
Images: albumImage, Images: albumImage,
} }
// Collect all tracks (including paginated)
allTrackItems := data.Tracks.Items allTrackItems := data.Tracks.Items
nextURL := data.Tracks.Next nextURL := data.Tracks.Next
// Fetch remaining tracks using pagination (no limit)
for nextURL != "" { for nextURL != "" {
var pageData struct { var pageData struct {
Items []trackItem `json:"items"` Items []trackItem `json:"items"`
@@ -572,7 +553,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
trackIDs[i] = item.ID trackIDs[i] = item.ID
} }
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token) isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems)) tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
@@ -612,10 +592,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
return result, nil return result, nil
} }
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
// Similar to Deezer implementation for consistency
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string { func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
const maxParallelISRC = 10 // Max concurrent ISRC fetches const maxParallelISRC = 10
result := make(map[string]string) result := make(map[string]string)
var resultMu sync.Mutex var resultMu sync.Mutex
@@ -624,7 +602,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
return result return result
} }
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, maxParallelISRC) sem := make(chan struct{}, maxParallelISRC)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -633,7 +610,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
go func(id string) { go func(id string) {
defer wg.Done() defer wg.Done()
// Acquire semaphore
select { select {
case sem <- struct{}{}: case sem <- struct{}{}:
defer func() { <-sem }() defer func() { <-sem }()
@@ -654,7 +630,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
} }
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
// First request to get playlist info and first batch of tracks
var data struct { var data struct {
Name string `json:"name"` Name string `json:"name"`
Images []image `json:"images"` Images []image `json:"images"`
@@ -680,10 +655,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
info.Owner.Name = data.Name info.Owner.Name = data.Name
info.Owner.Images = firstImageURL(data.Images) info.Owner.Images = firstImageURL(data.Images)
// Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total) tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items { for _, item := range data.Tracks.Items {
if item.Track == nil { if item.Track == nil {
continue continue
@@ -707,7 +680,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}) })
} }
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
nextURL := data.Tracks.Next nextURL := data.Tracks.Next
for nextURL != "" { for nextURL != "" {
@@ -719,7 +691,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
} }
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil { if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
// Log error but return what we have so far
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err) fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
break break
} }
@@ -766,7 +737,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch artist info
var artistData struct { var artistData struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -789,7 +759,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Popularity: artistData.Popularity, Popularity: artistData.Popularity,
} }
// Fetch artist albums (all types: album, single, compilation)
albums := make([]ArtistAlbumMetadata, 0) albums := make([]ArtistAlbumMetadata, 0)
offset := 0 offset := 0
limit := 50 limit := 50
@@ -829,13 +798,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
}) })
} }
// Check if there are more albums
if albumsData.Next == "" || len(albumsData.Items) < limit { if albumsData.Next == "" || len(albumsData.Items) < limit {
break break
} }
offset += limit offset += limit
// Safety limit to prevent infinite loops
if offset > 500 { if offset > 500 {
break break
} }
@@ -916,7 +883,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
return err return err
} }
// Set headers (same as PC version baseHeaders)
req.Header.Set("User-Agent", c.userAgent) req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.9") req.Header.Set("Accept-Language", "en-US,en;q=0.9")
@@ -952,8 +918,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
c.rngMu.Lock() c.rngMu.Lock()
defer c.rngMu.Unlock() defer c.rngMu.Unlock()
// Use Mac User-Agent format (same as PC version) macMajor := c.rng.Intn(4) + 11
macMajor := c.rng.Intn(4) + 11 // 11-14
macMinor := c.rng.Intn(5) + 4 // 4-8 macMinor := c.rng.Intn(5) + 4 // 4-8
webkitMajor := c.rng.Intn(7) + 530 // 530-536 webkitMajor := c.rng.Intn(7) + 530 // 530-536
webkitMinor := c.rng.Intn(7) + 30 // 30-36 webkitMinor := c.rng.Intn(7) + 30 // 30-36
@@ -978,7 +943,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
} }
// Handle spotify: URI format
if strings.HasPrefix(trimmed, "spotify:") { if strings.HasPrefix(trimmed, "spotify:") {
parts := strings.Split(trimmed, ":") parts := strings.Split(trimmed, ":")
if len(parts) == 3 { if len(parts) == 3 {
@@ -989,13 +953,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
} }
} }
// Handle URL format
parsed, err := url.Parse(trimmed) parsed, err := url.Parse(trimmed)
if err != nil { if err != nil {
return spotifyURI{}, err return spotifyURI{}, err
} }
// Handle embed.spotify.com URLs
if parsed.Host == "embed.spotify.com" { if parsed.Host == "embed.spotify.com" {
if parsed.RawQuery == "" { if parsed.RawQuery == "" {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
@@ -1008,7 +970,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return parseSpotifyURI(embedded) return parseSpotifyURI(embedded)
} }
// Handle plain ID (no scheme/host) - defaults to playlist
if parsed.Scheme == "" && parsed.Host == "" { if parsed.Scheme == "" && parsed.Host == "" {
id := strings.Trim(strings.TrimSpace(parsed.Path), "/") id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
if id == "" { if id == "" {
@@ -1034,7 +995,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
} }
// Skip intl- prefix if present
if strings.HasPrefix(parts[0], "intl-") { if strings.HasPrefix(parts[0], "intl-") {
parts = parts[1:] parts = parts[1:]
} }
@@ -1042,7 +1002,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
} }
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
if len(parts) == 2 { if len(parts) == 2 {
switch parts[0] { switch parts[0] {
case "album", "track", "playlist", "artist": case "album", "track", "playlist", "artist":
@@ -1050,7 +1009,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
} }
} }
// Handle nested playlist URLs: /user/{user}/playlist/{id}
if len(parts) == 4 && parts[2] == "playlist" { if len(parts) == 4 && parts[2] == "playlist" {
return spotifyURI{Type: "playlist", ID: parts[3]}, nil return spotifyURI{Type: "playlist", ID: parts[3]}, nil
} }
+57 -167
View File
@@ -19,7 +19,6 @@ import (
"time" "time"
) )
// TidalDownloader handles Tidal downloads
type TidalDownloader struct { type TidalDownloader struct {
client *http.Client client *http.Client
clientID string clientID string
@@ -35,7 +34,6 @@ var (
tidalDownloaderOnce sync.Once tidalDownloaderOnce sync.Once
) )
// TidalTrack represents a Tidal track
type TidalTrack struct { type TidalTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -60,7 +58,6 @@ type TidalTrack struct {
} `json:"mediaMetadata"` } `json:"mediaMetadata"`
} }
// TidalAPIResponseV2 is the new API response format (version 2.0)
type TidalAPIResponseV2 struct { type TidalAPIResponseV2 struct {
Version string `json:"version"` Version string `json:"version"`
Data struct { Data struct {
@@ -76,7 +73,6 @@ type TidalAPIResponseV2 struct {
} `json:"data"` } `json:"data"`
} }
// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format
type TidalBTSManifest struct { type TidalBTSManifest struct {
MimeType string `json:"mimeType"` MimeType string `json:"mimeType"`
Codecs string `json:"codecs"` Codecs string `json:"codecs"`
@@ -84,7 +80,6 @@ type TidalBTSManifest struct {
URLs []string `json:"urls"` URLs []string `json:"urls"`
} }
// MPD represents DASH manifest structure
type MPD struct { type MPD struct {
XMLName xml.Name `xml:"MPD"` XMLName xml.Name `xml:"MPD"`
Period struct { Period struct {
@@ -105,7 +100,6 @@ type MPD struct {
} `xml:"Period"` } `xml:"Period"`
} }
// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse)
func NewTidalDownloader() *TidalDownloader { func NewTidalDownloader() *TidalDownloader {
tidalDownloaderOnce.Do(func() { tidalDownloaderOnce.Do(func() {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
@@ -150,7 +144,6 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
// GetAccessToken gets Tidal access token (with caching)
func (t *TidalDownloader) GetAccessToken() (string, error) { func (t *TidalDownloader) GetAccessToken() (string, error) {
t.tokenMu.Lock() t.tokenMu.Lock()
defer t.tokenMu.Unlock() defer t.tokenMu.Unlock()
@@ -199,7 +192,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
return result.AccessToken, nil return result.AccessToken, nil
} }
// GetTidalURLFromSpotify gets Tidal URL from Spotify track ID using SongLink
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -239,7 +231,6 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string,
return tidalLink.URL, nil return tidalLink.URL, nil
} }
// GetTrackIDFromURL extracts track ID from Tidal URL
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
parts := strings.Split(tidalURL, "/track/") parts := strings.Split(tidalURL, "/track/")
if len(parts) < 2 { if len(parts) < 2 {
@@ -293,7 +284,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
return &trackInfo, nil return &trackInfo, nil
} }
// SearchTrackByISRC searches for a track by ISRC
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
if err != nil { if err != nil {
@@ -341,30 +331,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// normalizeTitle normalizes a track title for comparison
// Kept for potential future use
// func normalizeTitle(title string) string {
// normalized := strings.ToLower(strings.TrimSpace(title))
//
// // Remove common suffixes in parentheses or brackets
// suffixPatterns := []string{
// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
// " (bonus track)", " (single)", " (album version)", " (radio edit)",
// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
// }
// for _, suffix := range suffixPatterns {
// normalized = strings.TrimSuffix(normalized, suffix)
// }
//
// // Remove multiple spaces
// for strings.Contains(normalized, " ") {
// normalized = strings.ReplaceAll(normalized, " ", " ")
// }
//
// return normalized
// }
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
// Now includes romaji conversion for Japanese text (4 search strategies like PC) // Now includes romaji conversion for Japanese text (4 search strategies like PC)
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
@@ -466,7 +432,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
if len(result.Items) > 0 { if len(result.Items) > 0 {
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
if spotifyISRC != "" { if spotifyISRC != "" {
for i := range result.Items { for i := range result.Items {
if result.Items[i].ISRC == spotifyISRC { if result.Items[i].ISRC == spotifyISRC {
@@ -592,7 +557,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
return bestMatch, nil return bestMatch, nil
} }
// containsQuery checks if a query already exists in the list
func containsQuery(queries []string, query string) bool { func containsQuery(queries []string, query string) bool {
for _, q := range queries { for _, q := range queries {
if q == query { if q == query {
@@ -602,7 +566,6 @@ func containsQuery(queries []string, query string) bool {
return false return false
} }
// SearchTrackByMetadata searches for a track using artist name and track name
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0) return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
} }
@@ -614,7 +577,6 @@ type TidalDownloadInfo struct {
SampleRate int SampleRate int
} }
// tidalAPIResult holds the result from a parallel API request
type tidalAPIResult struct { type tidalAPIResult struct {
apiURL string apiURL string
info TidalDownloadInfo info TidalDownloadInfo
@@ -622,9 +584,7 @@ type tidalAPIResult struct {
duration time.Duration duration time.Duration
} }
// getDownloadURLParallel requests download URL from all APIs in parallel
// Returns the first successful result (supports both v1 and v2 API formats) // Returns the first successful result (supports both v1 and v2 API formats)
// "Siapa cepat dia dapat" - first success wins
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 { if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
@@ -639,9 +599,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
go func(api string) { go func(api string) {
reqStart := time.Now() reqStart := time.Now()
client := &http.Client{ client := NewHTTPClientWithTimeout(15 * time.Second)
Timeout: 15 * time.Second,
}
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
@@ -671,7 +629,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
var v2Response TidalAPIResponseV2 var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
if v2Response.Data.AssetPresentation == "PREVIEW" { if v2Response.Data.AssetPresentation == "PREVIEW" {
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
return return
@@ -715,7 +672,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n", GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration) result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
// Don't return immediately - drain remaining results to avoid goroutine leaks
go func(remaining int) { go func(remaining int) {
for j := 0; j < remaining; j++ { for j := 0; j < remaining; j++ {
<-resultChan <-resultChan
@@ -736,8 +692,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
} }
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) { func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
apis := t.GetAvailableAPIs() apis := t.GetAvailableAPIs()
if len(apis) == 0 { if len(apis) == 0 {
@@ -752,7 +706,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
return info, nil return info, nil
} }
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil { if err != nil {
@@ -859,7 +812,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID) return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
} }
// Initialize item progress for direct downloads
if itemID != "" { if itemID != "" {
StartItemProgress(itemID) StartItemProgress(itemID)
defer CompleteItemProgress(itemID) defer CompleteItemProgress(itemID)
@@ -946,14 +898,10 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n", GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
directURL != "", initURL != "", len(mediaURLs)) directURL != "", initURL != "", len(mediaURLs))
client := &http.Client{ client := NewHTTPClientWithTimeout(120 * time.Second)
Timeout: 120 * time.Second,
}
if directURL != "" { if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
// Note: Progress tracking is initialized by the caller (DownloadFile)
if isDownloadCancelled(itemID) { if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
@@ -1135,7 +1083,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return nil return nil
} }
// TidalDownloadResult contains download result with quality info
type TidalDownloadResult struct { type TidalDownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
@@ -1149,12 +1096,10 @@ type TidalDownloadResult struct {
ISRC string ISRC string
} }
// artistsMatch checks if the artist names are similar enough
func artistsMatch(spotifyArtist, tidalArtist string) bool { func artistsMatch(spotifyArtist, tidalArtist string) bool {
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist)) normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist)) normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
// Exact match
if normSpotify == normTidal { if normSpotify == normTidal {
return true return true
} }
@@ -1164,22 +1109,17 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return true return true
} }
// Split artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
spotifyArtists := splitArtists(normSpotify) spotifyArtists := splitArtists(normSpotify)
tidalArtists := splitArtists(normTidal) tidalArtists := splitArtists(normTidal)
// Check if ANY expected artist matches ANY found artist
for _, exp := range spotifyArtists { for _, exp := range spotifyArtists {
for _, fnd := range tidalArtists { for _, fnd := range tidalArtists {
if exp == fnd { if exp == fnd {
return true return true
} }
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true return true
} }
// Check same words different order
if sameWordsUnordered(exp, fnd) { if sameWordsUnordered(exp, fnd) {
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true return true
@@ -1187,9 +1127,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
} }
} }
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
spotifyLatin := isLatinScript(spotifyArtist) spotifyLatin := isLatinScript(spotifyArtist)
tidalLatin := isLatinScript(tidalArtist) tidalLatin := isLatinScript(tidalArtist)
if spotifyLatin != tidalLatin { if spotifyLatin != tidalLatin {
@@ -1200,9 +1137,7 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return false return false
} }
// splitArtists splits artist string by common separators
func splitArtists(artists string) []string { func splitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|")
@@ -1224,8 +1159,6 @@ func splitArtists(artists string) []string {
return result return result
} }
// sameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func sameWordsUnordered(a, b string) bool { func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a) wordsA := strings.Fields(a)
wordsB := strings.Fields(b) wordsB := strings.Fields(b)
@@ -1235,13 +1168,11 @@ func sameWordsUnordered(a, b string) bool {
return false return false
} }
// Sort and compare
sortedA := make([]string, len(wordsA)) sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB)) sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA) copy(sortedA, wordsA)
copy(sortedB, wordsB) copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ { for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ { for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] { if sortedA[i] > sortedA[j] {
@@ -1261,7 +1192,6 @@ func sameWordsUnordered(a, b string) bool {
return true return true
} }
// titlesMatch checks if track titles are similar enough
func titlesMatch(expectedTitle, foundTitle string) bool { func titlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle))
@@ -1271,7 +1201,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
@@ -1284,7 +1213,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Check if cleaned versions contain each other
if cleanExpected != "" && cleanFound != "" { if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
return true return true
@@ -1299,7 +1227,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script // Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := isLatinScript(expectedTitle) expectedLatin := isLatinScript(expectedTitle)
foundLatin := isLatinScript(foundTitle) foundLatin := isLatinScript(foundTitle)
@@ -1311,9 +1238,7 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return false return false
} }
// extractCoreTitle extracts the main title before any parentheses or brackets
func extractCoreTitle(title string) string { func extractCoreTitle(title string) string {
// Find first occurrence of ( or [
parenIdx := strings.Index(title, "(") parenIdx := strings.Index(title, "(")
bracketIdx := strings.Index(title, "[") bracketIdx := strings.Index(title, "[")
dashIdx := strings.Index(title, " - ") dashIdx := strings.Index(title, " - ")
@@ -1332,18 +1257,15 @@ func extractCoreTitle(title string) string {
return strings.TrimSpace(title[:cutIdx]) return strings.TrimSpace(title[:cutIdx])
} }
// cleanTitle removes common suffixes from track titles for comparison
func cleanTitle(title string) string { func cleanTitle(title string) string {
cleaned := title cleaned := title
// Version indicators to remove from parentheses/brackets
versionPatterns := []string{ versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single", "remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended", "album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo", "club mix", "remix", "live", "acoustic", "demo",
} }
// Remove parenthetical content if it contains version indicators
for { for {
startParen := strings.LastIndex(cleaned, "(") startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")") endParen := strings.LastIndex(cleaned, ")")
@@ -1364,7 +1286,6 @@ func cleanTitle(title string) string {
break break
} }
// Same for brackets
for { for {
startBracket := strings.LastIndex(cleaned, "[") startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]") endBracket := strings.LastIndex(cleaned, "]")
@@ -1385,7 +1306,6 @@ func cleanTitle(title string) string {
break break
} }
// Remove trailing " - version" patterns
dashPatterns := []string{ dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit", " - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix", " - live", " - acoustic", " - demo", " - remix",
@@ -1396,7 +1316,6 @@ func cleanTitle(title string) string {
} }
} }
// Remove multiple spaces
for strings.Contains(cleaned, " ") { for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ") cleaned = strings.ReplaceAll(cleaned, " ", " ")
} }
@@ -1404,48 +1323,28 @@ func cleanTitle(title string) string {
return strings.TrimSpace(cleaned) return strings.TrimSpace(cleaned)
} }
// isLatinScript checks if a string is primarily Latin script
// Returns true for ASCII and Latin Extended characters (European languages)
// Returns false for CJK, Arabic, Cyrillic, etc.
func isLatinScript(s string) bool { func isLatinScript(s string) bool {
for _, r := range s { for _, r := range s {
// Skip common punctuation and numbers
if r < 128 { if r < 128 {
continue continue
} }
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.) if (r >= 0x0100 && r <= 0x024F) ||
// Latin Extended-B: U+0180 to U+024F (r >= 0x1E00 && r <= 0x1EFF) ||
// Latin Extended Additional: U+1E00 to U+1EFF (r >= 0x00C0 && r <= 0x00FF) {
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
continue continue
} }
// CJK ranges - definitely different script if (r >= 0x4E00 && r <= 0x9FFF) ||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs (r >= 0x3040 && r <= 0x309F) ||
(r >= 0x3040 && r <= 0x309F) || // Hiragana (r >= 0x30A0 && r <= 0x30FF) ||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana (r >= 0xAC00 && r <= 0xD7AF) ||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean) (r >= 0x0600 && r <= 0x06FF) ||
(r >= 0x0600 && r <= 0x06FF) || // Arabic (r >= 0x0400 && r <= 0x04FF) {
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
return false return false
} }
} }
return true return true
} }
// isASCIIString checks if a string contains only ASCII characters
// Kept for potential future use
// func isASCIIString(s string) bool {
// for _, r := range s {
// if r > 127 {
// return false
// }
// }
// return true
// }
// downloadFromTidal downloads a track using the request parameters
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader() downloader := NewTidalDownloader()
@@ -1453,16 +1352,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
} }
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
var track *TidalTrack var track *TidalTrack
var err error var err error
// STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority)
if req.TidalID != "" { if req.TidalID != "" {
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID) GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
// Parse track ID (could be a number or extracted from URL)
var trackID int64 var trackID int64
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackInfoByID(trackID) track, err = downloader.GetTrackInfoByID(trackID)
@@ -1475,7 +1371,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
} }
// OPTIMIZATION: Check cache first for track ID
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
@@ -1487,8 +1382,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
} }
// OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC)
// Strategy 1: Search by metadata, match by ISRC (most accurate)
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC) GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
@@ -1510,7 +1403,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
} }
// Strategy 2: Try SongLink if we have Spotify ID
if track == nil && req.SpotifyID != "" { if track == nil && req.SpotifyID != "" {
GoLog("[Tidal] ISRC search failed, trying SongLink...\n") GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
var tidalURL string var tidalURL string
@@ -1545,13 +1437,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
track = nil track = nil
} }
// Verify duration if we have expected duration
if track != nil && expectedDurationSec > 0 { if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
} }
// Allow 3 seconds tolerance (same as PC version)
if durationDiff > 3 { if durationDiff > 3 {
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration) expectedDurationSec, track.Duration)
@@ -1563,11 +1453,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
} }
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
if track == nil { if track == nil {
GoLog("[Tidal] Trying metadata search as last resort...\n") GoLog("[Tidal] Trying metadata search as last resort...\n")
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
// Verify artist AND title for metadata search
if track != nil { if track != nil {
tidalArtist := track.Artist.Name tidalArtist := track.Artist.Name
if len(track.Artists) > 0 { if len(track.Artists) > 0 {
@@ -1578,7 +1466,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
tidalArtist = strings.Join(artistNames, ", ") tidalArtist = strings.Join(artistNames, ", ")
} }
// Verify title first
if !titlesMatch(req.TrackName, track.Title) { if !titlesMatch(req.TrackName, track.Title) {
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.TrackName, track.Title) req.TrackName, track.Title)
@@ -1599,7 +1486,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
} }
// Final verification logging
tidalArtist := track.Artist.Name tidalArtist := track.Artist.Name
if len(track.Artists) > 0 { if len(track.Artists) > 0 {
var artistNames []string var artistNames []string
@@ -1633,7 +1519,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
} }
// Clean up any leftover .tmp files from previous failed downloads
tmpPath := outputPath + ".m4a.tmp" tmpPath := outputPath + ".m4a.tmp"
if _, err := os.Stat(tmpPath); err == nil { if _, err := os.Stat(tmpPath); err == nil {
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
@@ -1651,10 +1536,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
} }
// Log actual quality received
GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate) GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{}) parallelDone := make(chan struct{})
go func() { go func() {
@@ -1670,7 +1553,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
) )
}() }()
// Download audio file with item ID for progress tracking
GoLog("[Tidal] Starting download to: %s\n", outputPath) GoLog("[Tidal] Starting download to: %s\n", outputPath)
GoLog("[Tidal] Download URL type: %s\n", func() string { GoLog("[Tidal] Download URL type: %s\n", func() string {
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") { if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
@@ -1688,7 +1570,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
fmt.Println("[Tidal] Download completed successfully") fmt.Println("[Tidal] Download completed successfully")
// Wait for parallel operations to complete
<-parallelDone <-parallelDone
if req.ItemID != "" { if req.ItemID != "" {
@@ -1701,24 +1582,38 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
actualOutputPath = m4aPath actualOutputPath = m4aPath
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
} else if _, err := os.Stat(outputPath); err != nil { } else if _, err := os.Stat(outputPath); err != nil {
// Neither FLAC nor M4A exists
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
} }
// Embed metadata using parallel-fetched cover data releaseDate := req.ReleaseDate
if releaseDate == "" && track.Album.ReleaseDate != "" {
releaseDate = track.Album.ReleaseDate
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
}
// Use track number from request if available, otherwise from Tidal API
actualTrackNumber := req.TrackNumber
actualDiscNumber := req.DiscNumber
if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber
}
if actualDiscNumber == 0 {
actualDiscNumber = track.VolumeNumber
}
metadata := Metadata{ metadata := Metadata{
Title: req.TrackName, Title: req.TrackName,
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate, Date: releaseDate,
TrackNumber: track.TrackNumber, // Use actual track number from Tidal TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal DiscNumber: actualDiscNumber,
ISRC: track.ISRC, // Use actual ISRC from Tidal ISRC: track.ISRC,
Genre: req.Genre, // From Deezer album metadata Genre: req.Genre,
Label: req.Label, // From Deezer album metadata Label: req.Label,
Copyright: req.Copyright, // From Deezer album metadata Copyright: req.Copyright,
} }
var coverData []byte var coverData []byte
@@ -1727,46 +1622,41 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} }
// Embed metadata based on file type
if strings.HasSuffix(actualOutputPath, ".flac") { if strings.HasSuffix(actualOutputPath, ".flac") {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) fmt.Printf("Warning: failed to embed metadata: %v\n", err)
} }
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) lyricsMode := req.LyricsMode
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { if lyricsMode == "" {
GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) lyricsMode = "embed"
} else { }
fmt.Println("[Tidal] Lyrics embedded successfully")
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Tidal] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Tidal] Lyrics embedded successfully")
}
} }
} else if req.EmbedLyrics { } else if req.EmbedLyrics {
fmt.Println("[Tidal] No lyrics available from parallel fetch") fmt.Println("[Tidal] No lyrics available from parallel fetch")
} }
} else if strings.HasSuffix(actualOutputPath, ".m4a") { } else if strings.HasSuffix(actualOutputPath, ".m4a") {
// Embed metadata to M4A file
// GoLog("[Tidal] Embedding metadata to M4A file...\n")
// Add lyrics to metadata if available
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
// metadata.Lyrics = parallelResult.LyricsLRC
// }
// SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion
// M4A files from DASH are often fragmented and editing metadata might corrupt the container
// structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter.
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
// GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
// } else {
// fmt.Println("[Tidal] M4A metadata embedded successfully")
// }
} }
// Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
return TidalDownloadResult{ return TidalDownloadResult{
@@ -1777,8 +1667,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
Artist: track.Artist.Name, Artist: track.Artist.Name,
Album: track.Album.Title, Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate, ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: actualTrackNumber,
DiscNumber: track.VolumeNumber, DiscNumber: actualDiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
}, nil }, nil
} }
+81
View File
@@ -142,6 +142,27 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "checkDuplicatesBatch":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
let tracksJson = args["tracks"] as? String ?? "[]"
let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
if let error = error { throw error }
return response
case "preBuildDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendPreBuildDuplicateIndex(outputDir, &error)
if let error = error { throw error }
return nil
case "invalidateDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendInvalidateDuplicateIndex(outputDir)
return nil
case "buildFilename": case "buildFilename":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let template = args["template"] as! String let template = args["template"] as! String
@@ -249,6 +270,43 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "checkAvailabilityFromDeezerID":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
if let error = error { throw error }
return response
case "checkAvailabilityByPlatformID":
let args = call.arguments as! [String: Any]
let platform = args["platform"] as! String
let entityType = args["entity_type"] as! String
let entityId = args["entity_id"] as! String
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
if let error = error { throw error }
return response
case "getSpotifyIDFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getTidalURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getAmazonURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "preWarmTrackCache": case "preWarmTrackCache":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String let tracksJson = args["tracks"] as! String
@@ -404,6 +462,14 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let trackJson = args["track"] as? String ?? "{}"
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error }
return response
case "removeExtension": case "removeExtension":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String let extensionId = args["extension_id"] as! String
@@ -605,6 +671,21 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return nil return nil
// Extension Home Feed API
case "getExtensionHomeFeed":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
if let error = error { throw error }
return response
case "getExtensionBrowseCategories":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error)
if let error = error { throw error }
return response
default: default:
throw NSError( throw NSError(
domain: "SpotiFLAC", domain: "SpotiFLAC",
+6 -1
View File
@@ -36,7 +36,12 @@ class SpotiFLACApp extends ConsumerWidget {
Locale? locale; Locale? locale;
if (localeString != 'system') { if (localeString != 'system') {
locale = Locale(localeString); if (localeString.contains('_')) {
final parts = localeString.split('_');
locale = Locale(parts[0], parts[1]);
} else {
locale = Locale(localeString);
}
} }
return DynamicColorWrapper( return DynamicColorWrapper(
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.1.2'; static const String version = '3.2.0';
static const String buildNumber = '61'; static const String buildNumber = '63';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+221
View File
@@ -16,6 +16,7 @@ import 'app_localizations_ko.dart';
import 'app_localizations_nl.dart'; import 'app_localizations_nl.dart';
import 'app_localizations_pt.dart'; import 'app_localizations_pt.dart';
import 'app_localizations_ru.dart'; import 'app_localizations_ru.dart';
import 'app_localizations_tr.dart';
import 'app_localizations_zh.dart'; import 'app_localizations_zh.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -117,6 +118,7 @@ abstract class AppLocalizations {
Locale('pt'), Locale('pt'),
Locale('pt', 'PT'), Locale('pt', 'PT'),
Locale('ru'), Locale('ru'),
Locale('tr'),
Locale('zh'), Locale('zh'),
Locale('zh', 'CN'), Locale('zh', 'CN'),
Locale('zh', 'TW'), Locale('zh', 'TW'),
@@ -278,6 +280,12 @@ abstract class AppLocalizations {
/// **'Single track downloads will appear here'** /// **'Single track downloads will appear here'**
String get historyNoSinglesSubtitle; String get historyNoSinglesSubtitle;
/// Search bar placeholder in history
///
/// In en, this message translates to:
/// **'Search history...'**
String get historySearchHint;
/// Settings screen title /// Settings screen title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -872,6 +880,36 @@ abstract class AppLocalizations {
/// **'Suggest new features for the app'** /// **'Suggest new features for the app'**
String get aboutFeatureRequestSubtitle; String get aboutFeatureRequestSubtitle;
/// Link to Telegram channel
///
/// In en, this message translates to:
/// **'Telegram Channel'**
String get aboutTelegramChannel;
/// Subtitle for Telegram channel
///
/// In en, this message translates to:
/// **'Announcements and updates'**
String get aboutTelegramChannelSubtitle;
/// Link to Telegram chat group
///
/// In en, this message translates to:
/// **'Telegram Community'**
String get aboutTelegramChat;
/// Subtitle for Telegram chat
///
/// In en, this message translates to:
/// **'Chat with other users'**
String get aboutTelegramChatSubtitle;
/// Section for social links
///
/// In en, this message translates to:
/// **'Social'**
String get aboutSocial;
/// Section for support/donation links /// Section for support/donation links
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1688,6 +1726,12 @@ abstract class AppLocalizations {
/// **'Found {count} tracks in CSV. Add them to download queue?'** /// **'Found {count} tracks in CSV. Add them to download queue?'**
String dialogImportPlaylistMessage(int count); String dialogImportPlaylistMessage(int count);
/// Label shown in quality picker for CSV import
///
/// In en, this message translates to:
/// **'{count} tracks from CSV'**
String csvImportTracks(int count);
/// Snackbar - track added to download queue /// Snackbar - track added to download queue
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2612,6 +2656,60 @@ abstract class AppLocalizations {
/// **'File Settings'** /// **'File Settings'**
String get sectionFileSettings; String get sectionFileSettings;
/// Settings section header
///
/// In en, this message translates to:
/// **'Lyrics'**
String get sectionLyrics;
/// Setting - how to save lyrics
///
/// In en, this message translates to:
/// **'Lyrics Mode'**
String get lyricsMode;
/// Lyrics mode picker description
///
/// In en, this message translates to:
/// **'Choose how lyrics are saved with your downloads'**
String get lyricsModeDescription;
/// Lyrics mode option - embed in audio file
///
/// In en, this message translates to:
/// **'Embed in file'**
String get lyricsModeEmbed;
/// Subtitle for embed option
///
/// In en, this message translates to:
/// **'Lyrics stored inside FLAC metadata'**
String get lyricsModeEmbedSubtitle;
/// Lyrics mode option - separate LRC file
///
/// In en, this message translates to:
/// **'External .lrc file'**
String get lyricsModeExternal;
/// Subtitle for external option
///
/// In en, this message translates to:
/// **'Separate .lrc file for players like Samsung Music'**
String get lyricsModeExternalSubtitle;
/// Lyrics mode option - embed and external
///
/// In en, this message translates to:
/// **'Both'**
String get lyricsModeBoth;
/// Subtitle for both option
///
/// In en, this message translates to:
/// **'Embed and save .lrc file'**
String get lyricsModeBothSubtitle;
/// Settings section header /// Settings section header
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2816,6 +2914,24 @@ abstract class AppLocalizations {
/// **'Release date'** /// **'Release date'**
String get trackReleaseDate; String get trackReleaseDate;
/// Metadata label - music genre
///
/// In en, this message translates to:
/// **'Genre'**
String get trackGenre;
/// Metadata label - record label
///
/// In en, this message translates to:
/// **'Label'**
String get trackLabel;
/// Metadata label - copyright information
///
/// In en, this message translates to:
/// **'Copyright'**
String get trackCopyright;
/// Metadata label - download date /// Metadata label - download date
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3673,6 +3789,108 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Error: {message}'** /// **'Error: {message}'**
String errorGeneric(String message); String errorGeneric(String message);
/// Button - download artist discography
///
/// In en, this message translates to:
/// **'Download Discography'**
String get discographyDownload;
/// Option - download entire discography
///
/// In en, this message translates to:
/// **'Download All'**
String get discographyDownloadAll;
/// Subtitle showing total tracks and albums
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} releases'**
String discographyDownloadAllSubtitle(int count, int albumCount);
/// Option - download only albums
///
/// In en, this message translates to:
/// **'Albums Only'**
String get discographyAlbumsOnly;
/// Subtitle showing album tracks count
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} albums'**
String discographyAlbumsOnlySubtitle(int count, int albumCount);
/// Option - download only singles
///
/// In en, this message translates to:
/// **'Singles & EPs Only'**
String get discographySinglesOnly;
/// Subtitle showing singles tracks count
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} singles'**
String discographySinglesOnlySubtitle(int count, int albumCount);
/// Option - manually select albums to download
///
/// In en, this message translates to:
/// **'Select Albums...'**
String get discographySelectAlbums;
/// Subtitle for select albums option
///
/// In en, this message translates to:
/// **'Choose specific albums or singles'**
String get discographySelectAlbumsSubtitle;
/// Progress - fetching album tracks
///
/// In en, this message translates to:
/// **'Fetching tracks...'**
String get discographyFetchingTracks;
/// Progress - fetching specific album
///
/// In en, this message translates to:
/// **'Fetching {current} of {total}...'**
String discographyFetchingAlbum(int current, int total);
/// Selection count badge
///
/// In en, this message translates to:
/// **'{count} selected'**
String discographySelectedCount(int count);
/// Button - download selected albums
///
/// In en, this message translates to:
/// **'Download Selected'**
String get discographyDownloadSelected;
/// Snackbar - tracks added from discography
///
/// In en, this message translates to:
/// **'Added {count} tracks to queue'**
String discographyAddedToQueue(int count);
/// Snackbar - with skipped tracks count
///
/// In en, this message translates to:
/// **'{added} added, {skipped} already downloaded'**
String discographySkippedDownloaded(int added, int skipped);
/// Error - no albums found for artist
///
/// In en, this message translates to:
/// **'No albums available'**
String get discographyNoAlbums;
/// Error - some albums failed to load
///
/// In en, this message translates to:
/// **'Failed to fetch some albums'**
String get discographyFailedToFetch;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
@@ -3697,6 +3915,7 @@ class _AppLocalizationsDelegate
'nl', 'nl',
'pt', 'pt',
'ru', 'ru',
'tr',
'zh', 'zh',
].contains(locale.languageCode); ].contains(locale.languageCode);
@@ -3759,6 +3978,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsPt(); return AppLocalizationsPt();
case 'ru': case 'ru':
return AppLocalizationsRu(); return AppLocalizationsRu();
case 'tr':
return AppLocalizationsTr();
case 'zh': case 'zh':
return AppLocalizationsZh(); return AppLocalizationsZh();
} }
+127
View File
@@ -111,6 +111,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Einzelne Titel-Downloads werden hier angezeigt'; 'Einzelne Titel-Downloads werden hier angezeigt';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Einstellungen'; String get settingsTitle => 'Einstellungen';
@@ -441,6 +444,21 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutFeatureRequestSubtitle => String get aboutFeatureRequestSubtitle =>
'Schlage neue Funktionen für die App vor'; 'Schlage neue Funktionen für die App vor';
@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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -910,6 +928,11 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1443,6 +1466,35 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1555,6 +1607,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2034,4 +2095,70 @@ class AppLocalizationsDe extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
+127
View File
@@ -109,6 +109,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Single track downloads will appear here'; 'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Settings'; String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; 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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -897,6 +915,11 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1430,6 +1453,35 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1542,6 +1594,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2021,4 +2082,70 @@ class AppLocalizationsEn extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
+127
View File
@@ -109,6 +109,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Single track downloads will appear here'; 'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Settings'; String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; 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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -897,6 +915,11 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1430,6 +1453,35 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1542,6 +1594,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2021,6 +2082,72 @@ class AppLocalizationsEs extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+127
View File
@@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Single track downloads will appear here'; 'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Settings'; String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; 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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -897,6 +915,11 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1430,6 +1453,35 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1542,6 +1594,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2021,4 +2082,70 @@ class AppLocalizationsFr extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
+127
View File
@@ -109,6 +109,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Single track downloads will appear here'; 'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Settings'; String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; 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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -897,6 +915,11 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1430,6 +1453,35 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1542,6 +1594,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2021,4 +2082,70 @@ class AppLocalizationsHi extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
+127
View File
@@ -110,6 +110,9 @@ class AppLocalizationsId extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Unduhan lagu satuan akan muncul di sini'; 'Unduhan lagu satuan akan muncul di sini';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Pengaturan'; String get settingsTitle => 'Pengaturan';
@@ -434,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutFeatureRequestSubtitle => String get aboutFeatureRequestSubtitle =>
'Sarankan fitur baru untuk aplikasi'; 'Sarankan fitur baru untuk aplikasi';
@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 @override
String get aboutSupport => 'Dukungan'; String get aboutSupport => 'Dukungan';
@@ -903,6 +921,11 @@ class AppLocalizationsId extends AppLocalizations {
return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?'; return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Menambahkan \"$trackName\" ke antrian'; return 'Menambahkan \"$trackName\" ke antrian';
@@ -1440,6 +1463,35 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get sectionFileSettings => 'Pengaturan File'; String get sectionFileSettings => 'Pengaturan File';
@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 @override
String get sectionColor => 'Warna'; String get sectionColor => 'Warna';
@@ -1552,6 +1604,15 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Tanggal rilis'; String get trackReleaseDate => 'Tanggal rilis';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Diunduh'; String get trackDownloaded => 'Diunduh';
@@ -2034,4 +2095,70 @@ class AppLocalizationsId extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@override
String get discographyDownload => 'Unduh Diskografi';
@override
String get discographyDownloadAll => 'Unduh Semua';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount rilis';
}
@override
String get discographyAlbumsOnly => 'Album Saja';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount album';
}
@override
String get discographySinglesOnly => 'Single & EP Saja';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount single';
}
@override
String get discographySelectAlbums => 'Pilih Album...';
@override
String get discographySelectAlbumsSubtitle =>
'Pilih album atau single tertentu';
@override
String get discographyFetchingTracks => 'Mengambil lagu...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Mengambil $current dari $total...';
}
@override
String discographySelectedCount(int count) {
return '$count dipilih';
}
@override
String get discographyDownloadSelected => 'Unduh yang Dipilih';
@override
String discographyAddedToQueue(int count) {
return 'Menambahkan $count lagu ke antrian';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added ditambahkan, $skipped sudah diunduh';
}
@override
String get discographyNoAlbums => 'Tidak ada album tersedia';
@override
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
} }
+127
View File
@@ -109,6 +109,9 @@ class AppLocalizationsJa extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Single track downloads will appear here'; 'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => '設定'; String get settingsTitle => '設定';
@@ -429,6 +432,21 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; 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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -897,6 +915,11 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1430,6 +1453,35 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1542,6 +1594,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2021,4 +2082,70 @@ class AppLocalizationsJa extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
+127
View File
@@ -109,6 +109,9 @@ class AppLocalizationsKo extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Single track downloads will appear here'; 'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Settings'; String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; 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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -897,6 +915,11 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1430,6 +1453,35 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1542,6 +1594,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2021,4 +2082,70 @@ class AppLocalizationsKo extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
+127
View File
@@ -109,6 +109,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Single track downloads will appear here'; 'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Settings'; String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; 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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -897,6 +915,11 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1430,6 +1453,35 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1542,6 +1594,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2021,4 +2082,70 @@ class AppLocalizationsNl extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
+127
View File
@@ -109,6 +109,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Single track downloads will appear here'; 'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Settings'; String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; 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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -897,6 +915,11 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1430,6 +1453,35 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1542,6 +1594,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2021,6 +2082,72 @@ class AppLocalizationsPt extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
/// The translations for Portuguese, as used in Portugal (`pt_PT`). /// The translations for Portuguese, as used in Portugal (`pt_PT`).
+127
View File
@@ -114,6 +114,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Здесь будут отображаться загрузки синглов'; 'Здесь будут отображаться загрузки синглов';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Настройки'; String get settingsTitle => 'Настройки';
@@ -442,6 +445,21 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutFeatureRequestSubtitle => String get aboutFeatureRequestSubtitle =>
'Предложить новые функции для приложения'; 'Предложить новые функции для приложения';
@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 @override
String get aboutSupport => 'Поддержка'; String get aboutSupport => 'Поддержка';
@@ -919,6 +937,11 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Найдено $count треков в CSV. Добавить их в очередь загрузки?'; return 'Найдено $count треков в CSV. Добавить их в очередь загрузки?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return '\"$trackName\" добавлен в очередь'; return '\"$trackName\" добавлен в очередь';
@@ -1458,6 +1481,35 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get sectionFileSettings => 'Настройки файла'; String get sectionFileSettings => 'Настройки файла';
@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 @override
String get sectionColor => 'Цвет'; String get sectionColor => 'Цвет';
@@ -1574,6 +1626,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Дата выхода'; String get trackReleaseDate => 'Дата выхода';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Скачано'; String get trackDownloaded => 'Скачано';
@@ -2066,4 +2127,70 @@ class AppLocalizationsRu extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Ошибка: $message'; return 'Ошибка: $message';
} }
@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';
} }
File diff suppressed because it is too large Load Diff
+127
View File
@@ -109,6 +109,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get historyNoSinglesSubtitle => String get historyNoSinglesSubtitle =>
'Single track downloads will appear here'; 'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override @override
String get settingsTitle => 'Settings'; String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; 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 @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -897,6 +915,11 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Found $count tracks in CSV. Add them to download queue?';
} }
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return 'Added \"$trackName\" to queue';
@@ -1430,6 +1453,35 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get sectionFileSettings => 'File Settings'; 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 @override
String get sectionColor => 'Color'; String get sectionColor => 'Color';
@@ -1542,6 +1594,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get trackReleaseDate => 'Release date'; String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'Downloaded'; String get trackDownloaded => 'Downloaded';
@@ -2021,6 +2082,72 @@ class AppLocalizationsZh extends AppLocalizations {
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Error: $message';
} }
@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';
} }
/// The translations for Chinese, as used in China (`zh_CN`). /// The translations for Chinese, as used in China (`zh_CN`).
+123 -3
View File
@@ -75,8 +75,10 @@
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"}, "@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
"historyNoSingles": "No single downloads", "historyNoSingles": "No single downloads",
"@historyNoSingles": {"description": "Empty state when filtering singles"}, "@historyNoSingles": {"description": "Empty state when filtering singles"},
"historyNoSinglesSubtitle": "Single track downloads will appear here", "historyNoSinglesSubtitle": "Single track downloads will appear here",
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"}, "@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
"historySearchHint": "Search history...",
"@historySearchHint": {"description": "Search bar placeholder in history"},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": {"description": "Settings screen title"}, "@settingsTitle": {"description": "Settings screen title"},
@@ -304,10 +306,20 @@
"@aboutReportIssue": {"description": "Link to report bugs"}, "@aboutReportIssue": {"description": "Link to report bugs"},
"aboutReportIssueSubtitle": "Report any problems you encounter", "aboutReportIssueSubtitle": "Report any problems you encounter",
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"}, "@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
"aboutFeatureRequest": "Feature request", "aboutFeatureRequest": "Feature request",
"@aboutFeatureRequest": {"description": "Link to suggest features"}, "@aboutFeatureRequest": {"description": "Link to suggest features"},
"aboutFeatureRequestSubtitle": "Suggest new features for the app", "aboutFeatureRequestSubtitle": "Suggest new features for the app",
"@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"}, "@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"},
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {"description": "Link to Telegram channel"},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {"description": "Link to Telegram chat group"},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"},
"aboutSocial": "Social",
"@aboutSocial": {"description": "Section for social links"},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": {"description": "Section for support/donation links"}, "@aboutSupport": {"description": "Section for support/donation links"},
"aboutBuyMeCoffee": "Buy me a coffee", "aboutBuyMeCoffee": "Buy me a coffee",
@@ -619,6 +631,13 @@
"dialogImportPlaylistTitle": "Import Playlist", "dialogImportPlaylistTitle": "Import Playlist",
"@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"}, "@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"},
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {"type": "int"}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1051,6 +1070,26 @@
"@sectionAudioQuality": {"description": "Settings section header"}, "@sectionAudioQuality": {"description": "Settings section header"},
"sectionFileSettings": "File Settings", "sectionFileSettings": "File Settings",
"@sectionFileSettings": {"description": "Settings section header"}, "@sectionFileSettings": {"description": "Settings section header"},
"sectionLyrics": "Lyrics",
"@sectionLyrics": {"description": "Settings section header"},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {"description": "Setting - how to save lyrics"},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {"description": "Lyrics mode picker description"},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {"description": "Lyrics mode option - embed in audio file"},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {"description": "Subtitle for embed option"},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {"description": "Lyrics mode option - separate LRC file"},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {"description": "Subtitle for external option"},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {"description": "Lyrics mode option - embed and external"},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {"description": "Subtitle for both option"},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": {"description": "Settings section header"}, "@sectionColor": {"description": "Settings section header"},
"sectionTheme": "Theme", "sectionTheme": "Theme",
@@ -1133,6 +1172,12 @@
"@trackAudioQuality": {"description": "Metadata label - audio quality"}, "@trackAudioQuality": {"description": "Metadata label - audio quality"},
"trackReleaseDate": "Release date", "trackReleaseDate": "Release date",
"@trackReleaseDate": {"description": "Metadata label - release date"}, "@trackReleaseDate": {"description": "Metadata label - release date"},
"trackGenre": "Genre",
"@trackGenre": {"description": "Metadata label - music genre"},
"trackLabel": "Label",
"@trackLabel": {"description": "Metadata label - record label"},
"trackCopyright": "Copyright",
"@trackCopyright": {"description": "Metadata label - copyright information"},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": {"description": "Metadata label - download date"}, "@trackDownloaded": {"description": "Metadata label - download date"},
"trackCopyLyrics": "Copy lyrics", "trackCopyLyrics": "Copy lyrics",
@@ -1504,5 +1549,80 @@
"placeholders": { "placeholders": {
"message": {"type": "String", "description": "Error message"} "message": {"type": "String", "description": "Error message"}
} }
} },
"discographyDownload": "Download Discography",
"@discographyDownload": {"description": "Button - download artist discography"},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {"description": "Option - download entire discography"},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {"type": "int"},
"albumCount": {"type": "int"}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {"description": "Option - download only albums"},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {"type": "int"},
"albumCount": {"type": "int"}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {"description": "Option - download only singles"},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {"type": "int"},
"albumCount": {"type": "int"}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {"description": "Option - manually select albums to download"},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {"description": "Progress - fetching album tracks"},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {"type": "int"}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {"description": "Button - download selected albums"},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {"type": "int"}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {"type": "int"},
"skipped": {"type": "int"}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {"description": "Error - no albums found for artist"},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"}
} }
+10 -10
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": { "@historyFilterSingles": {
"description": "Filter chip - show singles only" "description": "Filter chip - show singles only"
}, },
"historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", "historyTracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@historyTracksCount": { "@historyTracksCount": {
"description": "Track count with plural form", "description": "Track count with plural form",
"placeholders": { "placeholders": {
@@ -94,7 +94,7 @@
} }
} }
}, },
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}", "historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbumes}}",
"@historyAlbumsCount": { "@historyAlbumsCount": {
"description": "Album count with plural form", "description": "Album count with plural form",
"placeholders": { "placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": { "@albumTitle": {
"description": "Album screen title" "description": "Album screen title"
}, },
"albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", "albumTracks": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@albumTracks": { "@albumTracks": {
"description": "Album track count", "description": "Album track count",
"placeholders": { "placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": { "@artistCompilations": {
"description": "Section header for compilations" "description": "Section header for compilations"
}, },
"artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}", "artistReleases": "{count, plural, =1{1 lanzamiento} other{{count} lanzamientos}}",
"@artistReleases": { "@artistReleases": {
"description": "Artist release count", "description": "Artist release count",
"placeholders": { "placeholders": {
@@ -1108,7 +1108,7 @@
"@dialogDeleteSelectedTitle": { "@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items" "description": "Dialog title - delete selected items"
}, },
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", "dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"@dialogDeleteSelectedMessage": { "@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks", "description": "Dialog message - delete selected tracks",
"placeholders": { "placeholders": {
@@ -1169,7 +1169,7 @@
"@snackbarCredentialsCleared": { "@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed" "description": "Snackbar - Spotify credentials removed"
}, },
"snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}", "snackbarDeletedTracks": "Eliminado {count} {count, plural, =1{pista} other{pistas}}",
"@snackbarDeletedTracks": { "@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted", "description": "Snackbar - tracks deleted",
"placeholders": { "placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": { "@selectionTapToSelect": {
"description": "Hint - how to select items" "description": "Hint - how to select items"
}, },
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}", "selectionDeleteTracks": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
"@selectionDeleteTracks": { "@selectionDeleteTracks": {
"description": "Delete button with count", "description": "Delete button with count",
"placeholders": { "placeholders": {
@@ -1916,7 +1916,7 @@
} }
} }
}, },
"tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", "tracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@tracksCount": { "@tracksCount": {
"description": "Track count display", "description": "Track count display",
"placeholders": { "placeholders": {
@@ -2520,7 +2520,7 @@
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
}, },
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", "downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"@downloadedAlbumDeleteMessage": { "@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count", "description": "Delete confirmation with count",
"placeholders": { "placeholders": {
@@ -2559,7 +2559,7 @@
"@downloadedAlbumTapToSelect": { "@downloadedAlbumTapToSelect": {
"description": "Selection hint" "description": "Selection hint"
}, },
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}", "downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
"@downloadedAlbumDeleteCount": { "@downloadedAlbumDeleteCount": {
"description": "Delete button text with count", "description": "Delete button text with count",
"placeholders": { "placeholders": {
+19 -1
View File
@@ -683,5 +683,23 @@
"recentTypePlaylist": "Playlist", "recentTypePlaylist": "Playlist",
"recentPlaylistInfo": "Playlist: {name}", "recentPlaylistInfo": "Playlist: {name}",
"errorGeneric": "Error: {message}" "errorGeneric": "Error: {message}",
"discographyDownload": "Unduh Diskografi",
"discographyDownloadAll": "Unduh Semua",
"discographyDownloadAllSubtitle": "{count} lagu dari {albumCount} rilis",
"discographyAlbumsOnly": "Album Saja",
"discographyAlbumsOnlySubtitle": "{count} lagu dari {albumCount} album",
"discographySinglesOnly": "Single & EP Saja",
"discographySinglesOnlySubtitle": "{count} lagu dari {albumCount} single",
"discographySelectAlbums": "Pilih Album...",
"discographySelectAlbumsSubtitle": "Pilih album atau single tertentu",
"discographyFetchingTracks": "Mengambil lagu...",
"discographyFetchingAlbum": "Mengambil {current} dari {total}...",
"discographySelectedCount": "{count} dipilih",
"discographyDownloadSelected": "Unduh yang Dipilih",
"discographyAddedToQueue": "Menambahkan {count} lagu ke antrian",
"discographySkippedDownloaded": "{added} ditambahkan, {skipped} sudah diunduh",
"discographyNoAlbums": "Tidak ada album tersedia",
"discographyFailedToFetch": "Gagal mengambil beberapa album"
} }
+6 -6
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": { "@historyFilterSingles": {
"description": "Filter chip - show singles only" "description": "Filter chip - show singles only"
}, },
"historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", "historyTracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@historyTracksCount": { "@historyTracksCount": {
"description": "Track count with plural form", "description": "Track count with plural form",
"placeholders": { "placeholders": {
@@ -94,7 +94,7 @@
} }
} }
}, },
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}", "historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbuns}}",
"@historyAlbumsCount": { "@historyAlbumsCount": {
"description": "Album count with plural form", "description": "Album count with plural form",
"placeholders": { "placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": { "@albumTitle": {
"description": "Album screen title" "description": "Album screen title"
}, },
"albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", "albumTracks": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@albumTracks": { "@albumTracks": {
"description": "Album track count", "description": "Album track count",
"placeholders": { "placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": { "@artistCompilations": {
"description": "Section header for compilations" "description": "Section header for compilations"
}, },
"artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}", "artistReleases": "{count, plural, =1{1 lançamento} other{{count} lançamentos}}",
"@artistReleases": { "@artistReleases": {
"description": "Artist release count", "description": "Artist release count",
"placeholders": { "placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": { "@selectionTapToSelect": {
"description": "Hint - how to select items" "description": "Hint - how to select items"
}, },
"selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}", "selectionDeleteTracks": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
"@selectionDeleteTracks": { "@selectionDeleteTracks": {
"description": "Delete button with count", "description": "Delete button with count",
"placeholders": { "placeholders": {
@@ -1916,7 +1916,7 @@
} }
} }
}, },
"tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", "tracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@tracksCount": { "@tracksCount": {
"description": "Track count display", "description": "Track count display",
"placeholders": { "placeholders": {
+7
View File
@@ -0,0 +1,7 @@
{
"@@locale": "tr",
"@@last_modified": "2026-01-21",
"appName": "SpotiFLAC",
"@appName": {"description": "App name - DO NOT TRANSLATE"}
}
+7 -2
View File
@@ -7,13 +7,18 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await NotificationService().initialize(); await CoverCacheManager.initialize();
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
await ShareIntentService().initialize(); await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
runApp( runApp(
ProviderScope( ProviderScope(
+6 -9
View File
@@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart';
part 'download_item.g.dart'; part 'download_item.g.dart';
/// Download status enum
enum DownloadStatus { enum DownloadStatus {
queued, queued,
downloading, downloading,
finalizing, // Embedding metadata, cover, lyrics finalizing,
completed, completed,
failed, failed,
skipped, skipped,
} }
/// Error type enum for better error handling
enum DownloadErrorType { enum DownloadErrorType {
unknown, unknown,
notFound, // Track not found on any service notFound,
rateLimit, // Rate limited by service rateLimit,
network, // Network/connection error network,
permission, // File/folder permission error permission,
} }
@JsonSerializable() @JsonSerializable()
@@ -29,7 +27,7 @@ class DownloadItem {
final String service; final String service;
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double speedMBps; // Download speed in MB/s final double speedMBps;
final String? filePath; final String? filePath;
final String? error; final String? error;
final DownloadErrorType? errorType; final DownloadErrorType? errorType;
@@ -78,7 +76,6 @@ class DownloadItem {
); );
} }
/// Get user-friendly error message based on error type
String get errorMessage { String get errorMessage {
if (error == null) return ''; if (error == null) return '';
+45 -41
View File
@@ -12,26 +12,27 @@ class AppSettings {
final bool embedLyrics; final bool embedLyrics;
final bool maxQualityCover; final bool maxQualityCover;
final bool isFirstLaunch; final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3 final int concurrentDownloads;
final bool checkForUpdates; // Check for updates on app start final bool checkForUpdates;
final String updateChannel; // stable, preview final String updateChannel;
final bool hasSearchedBefore; // Hide helper text after first search final bool hasSearchedBefore;
final String folderOrganization; // none, artist, album, artist_album final String folderOrganization;
final String historyViewMode; // list, grid final String historyViewMode;
final String historyFilterMode; // all, albums, singles final String historyFilterMode;
final bool askQualityBeforeDownload; // Show quality picker before each download final bool askQualityBeforeDownload;
final String spotifyClientId; // Custom Spotify client ID (empty = use default) final String spotifyClientId;
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) final String spotifyClientSecret;
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) final bool useCustomSpotifyCredentials;
final String metadataSource; // spotify, deezer - source for search and metadata final String metadataSource;
final bool enableLogging; // Enable detailed logging for debugging final bool enableLogging;
final bool useExtensionProviders; // Use extension providers for downloads when available final bool useExtensionProviders;
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID final String? searchProvider;
final bool separateSingles; // Separate singles/EPs into their own folder final bool separateSingles;
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album final String albumFolderStructure;
final bool showExtensionStore; // Show Extension Store tab in navigation final bool showExtensionStore;
final String locale; // App language: 'system', 'en', 'id', etc. final String locale;
final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion) final bool enableMp3Option;
final String lyricsMode;
const AppSettings({ const AppSettings({
this.defaultService = 'tidal', this.defaultService = 'tidal',
@@ -42,26 +43,27 @@ class AppSettings {
this.embedLyrics = true, this.embedLyrics = true,
this.maxQualityCover = true, this.maxQualityCover = true,
this.isFirstLaunch = true, this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off) this.concurrentDownloads = 1,
this.checkForUpdates = true, // Default: enabled this.checkForUpdates = true,
this.updateChannel = 'stable', // Default: stable releases only this.updateChannel = 'stable',
this.hasSearchedBefore = false, // Default: show helper text this.hasSearchedBefore = false,
this.folderOrganization = 'none', // Default: no folder organization this.folderOrganization = 'none',
this.historyViewMode = 'grid', // Default: grid view this.historyViewMode = 'grid',
this.historyFilterMode = 'all', // Default: show all this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true, // Default: ask quality before download this.askQualityBeforeDownload = true,
this.spotifyClientId = '', // Default: use built-in credentials this.spotifyClientId = '',
this.spotifyClientSecret = '', // Default: use built-in credentials this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = true, // Default: use custom if set this.useCustomSpotifyCredentials = true,
this.metadataSource = 'deezer', // Default: Deezer (no rate limit) this.metadataSource = 'deezer',
this.enableLogging = false, // Default: disabled for performance this.enableLogging = false,
this.useExtensionProviders = true, // Default: use extensions when available this.useExtensionProviders = true,
this.searchProvider, // Default: null (use Deezer/Spotify) this.searchProvider,
this.separateSingles = false, // Default: disabled this.separateSingles = false,
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true, // Default: show store this.showExtensionStore = true,
this.locale = 'system', // Default: follow system language this.locale = 'system',
this.enableMp3Option = false, // Default: disabled this.enableMp3Option = false,
this.lyricsMode = 'embed',
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -88,12 +90,13 @@ class AppSettings {
bool? enableLogging, bool? enableLogging,
bool? useExtensionProviders, bool? useExtensionProviders,
String? searchProvider, String? searchProvider,
bool clearSearchProvider = false, // Set to true to clear searchProvider to null bool clearSearchProvider = false,
bool? separateSingles, bool? separateSingles,
String? albumFolderStructure, String? albumFolderStructure,
bool? showExtensionStore, bool? showExtensionStore,
String? locale, String? locale,
bool? enableMp3Option, bool? enableMp3Option,
String? lyricsMode,
}) { }) {
return AppSettings( return AppSettings(
defaultService: defaultService ?? this.defaultService, defaultService: defaultService ?? this.defaultService,
@@ -124,6 +127,7 @@ class AppSettings {
showExtensionStore: showExtensionStore ?? this.showExtensionStore, showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale, locale: locale ?? this.locale,
enableMp3Option: enableMp3Option ?? this.enableMp3Option, enableMp3Option: enableMp3Option ?? this.enableMp3Option,
lyricsMode: lyricsMode ?? this.lyricsMode,
); );
} }
+2
View File
@@ -37,6 +37,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
showExtensionStore: json['showExtensionStore'] as bool? ?? true, showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system', locale: json['locale'] as String? ?? 'system',
enableMp3Option: json['enableMp3Option'] as bool? ?? false, enableMp3Option: json['enableMp3Option'] as bool? ?? false,
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
); );
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) => Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -69,4 +70,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'showExtensionStore': instance.showExtensionStore, 'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale, 'locale': instance.locale,
'enableMp3Option': instance.enableMp3Option, 'enableMp3Option': instance.enableMp3Option,
'lyricsMode': instance.lyricsMode,
}; };
-6
View File
@@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled';
/// Default Spotify green color for fallback /// Default Spotify green color for fallback
const int kDefaultSeedColor = 0xFF1DB954; const int kDefaultSeedColor = 0xFF1DB954;
/// Theme settings model for Material Expressive 3
class ThemeSettings { class ThemeSettings {
final ThemeMode themeMode; final ThemeMode themeMode;
final bool useDynamicColor; final bool useDynamicColor;
@@ -23,10 +22,8 @@ class ThemeSettings {
this.useAmoled = false, this.useAmoled = false,
}); });
/// Get seed color as Color object
Color get seedColor => Color(seedColorValue); Color get seedColor => Color(seedColorValue);
/// Create a copy with updated values
ThemeSettings copyWith({ ThemeSettings copyWith({
ThemeMode? themeMode, ThemeMode? themeMode,
bool? useDynamicColor, bool? useDynamicColor,
@@ -41,7 +38,6 @@ class ThemeSettings {
); );
} }
/// Convert to JSON map for persistence
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
kThemeModeKey: themeMode.name, kThemeModeKey: themeMode.name,
kUseDynamicColorKey: useDynamicColor, kUseDynamicColorKey: useDynamicColor,
@@ -49,7 +45,6 @@ class ThemeSettings {
kUseAmoledKey: useAmoled, kUseAmoledKey: useAmoled,
}; };
/// Create from JSON map
factory ThemeSettings.fromJson(Map<String, dynamic> json) { factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings( return ThemeSettings(
themeMode: _themeModeFromString(json[kThemeModeKey] as String?), themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
@@ -74,7 +69,6 @@ class ThemeSettings {
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode; themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
} }
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) { ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system; if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere( return ThemeMode.values.firstWhere(
+3 -10
View File
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
part 'track.g.dart'; part 'track.g.dart';
/// Track model representing a music track
@JsonSerializable() @JsonSerializable()
class Track { class Track {
final String id; final String id;
@@ -18,9 +17,9 @@ class Track {
final String? releaseDate; final String? releaseDate;
final String? deezerId; final String? deezerId;
final ServiceAvailability? availability; final ServiceAvailability? availability;
final String? source; // Extension ID that provided this track (null for built-in sources) final String? source;
final String? albumType; // album, single, ep, compilation (from metadata API) final String? albumType;
final String? itemType; // track, album, playlist - for extension search results final String? itemType;
const Track({ const Track({
required this.id, required this.id,
@@ -41,25 +40,19 @@ class Track {
this.itemType, this.itemType,
}); });
/// Check if this track is a single (based on album_type metadata)
bool get isSingle => albumType == 'single' || albumType == 'ep'; bool get isSingle => albumType == 'single' || albumType == 'ep';
/// Check if this is an album item (not a track)
bool get isAlbumItem => itemType == 'album'; bool get isAlbumItem => itemType == 'album';
/// Check if this is a playlist item (not a track)
bool get isPlaylistItem => itemType == 'playlist'; bool get isPlaylistItem => itemType == 'playlist';
/// Check if this is an artist item (not a track)
bool get isArtistItem => itemType == 'artist'; bool get isArtistItem => itemType == 'artist';
/// Check if this is a collection (album, playlist, or artist)
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem; bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json); factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this); Map<String, dynamic> toJson() => _$TrackToJson(this);
/// Check if this track is from an extension
bool get isFromExtension => source != null && source!.isNotEmpty; bool get isFromExtension => source != null && source!.isNotEmpty;
} }
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('ExploreProvider');
/// Represents an item in a Spotify home section
class ExploreItem {
final String id;
final String uri;
final String type; // track, album, playlist, artist, station
final String name;
final String artists;
final String? description;
final String? coverUrl;
final String? providerId;
final String? albumId;
final String? albumName;
final int durationMs;
const ExploreItem({
required this.id,
required this.uri,
required this.type,
required this.name,
required this.artists,
this.description,
this.coverUrl,
this.providerId,
this.albumId,
this.albumName,
this.durationMs = 0,
});
factory ExploreItem.fromJson(Map<String, dynamic> json) {
return ExploreItem(
id: json['id'] as String? ?? '',
uri: json['uri'] as String? ?? '',
type: json['type'] as String? ?? 'track',
name: json['name'] as String? ?? '',
artists: json['artists'] as String? ?? '',
description: json['description'] as String?,
coverUrl: json['cover_url'] as String?,
providerId: json['provider_id'] as String?,
albumId: json['album_id'] as String?,
albumName: json['album_name'] as String?,
durationMs: json['duration_ms'] as int? ?? 0,
);
}
}
/// Represents a section in Spotify home feed
class ExploreSection {
final String uri;
final String title;
final List<ExploreItem> items;
const ExploreSection({
required this.uri,
required this.title,
required this.items,
});
factory ExploreSection.fromJson(Map<String, dynamic> json) {
final itemsList = json['items'] as List<dynamic>? ?? [];
return ExploreSection(
uri: json['uri'] as String? ?? '',
title: json['title'] as String? ?? '',
items: itemsList
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
.toList(),
);
}
}
/// State for explore/home feed
class ExploreState {
final bool isLoading;
final String? error;
final String? greeting;
final List<ExploreSection> sections;
final DateTime? lastFetched;
const ExploreState({
this.isLoading = false,
this.error,
this.greeting,
this.sections = const [],
this.lastFetched,
});
bool get hasContent => sections.isNotEmpty;
ExploreState copyWith({
bool? isLoading,
String? error,
String? greeting,
List<ExploreSection>? sections,
DateTime? lastFetched,
}) {
return ExploreState(
isLoading: isLoading ?? this.isLoading,
error: error,
greeting: greeting ?? this.greeting,
sections: sections ?? this.sections,
lastFetched: lastFetched ?? this.lastFetched,
);
}
}
/// Provider for explore/home feed state
class ExploreNotifier extends Notifier<ExploreState> {
@override
ExploreState build() {
return const ExploreState();
}
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// Don't refetch if we have data and it's less than 5 minutes old
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed');
return;
}
if (state.isLoading) {
_log.d('Home feed fetch already in progress');
return;
}
state = state.copyWith(isLoading: true, error: null);
try {
// Find any extension with homeFeed capability
final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}');
// Look for extensions with homeFeed capability (prefer spotify-web)
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (extension.id == 'spotify-web') {
break;
}
}
}
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
state = state.copyWith(
isLoading: false,
error: 'No extension with home feed support enabled',
);
return;
}
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
if (result == null) {
state = state.copyWith(
isLoading: false,
error: 'Failed to fetch home feed',
);
return;
}
final success = result['success'] as bool? ?? false;
_log.d('getExtensionHomeFeed success=$success');
if (!success) {
final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith(
isLoading: false,
error: error,
);
return;
}
final greeting = result['greeting'] as String?;
final sectionsData = result['sections'] as List<dynamic>? ?? [];
final sections = sectionsData
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
.toList();
_log.i('Fetched ${sections.length} sections');
// Debug: log first section items
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}');
}
state = ExploreState(
isLoading: false,
greeting: greeting,
sections: sections,
lastFetched: DateTime.now(),
);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
/// Clear cached data
void clear() {
state = const ExploreState();
}
/// Refresh home feed
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});
+22 -40
View File
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExtensionProvider'); final _log = AppLogger('ExtensionProvider');
/// Represents an installed extension
class Extension { class Extension {
final String id; final String id;
final String name; final String name;
@@ -14,19 +13,20 @@ class Extension {
final String author; final String author;
final String description; final String description;
final bool enabled; final bool enabled;
final String status; // 'loaded', 'error', 'disabled' final String status;
final String? errorMessage; final String? errorMessage;
final String? iconPath; // Path to extension icon final String? iconPath;
final List<String> permissions; final List<String> permissions;
final List<ExtensionSetting> settings; final List<ExtensionSetting> settings;
final List<QualityOption> qualityOptions; // Custom quality options for download providers final List<QualityOption> qualityOptions;
final bool hasMetadataProvider; final bool hasMetadataProvider;
final bool hasDownloadProvider; final bool hasDownloadProvider;
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior; // Custom search behavior final SearchBehavior? searchBehavior;
final URLHandler? urlHandler; // Custom URL handling final URLHandler? urlHandler;
final TrackMatching? trackMatching; // Custom track matching final TrackMatching? trackMatching;
final PostProcessing? postProcessing; // Post-processing hooks final PostProcessing? postProcessing;
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
const Extension({ const Extension({
required this.id, required this.id,
@@ -49,6 +49,7 @@ class Extension {
this.urlHandler, this.urlHandler,
this.trackMatching, this.trackMatching,
this.postProcessing, this.postProcessing,
this.capabilities = const {},
}); });
factory Extension.fromJson(Map<String, dynamic> json) { factory Extension.fromJson(Map<String, dynamic> json) {
@@ -85,6 +86,7 @@ class Extension {
postProcessing: json['post_processing'] != null postProcessing: json['post_processing'] != null
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>) ? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
: null, : null,
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
); );
} }
@@ -109,6 +111,7 @@ class Extension {
URLHandler? urlHandler, URLHandler? urlHandler,
TrackMatching? trackMatching, TrackMatching? trackMatching,
PostProcessing? postProcessing, PostProcessing? postProcessing,
Map<String, dynamic>? capabilities,
}) { }) {
return Extension( return Extension(
id: id ?? this.id, id: id ?? this.id,
@@ -131,6 +134,7 @@ class Extension {
urlHandler: urlHandler ?? this.urlHandler, urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching, trackMatching: trackMatching ?? this.trackMatching,
postProcessing: postProcessing ?? this.postProcessing, postProcessing: postProcessing ?? this.postProcessing,
capabilities: capabilities ?? this.capabilities,
); );
} }
@@ -138,9 +142,10 @@ class Extension {
bool get hasURLHandler => urlHandler?.enabled ?? false; bool get hasURLHandler => urlHandler?.enabled ?? false;
bool get hasCustomMatching => trackMatching?.customMatching ?? false; bool get hasCustomMatching => trackMatching?.customMatching ?? false;
bool get hasPostProcessing => postProcessing?.enabled ?? false; bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
} }
/// Custom search behavior configuration
class SearchBehavior { class SearchBehavior {
final bool enabled; final bool enabled;
final String? placeholder; final String? placeholder;
@@ -172,8 +177,6 @@ class SearchBehavior {
); );
} }
/// Get thumbnail size based on configuration
/// Returns (width, height) tuple
(double, double) getThumbnailSize({double defaultSize = 56}) { (double, double) getThumbnailSize({double defaultSize = 56}) {
if (thumbnailWidth != null && thumbnailHeight != null) { if (thumbnailWidth != null && thumbnailHeight != null) {
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
@@ -191,11 +194,10 @@ class SearchBehavior {
} }
} }
/// Custom track matching configuration
class TrackMatching { class TrackMatching {
final bool customMatching; final bool customMatching;
final String? strategy; // "isrc", "name", "duration", "custom" final String? strategy;
final int durationTolerance; // in seconds final int durationTolerance;
const TrackMatching({ const TrackMatching({
required this.customMatching, required this.customMatching,
@@ -212,7 +214,6 @@ class TrackMatching {
} }
} }
/// Post-processing configuration
class PostProcessing { class PostProcessing {
final bool enabled; final bool enabled;
final List<PostProcessingHook> hooks; final List<PostProcessingHook> hooks;
@@ -262,7 +263,6 @@ class URLHandler {
} }
} }
/// A post-processing hook
class PostProcessingHook { class PostProcessingHook {
final String id; final String id;
final String name; final String name;
@@ -289,12 +289,11 @@ class PostProcessingHook {
} }
} }
/// Represents a quality option for download providers
class QualityOption { class QualityOption {
final String id; final String id;
final String label; final String label;
final String? description; final String? description;
final List<QualitySpecificSetting> settings; // Quality-specific settings final List<QualitySpecificSetting> settings;
const QualityOption({ const QualityOption({
required this.id, required this.id,
@@ -315,14 +314,13 @@ class QualityOption {
} }
} }
/// Represents a setting that's specific to a quality option
class QualitySpecificSetting { class QualitySpecificSetting {
final String key; final String key;
final String label; final String label;
final String type; // 'string', 'number', 'boolean', 'select' final String type;
final dynamic defaultValue; final dynamic defaultValue;
final String? description; final String? description;
final List<String>? options; // For select type final List<String>? options;
final bool required; final bool required;
final bool secret; final bool secret;
@@ -351,16 +349,15 @@ class QualitySpecificSetting {
} }
} }
/// Represents a setting field for an extension
class ExtensionSetting { class ExtensionSetting {
final String key; final String key;
final String label; final String label;
final String type; // 'string', 'number', 'boolean', 'select', 'button' final String type;
final dynamic defaultValue; final dynamic defaultValue;
final String? description; final String? description;
final List<String>? options; // For select type final List<String>? options;
final bool required; final bool required;
final String? action; // For button type: JS function name to call final String? action;
const ExtensionSetting({ const ExtensionSetting({
required this.key, required this.key,
@@ -387,7 +384,6 @@ class ExtensionSetting {
} }
} }
/// State for extension management
class ExtensionState { class ExtensionState {
final List<Extension> extensions; final List<Extension> extensions;
final List<String> providerPriority; final List<String> providerPriority;
@@ -425,7 +421,6 @@ class ExtensionState {
} }
/// Provider for managing extensions
class ExtensionNotifier extends Notifier<ExtensionState> { class ExtensionNotifier extends Notifier<ExtensionState> {
@override @override
ExtensionState build() { ExtensionState build() {
@@ -451,7 +446,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Load all extensions from directory
Future<void> loadExtensions(String dirPath) async { Future<void> loadExtensions(String dirPath) async {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -486,12 +480,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Clear any error state
void clearError() { void clearError() {
state = state.copyWith(error: null); state = state.copyWith(error: null);
} }
/// Install extension from file (auto-upgrades if already installed with newer version)
Future<bool> installExtension(String filePath) async { Future<bool> installExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -508,8 +500,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Check if a package file is an upgrade for an existing extension
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async { Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
try { try {
return await PlatformBridge.checkExtensionUpgrade(filePath); return await PlatformBridge.checkExtensionUpgrade(filePath);
@@ -519,7 +509,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Upgrade an existing extension from a new package file
Future<bool> upgradeExtension(String filePath) async { Future<bool> upgradeExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -553,7 +542,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Enable or disable an extension
Future<void> setExtensionEnabled(String extensionId, bool enabled) async { Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
try { try {
await PlatformBridge.setExtensionEnabled(extensionId, enabled); await PlatformBridge.setExtensionEnabled(extensionId, enabled);
@@ -600,7 +588,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Update settings for an extension
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async { Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
try { try {
await PlatformBridge.setExtensionSettings(extensionId, settings); await PlatformBridge.setExtensionSettings(extensionId, settings);
@@ -621,7 +608,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Set provider priority order
Future<void> setProviderPriority(List<String> priority) async { Future<void> setProviderPriority(List<String> priority) async {
try { try {
await PlatformBridge.setProviderPriority(priority); await PlatformBridge.setProviderPriority(priority);
@@ -643,7 +629,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Set metadata provider priority order
Future<void> setMetadataProviderPriority(List<String> priority) async { Future<void> setMetadataProviderPriority(List<String> priority) async {
try { try {
await PlatformBridge.setMetadataProviderPriority(priority); await PlatformBridge.setMetadataProviderPriority(priority);
@@ -665,7 +650,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Get extension by ID
Extension? getExtension(String extensionId) { Extension? getExtension(String extensionId) {
try { try {
return state.extensions.firstWhere((ext) => ext.id == extensionId); return state.extensions.firstWhere((ext) => ext.id == extensionId);
@@ -679,7 +663,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return state.extensions.where((ext) => ext.enabled).toList(); return state.extensions.where((ext) => ext.enabled).toList();
} }
/// Get all download providers (built-in + extensions)
List<String> getAllDownloadProviders() { List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'amazon']; final providers = ['tidal', 'qobuz', 'amazon'];
for (final ext in state.extensions) { for (final ext in state.extensions) {
@@ -700,7 +683,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
return providers; return providers;
} }
/// Get all extensions that provide custom search
List<Extension> get searchProviders { List<Extension> get searchProviders {
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
} }
@@ -121,7 +121,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>)) .map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
} catch (e) { } catch (e) {
// Ignore parse errors
} }
} }
@@ -266,7 +265,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
} }
} }
/// Provider instance
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>( final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new, RecentAccessNotifier.new,
); );
+7 -2
View File
@@ -30,7 +30,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
} }
/// Run one-time migrations for settings
Future<void> _runMigrations(SharedPreferences prefs) async { Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
@@ -51,7 +50,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
await prefs.setString(_settingsKey, jsonEncode(state.toJson())); await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
} }
/// Apply current Spotify credentials to Go backend
Future<void> _applySpotifyCredentials() async { Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty && if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) { state.spotifyClientSecret.isNotEmpty) {
@@ -92,6 +90,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setLyricsMode(String mode) {
if (mode == 'embed' || mode == 'external' || mode == 'both') {
state = state.copyWith(lyricsMode: mode);
_saveSettings();
}
}
void setMaxQualityCover(bool enabled) { void setMaxQualityCover(bool enabled) {
state = state.copyWith(maxQualityCover: enabled); state = state.copyWith(maxQualityCover: enabled);
_saveSettings(); _saveSettings();
-7
View File
@@ -52,7 +52,6 @@ class StoreCategory {
} }
} }
/// Represents an extension in the store
class StoreExtension { class StoreExtension {
final String id; final String id;
final String name; final String name;
@@ -118,7 +117,6 @@ class StoreExtension {
} }
} }
/// State for extension store
class StoreState { class StoreState {
final List<StoreExtension> extensions; final List<StoreExtension> extensions;
final String? selectedCategory; final String? selectedCategory;
@@ -200,7 +198,6 @@ class StoreNotifier extends Notifier<StoreState> {
return const StoreState(); return const StoreState();
} }
/// Initialize the store
Future<void> initialize(String cacheDir) async { Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return; if (state.isInitialized) return;
@@ -234,7 +231,6 @@ class StoreNotifier extends Notifier<StoreState> {
} }
} }
/// Set category filter
void setCategory(String? category) { void setCategory(String? category) {
if (category == null) { if (category == null) {
state = state.copyWith(clearCategory: true); state = state.copyWith(clearCategory: true);
@@ -248,7 +244,6 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: query); state = state.copyWith(searchQuery: query);
} }
/// Clear search
void clearSearch() { void clearSearch() {
state = state.copyWith(searchQuery: '', clearCategory: true); state = state.copyWith(searchQuery: '', clearCategory: true);
} }
@@ -279,7 +274,6 @@ class StoreNotifier extends Notifier<StoreState> {
} }
} }
/// Update an installed extension
Future<bool> updateExtension(String extensionId, String tempDir) async { Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
@@ -305,7 +299,6 @@ class StoreNotifier extends Notifier<StoreState> {
} }
} }
/// Clear error
void clearError() { void clearError() {
state = state.copyWith(clearError: true); state = state.copyWith(clearError: true);
} }
-1
View File
@@ -34,7 +34,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
); );
} catch (e) { } catch (e) {
debugPrint('Error loading theme settings: $e'); debugPrint('Error loading theme settings: $e');
// Keep default state on error
} }
} }
+5 -17
View File
@@ -89,7 +89,6 @@ class TrackState {
} }
} }
/// Represents an album in artist discography
class ArtistAlbum { class ArtistAlbum {
final String id; final String id;
final String name; final String name;
@@ -112,7 +111,6 @@ class ArtistAlbum {
}); });
} }
/// Represents an artist in search results
class SearchArtist { class SearchArtist {
final String id; final String id;
final String name; final String name;
@@ -130,7 +128,6 @@ class SearchArtist {
} }
class TrackNotifier extends Notifier<TrackState> { class TrackNotifier extends Notifier<TrackState> {
/// Request ID to track and cancel outdated requests
int _currentRequestId = 0; int _currentRequestId = 0;
@override @override
@@ -213,14 +210,8 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> metadata; Map<String, dynamic> metadata;
try { try {
// ignore: avoid_print
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
// ignore: avoid_print
print('[FetchURL] Metadata fetch success');
} catch (e) { } catch (e) {
// ignore: avoid_print
print('[FetchURL] Metadata fetch failed: $e');
rethrow; rethrow;
} }
@@ -263,7 +254,7 @@ class TrackNotifier extends Notifier<TrackState> {
final albumsList = metadata['albums'] as List<dynamic>; final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList(); final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
state = TrackState( state = TrackState(
tracks: [], // No tracks for artist view tracks: [],
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
@@ -397,7 +388,6 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} }
/// Perform custom search using a specific extension
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async { Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
@@ -429,7 +419,7 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
searchArtists: [], // Custom search doesn't return artists searchArtists: [],
isLoading: false, isLoading: false,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
searchExtensionId: extensionId, // Store which extension was used searchExtensionId: extensionId, // Store which extension was used
@@ -477,8 +467,6 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack; tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks); state = state.copyWith(tracks: tracks);
} catch (e) { } catch (e) {
// Silently ignore availability check errors
// This is a background operation that shouldn't disrupt the user
} }
} }
@@ -488,10 +476,12 @@ class TrackNotifier extends Notifier<TrackState> {
/// Set search text state for back button handling /// Set search text state for back button handling
void setSearchText(bool hasText) { void setSearchText(bool hasText) {
if (state.hasSearchText == hasText) {
return;
}
state = state.copyWith(hasSearchText: hasText); state = state.copyWith(hasSearchText: hasText);
} }
/// Set recent access mode state
void setShowingRecentAccess(bool showing) { void setShowingRecentAccess(bool showing) {
state = state.copyWith(isShowingRecentAccess: showing); state = state.copyWith(isShowingRecentAccess: showing);
} }
@@ -581,8 +571,6 @@ class TrackNotifier extends Notifier<TrackState> {
); );
} }
/// Pre-warm track ID cache for faster downloads
/// Runs in background, doesn't block UI
void _preWarmCacheForTracks(List<Track> tracks) { void _preWarmCacheForTracks(List<Track> tracks) {
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return; if (tracksWithIsrc.isEmpty) return;
+148 -55
View File
@@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
@@ -11,8 +12,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
/// Simple in-memory cache for album tracks
class _AlbumCache { class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {}; static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10); static const Duration _ttl = Duration(minutes: 10);
@@ -38,12 +40,14 @@ class _CacheEntry {
_CacheEntry(this.tracks, this.expiresAt); _CacheEntry(this.tracks, this.expiresAt);
} }
/// Album detail screen with Material Expressive 3 design
class AlbumScreen extends ConsumerStatefulWidget { class AlbumScreen extends ConsumerStatefulWidget {
final String albumId; final String albumId;
final String albumName; final String albumName;
final String? coverUrl; final String? coverUrl;
final List<Track>? tracks; // Optional - will fetch if null final List<Track>? tracks; // Optional - will fetch if null
final String? extensionId; // If from extension
final String? artistId; // Artist ID for navigation
final String? artistName; // Artist name for navigation
const AlbumScreen({ const AlbumScreen({
super.key, super.key,
@@ -51,6 +55,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
required this.albumName, required this.albumName,
this.coverUrl, this.coverUrl,
this.tracks, this.tracks,
this.extensionId,
this.artistId,
this.artistName,
}); });
@override @override
@@ -63,6 +70,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
String? _error; String? _error;
Color? _dominantColor; Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
String? _artistId;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@override @override
@@ -79,10 +87,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistName: widget.tracks?.firstOrNull?.artistName, artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl, imageUrl: widget.coverUrl,
providerId: providerId, providerId: providerId,
); );
}); });
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId); _tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
_artistId = widget.artistId; // Use provided artist ID if available
if (_tracks == null) { if (_tracks == null) {
_fetchTracks(); _fetchTracks();
} }
@@ -98,45 +108,47 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
void _onScroll() { void _onScroll() {
// Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top)
final shouldShow = _scrollController.offset > 280; final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) { if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow); setState(() => _showTitleInAppBar = shouldShow);
} }
} }
Future<void> _extractDominantColor() async { Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return; if (widget.coverUrl == null) return;
try { final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
final paletteGenerator = await PaletteGenerator.fromImageProvider( if (mounted && color != null) {
CachedNetworkImageProvider(widget.coverUrl!), setState(() => _dominantColor = color);
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
} }
} }
Future<void> _fetchTracks() async { String _formatReleaseDate(String date) {
// Handle formats: "2024-01-15", "2024-01", "2024"
if (date.length >= 10) {
// Full date: 2024-01-15
final parts = date.substring(0, 10).split('-');
if (parts.length == 3) {
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
}
} else if (date.length >= 7) {
// Month: 2024-01
final parts = date.split('-');
if (parts.length >= 2) {
return '${parts[1]}/${parts[0]}'; // MM/YYYY
}
}
return date; // Year only or unknown format
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
Map<String, dynamic> metadata; Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) { if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId); metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
} else { } else {
// ignore: avoid_print
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
final url = 'https://open.spotify.com/album/${widget.albumId}'; final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
} }
@@ -144,11 +156,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList(); final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Extract artist ID from album_info if available
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?;
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
if (mounted) { if (mounted) {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -218,7 +235,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
expandedHeight: 320, expandedHeight: 320,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
title: AnimatedOpacity( title: AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -260,7 +277,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
), ),
), ),
), ),
// Cover image centered - fade out when collapsing
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
@@ -283,10 +299,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null child: widget.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
@@ -317,9 +334,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
); );
} }
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? []; final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -339,32 +357,59 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
), ),
if (artistName != null && artistName.isNotEmpty) ...[ if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( GestureDetector(
artistName, onTap: () => _navigateToArtist(context, artistName),
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), child: Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
), ),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
if (tracks.isNotEmpty) if (tracks.isNotEmpty)
Container( Wrap(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), spacing: 8,
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)), runSpacing: 8,
child: Row( children: [
mainAxisSize: MainAxisSize.min, Container(
children: [ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer), decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
const SizedBox(width: 4), child: Row(
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), mainAxisSize: MainAxisSize.min,
], children: [
), Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
if (releaseDate != null && releaseDate.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
],
), ),
if (tracks.isNotEmpty) ...[ if (tracks.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: () => _downloadAll(context), onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download), icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(tracks.length)), label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
), ),
], ],
], ],
@@ -443,11 +488,51 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
} }
} }
/// Build error widget with special handling for rate limit (429) void _navigateToArtist(BuildContext context, String artistName) {
// Use stored artist ID if available, otherwise use a placeholder
final artistId = _artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
// Don't navigate if artist ID is unknown
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')),
);
return;
}
// If from extension, use ExtensionArtistScreen
if (widget.extensionId != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
extensionId: widget.extensionId!,
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
),
),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
),
),
);
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) { Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') || final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') || error.toLowerCase().contains('rate limit') ||
@@ -510,7 +595,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
} }
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _AlbumTrackItem extends ConsumerWidget { class _AlbumTrackItem extends ConsumerWidget {
final Track track; final Track track;
final VoidCallback onDownload; final VoidCallback onDownload;
@@ -521,9 +605,9 @@ class _AlbumTrackItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final queueItem = ref.watch(downloadQueueProvider.select((state) { final queueItem = ref.watch(
return state.items.where((item) => item.track.id == track.id).firstOrNull; downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
})); );
final isInHistory = ref.watch(downloadHistoryProvider.select((state) { final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id); return state.isDownloaded(track.id);
@@ -543,11 +627,20 @@ class _AlbumTrackItem extends ConsumerWidget {
elevation: 0, elevation: 0,
color: Colors.transparent, color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null leading: SizedBox(
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96)) width: 32,
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), child: Center(
child: Text(
'${track.trackNumber ?? 0}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress), trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
+685 -17
View File
@@ -1,8 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
@@ -13,6 +15,7 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Simple in-memory cache for artist data /// Simple in-memory cache for artist data
class _ArtistCache { class _ArtistCache {
@@ -96,10 +99,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
int? _monthlyListeners; int? _monthlyListeners;
String? _error; String? _error;
// Sticky title state
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
// Selection mode state
bool _isSelectionMode = false;
final Set<String> _selectedAlbumIds = {};
bool _isFetchingDiscography = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -278,11 +285,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final singles = albums.where((a) => a.albumType == 'single').toList(); final singles = albums.where((a) => a.albumType == 'single').toList();
final compilations = albums.where((a) => a.albumType == 'compilation').toList(); final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold( final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty;
body: CustomScrollView(
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
_buildHeader(context, colorScheme), _buildHeader(context, colorScheme, albums: albums, hasDiscography: hasDiscography),
if (_isLoadingDiscography) if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding( const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
@@ -303,14 +321,444 @@ return Scaffold(
if (compilations.isNotEmpty) if (compilations.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)), SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
], ],
const SliverToBoxAdapter(child: SizedBox(height: 32)), // Add padding at bottom for selection bar
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
], ],
),
// Selection action bar
if (_isSelectionMode)
_buildSelectionBar(context, colorScheme, albums),
],
),
), ),
); );
} }
/// Build Spotify-style header with full-width image and artist name overlay void _exitSelectionMode() {
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) { HapticFeedback.lightImpact();
setState(() {
_isSelectionMode = false;
_selectedAlbumIds.clear();
});
}
void _enterSelectionMode(String albumId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedAlbumIds.add(albumId);
});
}
void _toggleAlbumSelection(String albumId) {
HapticFeedback.selectionClick();
setState(() {
if (_selectedAlbumIds.contains(albumId)) {
_selectedAlbumIds.remove(albumId);
if (_selectedAlbumIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedAlbumIds.add(albumId);
}
});
}
void _selectAll(List<ArtistAlbum> albums) {
setState(() {
_selectedAlbumIds.addAll(albums.map((a) => a.id));
});
}
void _deselectAll() {
setState(() {
_selectedAlbumIds.clear();
});
}
Widget _buildSelectionBar(BuildContext context, ColorScheme colorScheme, List<ArtistAlbum> allAlbums) {
final allSelected = _selectedAlbumIds.length == allAlbums.length;
final selectedCount = _selectedAlbumIds.length;
final selectedAlbums = allAlbums.where((a) => _selectedAlbumIds.contains(a.id)).toList();
final totalTracks = selectedAlbums.fold<int>(0, (sum, a) => sum + a.totalTracks);
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Close button
IconButton(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
tooltip: context.l10n.dialogCancel,
),
const SizedBox(width: 8),
// Selection info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.discographySelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (selectedCount > 0)
Text(
context.l10n.tracksCount(totalTracks),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
// Select all / Deselect button
TextButton(
onPressed: allSelected ? _deselectAll : () => _selectAll(allAlbums),
child: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
),
const SizedBox(width: 8),
// Download button
FilledButton.icon(
onPressed: selectedCount > 0 ? () => _downloadSelectedAlbums(context, selectedAlbums) : null,
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownloadSelected),
),
],
),
),
),
),
);
}
void _showDiscographyOptions(BuildContext context, ColorScheme colorScheme, List<ArtistAlbum> albums) {
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
final singles = albums.where((a) => a.albumType == 'single').toList();
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
final albumTracks = albumsOnly.fold<int>(0, (sum, a) => sum + a.totalTracks);
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle bar
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
// Title
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
children: [
Icon(Icons.download, color: colorScheme.primary),
const SizedBox(width: 12),
Text(
context.l10n.discographyDownload,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
const Divider(height: 1),
// Options
if (albums.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.library_music,
title: context.l10n.discographyDownloadAll,
subtitle: context.l10n.discographyDownloadAllSubtitle(totalTracks, albums.length),
onTap: () {
Navigator.pop(context);
_downloadAlbums(context, albums);
},
),
if (albumsOnly.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.album,
title: context.l10n.discographyAlbumsOnly,
subtitle: context.l10n.discographyAlbumsOnlySubtitle(albumTracks, albumsOnly.length),
onTap: () {
Navigator.pop(context);
_downloadAlbums(context, albumsOnly);
},
),
if (singles.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.music_note,
title: context.l10n.discographySinglesOnly,
subtitle: context.l10n.discographySinglesOnlySubtitle(singleTracks, singles.length),
onTap: () {
Navigator.pop(context);
_downloadAlbums(context, singles);
},
),
_DiscographyOptionTile(
icon: Icons.checklist,
title: context.l10n.discographySelectAlbums,
subtitle: context.l10n.discographySelectAlbumsSubtitle,
onTap: () {
Navigator.pop(context);
_enterSelectionMode(albums.first.id);
},
),
const SizedBox(height: 8),
],
),
),
),
);
}
Future<void> _downloadAlbums(BuildContext context, List<ArtistAlbum> albums) async {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
onSelect: (quality, service) {
_fetchAndQueueAlbums(albums, service, quality);
},
);
} else {
_fetchAndQueueAlbums(albums, settings.defaultService, null);
}
}
Future<void> _downloadSelectedAlbums(BuildContext context, List<ArtistAlbum> albums) async {
_exitSelectionMode();
await _downloadAlbums(context, albums);
}
Future<void> _fetchAndQueueAlbums(
List<ArtistAlbum> albums,
String service,
String? qualityOverride,
) async {
if (_isFetchingDiscography) return;
setState(() => _isFetchingDiscography = true);
// Show progress dialog
if (!mounted) {
setState(() => _isFetchingDiscography = false);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => _FetchingProgressDialog(
totalAlbums: albums.length,
onCancel: () {
setState(() => _isFetchingDiscography = false);
Navigator.pop(ctx);
},
),
);
final allTracks = <Track>[];
int fetchedCount = 0;
int failedCount = 0;
// Fetch tracks from each album
for (final album in albums) {
if (!_isFetchingDiscography) break; // Cancelled
try {
final tracks = await _fetchAlbumTracks(album);
allTracks.addAll(tracks);
} catch (e) {
failedCount++;
}
fetchedCount++;
// Update progress dialog
if (mounted) {
_FetchingProgressDialog.updateProgress(context, fetchedCount, albums.length);
}
}
setState(() => _isFetchingDiscography = false);
// Close progress dialog
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
// Show warning if some albums failed
if (failedCount > 0 && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.discographyFailedToFetch)),
);
}
if (allTracks.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.discographyNoAlbums)),
);
}
return;
}
// Check which tracks are already downloaded
final historyState = ref.read(downloadHistoryProvider);
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in allTracks) {
final isDownloaded = historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null);
if (!isDownloaded) {
tracksToQueue.add(track);
} else {
skippedCount++;
}
}
if (tracksToQueue.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.discographySkippedDownloaded(0, skippedCount)),
),
);
}
return;
}
// Add to queue
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: qualityOverride,
);
// Show success message
if (mounted) {
final message = skippedCount > 0
? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount)
: context.l10n.discographyAddedToQueue(tracksToQueue.length);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
action: SnackBarAction(
label: context.l10n.snackbarViewQueue,
onPressed: () {
// Navigate to queue tab (index 1)
// This will be handled by the navigation system
},
),
),
);
}
}
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
if (album.providerId != null && album.providerId!.isNotEmpty) {
// Extension album
final result = await PlatformBridge.getAlbumWithExtension(album.providerId!, album.id);
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
} else if (album.id.startsWith('deezer:')) {
// Deezer album
final deezerId = album.id.replaceFirst('deezer:', '');
final metadata = await PlatformBridge.getDeezerMetadata('album', deezerId);
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album)).toList();
}
} else {
// Spotify album
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<dynamic>;
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
// Fallback to direct Spotify metadata
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
}
return [];
}
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
int durationMs = 0;
final durationValue = data['duration'];
if (durationValue is int) {
durationMs = durationValue * 1000; // Deezer returns seconds
} else if (durationValue is double) {
durationMs = (durationValue * 1000).toInt();
}
return Track(
id: 'deezer:${data['id']}',
name: (data['title'] ?? data['name'] ?? '').toString(),
artistName: (data['artist']?['name'] ?? data['artist'] ?? widget.artistName).toString(),
albumName: album.name,
albumArtist: widget.artistName,
coverUrl: album.coverUrl,
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_position'] as int? ?? data['track_number'] as int?,
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
releaseDate: album.releaseDate,
albumType: album.albumType,
);
}
Widget _buildHeader(BuildContext context, ColorScheme colorScheme, {
required List<ArtistAlbum> albums,
required bool hasDiscography,
}) {
String? imageUrl = _headerImageUrl; String? imageUrl = _headerImageUrl;
if (imageUrl == null || imageUrl.isEmpty) { if (imageUrl == null || imageUrl.isEmpty) {
imageUrl = widget.headerImageUrl; imageUrl = widget.headerImageUrl;
@@ -331,7 +779,7 @@ return Scaffold(
} }
return SliverAppBar( return SliverAppBar(
expandedHeight: 380, expandedHeight: hasDiscography ? 420 : 380,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -355,12 +803,13 @@ return SliverAppBar(
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (hasValidImage) if (hasValidImage)
CachedNetworkImage( CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
alignment: Alignment.topCenter, // Show top of image (faces) alignment: Alignment.topCenter, // Show top of image (faces)
memCacheWidth: 800, memCacheWidth: 800,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
), ),
@@ -429,6 +878,26 @@ return SliverAppBar(
), ),
), ),
], ],
// Download Discography button
if (hasDiscography && !_isSelectionMode) ...[
const SizedBox(height: 12),
SizedBox(
height: 40,
child: FilledButton.icon(
onPressed: () => _showDiscographyOptions(context, colorScheme, albums),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownload),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
),
],
], ],
), ),
), ),
@@ -477,11 +946,10 @@ return SliverAppBar(
); );
} }
/// Build a single popular track item with dynamic download status
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
final queueItem = ref.watch(downloadQueueProvider.select((state) { final queueItem = ref.watch(
return state.items.where((item) => item.track.id == track.id).firstOrNull; downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
})); );
final isInHistory = ref.watch(downloadHistoryProvider.select((state) { final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id); return state.isDownloaded(track.id);
@@ -515,12 +983,13 @@ return SliverAppBar(
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: track.coverUrl != null child: track.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: track.coverUrl!, imageUrl: track.coverUrl!,
width: 48, width: 48,
height: 48, height: 48,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 96, memCacheWidth: 96,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
width: 48, width: 48,
height: 48, height: 48,
@@ -605,7 +1074,6 @@ return SliverAppBar(
_downloadTrack(track); _downloadTrack(track);
} }
/// Build download button with status indicator for popular tracks
Widget _buildPopularDownloadButton({ Widget _buildPopularDownloadButton({
required Track track, required Track track,
required ColorScheme colorScheme, required ColorScheme colorScheme,
@@ -740,23 +1208,39 @@ return SliverAppBar(
} }
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) { Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
final isSelected = _selectedAlbumIds.contains(album.id);
return GestureDetector( return GestureDetector(
onTap: () => _navigateToAlbum(album), onTap: () {
if (_isSelectionMode) {
_toggleAlbumSelection(album.id);
} else {
_navigateToAlbum(album);
}
},
onLongPress: () {
if (!_isSelectionMode) {
_enterSelectionMode(album.id);
}
},
child: Container( child: Container(
width: 140, width: 140,
margin: const EdgeInsets.symmetric(horizontal: 4), margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Stack(
children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null child: album.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: album.coverUrl!, imageUrl: album.coverUrl!,
width: 140, width: 140,
height: 140, height: 140,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 280, memCacheWidth: 280,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
width: 140, width: 140,
height: 140, height: 140,
@@ -775,6 +1259,50 @@ return SliverAppBar(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40), child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
), ),
),
// Selection overlay
if (_isSelectionMode)
Positioned.fill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
border: isSelected
? Border.all(color: colorScheme.primary, width: 3)
: null,
),
),
),
// Checkbox
if (_isSelectionMode)
Positioned(
top: 8,
right: 8,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 18)
: null,
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@@ -886,3 +1414,143 @@ return SliverAppBar(
); );
} }
} }
/// Option tile for discography download bottom sheet
class _DiscographyOptionTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _DiscographyOptionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 24),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(
subtitle,
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
),
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
onTap: onTap,
);
}
}
/// Progress dialog shown while fetching album tracks
class _FetchingProgressDialog extends StatefulWidget {
final int totalAlbums;
final VoidCallback onCancel;
const _FetchingProgressDialog({
required this.totalAlbums,
required this.onCancel,
});
// Static method to update progress from outside
static void updateProgress(BuildContext context, int current, int total) {
final state = context.findAncestorStateOfType<_FetchingProgressDialogState>();
state?._updateProgress(current, total);
}
@override
State<_FetchingProgressDialog> createState() => _FetchingProgressDialogState();
}
class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
int _current = 0;
int _total = 0;
@override
void initState() {
super.initState();
_total = widget.totalAlbums;
}
void _updateProgress(int current, int total) {
if (mounted) {
setState(() {
_current = current;
_total = total;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final progress = _total > 0 ? _current / _total : 0.0;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
SizedBox(
width: 64,
height: 64,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 4,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.library_music, color: colorScheme.primary, size: 24),
],
),
),
const SizedBox(height: 20),
Text(
context.l10n.discographyFetchingTracks,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
context.l10n.discographyFetchingAlbum(_current, _total),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress > 0 ? progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
minHeight: 6,
),
),
],
),
actions: [
TextButton(
onPressed: widget.onCancel,
child: Text(context.l10n.dialogCancel),
),
],
);
}
}
+49 -46
View File
@@ -3,8 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -58,37 +59,36 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Future<void> _extractDominantColor() async { Future<void> _extractDominantColor() async {
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return; if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
// Only use network images for palette extraction // Check cache first (instant)
final isNetworkUrl = widget.coverUrl!.startsWith('http://') || final cached = PaletteService.instance.getCached(widget.coverUrl);
widget.coverUrl!.startsWith('https://'); if (cached != null) {
if (!isNetworkUrl) return; if (mounted && cached != _dominantColor) {
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() { setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ?? _dominantColor = cached;
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
}); });
} }
} catch (_) { return;
// Ignore palette extraction errors }
// Extract in isolate (non-blocking)
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
} }
} }
/// Get tracks for this album from history provider (reactive) /// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) { List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) { return allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName // Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist! ? item.albumArtist!
: item.artistName; : item.artistName;
final itemKey = '${item.albumName}|$itemArtist'; // Use lowercase for case-insensitive matching
final albumKey = '${widget.albumName}|${widget.artistName}'; final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
return itemKey == albumKey; return itemKey == albumKey;
}).toList() }).toList()
..sort((a, b) { ..sort((a, b) {
@@ -103,24 +103,15 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}); });
} }
/// Get unique disc numbers from tracks (sorted) Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
List<int> _getDiscNumbers(List<DownloadHistoryItem> tracks) { List<DownloadHistoryItem> tracks,
final discNumbers = tracks ) {
.map((t) => t.discNumber ?? 1) final discMap = <int, List<DownloadHistoryItem>>{};
.toSet() for (final track in tracks) {
.toList() final discNumber = track.discNumber ?? 1;
..sort(); discMap.putIfAbsent(discNumber, () => []).add(track);
return discNumbers; }
} return discMap;
/// Check if album has multiple discs
bool _hasMultipleDiscs(List<DownloadHistoryItem> tracks) {
return _getDiscNumbers(tracks).length > 1;
}
/// Get tracks for a specific disc
List<DownloadHistoryItem> _getTracksForDisc(List<DownloadHistoryItem> tracks, int discNumber) {
return tracks.where((t) => (t.discNumber ?? 1) == discNumber).toList();
} }
void _enterSelectionMode(String itemId) { void _enterSelectionMode(String itemId) {
@@ -223,6 +214,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
void _navigateToMetadataScreen(DownloadHistoryItem item) { void _navigateToMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl);
Navigator.push(context, PageRouteBuilder( Navigator.push(context, PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300), transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250), reverseTransitionDuration: const Duration(milliseconds: 250),
@@ -231,6 +223,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
)); ));
} }
void _precacheCover(String? url) {
if (url == null || url.isEmpty) return;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return;
}
precacheImage(
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
context,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -368,10 +371,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null child: widget.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
@@ -501,9 +505,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
// Check if album has multiple discs final discMap = _groupTracksByDisc(tracks);
if (!_hasMultipleDiscs(tracks)) {
// Single disc - use simple list if (discMap.length <= 1) {
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
@@ -518,13 +522,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
); );
} }
// Multiple discs - build list with separators final discNumbers = discMap.keys.toList()..sort();
final discNumbers = _getDiscNumbers(tracks);
final List<Widget> children = []; final List<Widget> children = [];
for (final discNumber in discNumbers) { for (final discNumber in discNumbers) {
final discTracks = _getTracksForDisc(tracks, discNumber); final discTracks = discMap[discNumber];
if (discTracks.isEmpty) continue; if (discTracks == null || discTracks.isEmpty) continue;
// Add disc separator // Add disc separator
children.add(_buildDiscSeparator(context, colorScheme, discNumber)); children.add(_buildDiscSeparator(context, colorScheme, discNumber));
+29 -25
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -45,16 +46,15 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
} }
} }
void _downloadTrack(int index) { void _downloadTrack(Track track) {
final trackState = ref.read(trackProvider); final settings = ref.read(settingsProvider);
if (index >= 0 && index < trackState.tracks.length) { ref.read(downloadQueueProvider.notifier).addToQueue(
final track = trackState.tracks[index]; track,
final settings = ref.read(settingsProvider); settings.defaultService,
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')), SnackBar(content: Text('Added "${track.name}" to queue')),
); );
}
} }
void _downloadAll() { void _downloadAll() {
@@ -88,8 +88,10 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider); final trackState = ref.watch(trackProvider);
final queueState = ref.watch(downloadQueueProvider); final queuedCount =
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -145,13 +147,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
if (trackState.albumName != null || trackState.playlistName != null) if (trackState.albumName != null || trackState.playlistName != null)
_buildHeader(trackState, colorScheme), _buildHeader(trackState, colorScheme),
if (trackState.tracks.length > 1) if (tracks.length > 1)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: FilledButton.icon( child: FilledButton.icon(
onPressed: _downloadAll, onPressed: _downloadAll,
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
label: Text('Download All (${trackState.tracks.length})'), label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48), minimumSize: const Size.fromHeight(48),
), ),
@@ -159,11 +161,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
), ),
Expanded( Expanded(
child: trackState.tracks.isEmpty child: tracks.isEmpty
? _buildEmptyState(colorScheme) ? _buildEmptyState(colorScheme)
: ListView.builder( : ListView.builder(
itemCount: trackState.tracks.length, itemCount: tracks.length,
itemBuilder: (context, index) => _buildTrackTile(index, colorScheme), itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
), ),
), ),
], ],
@@ -179,13 +182,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
), ),
NavigationDestination( NavigationDestination(
icon: Badge( icon: Badge(
isLabelVisible: queueState.queuedCount > 0, isLabelVisible: queuedCount > 0,
label: Text('${queueState.queuedCount}'), label: Text('$queuedCount'),
child: const Icon(Icons.queue_music_outlined), child: const Icon(Icons.queue_music_outlined),
), ),
selectedIcon: Badge( selectedIcon: Badge(
isLabelVisible: queueState.queuedCount > 0, isLabelVisible: queuedCount > 0,
label: Text('${queueState.queuedCount}'), label: Text('$queuedCount'),
child: const Icon(Icons.queue_music), child: const Icon(Icons.queue_music),
), ),
label: 'Queue', label: 'Queue',
@@ -210,11 +213,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
if (state.coverUrl != null) if (state.coverUrl != null)
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: state.coverUrl!, imageUrl: state.coverUrl!,
width: 80, width: 80,
height: 80, height: 80,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container( placeholder: (_, _) => Container(
width: 80, width: 80,
height: 80, height: 80,
@@ -259,8 +263,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
); );
} }
Widget _buildTrackTile(int index, ColorScheme colorScheme) { Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
final track = ref.watch(trackProvider).tracks[index];
final isCollection = track.isCollection; final isCollection = track.isCollection;
String subtitleText; String subtitleText;
@@ -281,11 +284,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
leading: track.coverUrl != null leading: track.coverUrl != null
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: track.coverUrl!, imageUrl: track.coverUrl!,
width: 48, width: 48,
height: 48, height: 48,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
), ),
) )
: Container( : Container(
@@ -315,7 +319,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index), onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
); );
} }
+763 -116
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -120,7 +120,6 @@ class _MainShellState extends ConsumerState<MainShell> {
} }
} }
/// Handle back press with double-tap to exit
void _handleBackPress() { void _handleBackPress() {
final trackState = ref.read(trackProvider); final trackState = ref.read(trackProvider);
@@ -174,9 +173,6 @@ class _MainShellState extends ConsumerState<MainShell> {
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Determine if we can pop (for predictive back animation)
// canPop is true when we're at root with no content - enables predictive back gesture
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
final canPop = _currentIndex == 0 && final canPop = _currentIndex == 0 &&
!trackState.hasSearchText && !trackState.hasSearchText &&
!trackState.hasContent && !trackState.hasContent &&
@@ -250,8 +246,6 @@ class _MainShellState extends ConsumerState<MainShell> {
canPop: canPop, canPop: canPop,
onPopInvokedWithResult: (didPop, result) async { onPopInvokedWithResult: (didPop, result) async {
if (didPop) { if (didPop) {
// System handled the pop - this means predictive back completed
// We need to handle double-tap to exit here
return; return;
} }
+18 -25
View File
@@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
@@ -10,7 +11,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Playlist detail screen with Material Expressive 3 design
class PlaylistScreen extends ConsumerStatefulWidget { class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName; final String playlistName;
final String? coverUrl; final String? coverUrl;
@@ -55,20 +55,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Future<void> _extractDominantColor() async { Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return; if (widget.coverUrl == null) return;
try { final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
final paletteGenerator = await PaletteGenerator.fromImageProvider( if (mounted && color != null) {
CachedNetworkImageProvider(widget.coverUrl!), setState(() => _dominantColor = color);
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
} }
} }
@@ -164,10 +153,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null child: widget.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
@@ -225,12 +215,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
], ],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: () => _downloadAll(context), onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download), icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(widget.tracks.length)), label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
), ),
], ],
), ),
@@ -323,9 +316,9 @@ class _PlaylistTrackItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final queueItem = ref.watch(downloadQueueProvider.select((state) { final queueItem = ref.watch(
return state.items.where((item) => item.track.id == track.id).firstOrNull; downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
})); );
final isInHistory = ref.watch(downloadHistoryProvider.select((state) { final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id); return state.isDownloaded(track.id);
@@ -347,8 +340,8 @@ class _PlaylistTrackItem extends ConsumerWidget {
margin: const EdgeInsets.symmetric(vertical: 2), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96)) ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), : Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
+10 -7
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -10,20 +11,20 @@ class QueueScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final queueState = ref.watch(downloadQueueProvider); final items = ref.watch(downloadQueueProvider.select((s) => s.items));
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.l10n.queueTitle), title: Text(context.l10n.queueTitle),
actions: [ actions: [
if (queueState.items.isNotEmpty) if (items.isNotEmpty)
IconButton( IconButton(
icon: const Icon(Icons.delete_sweep), icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(), onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: context.l10n.queueClearCompleted, tooltip: context.l10n.queueClearCompleted,
), ),
if (queueState.items.isNotEmpty) if (items.isNotEmpty)
IconButton( IconButton(
icon: const Icon(Icons.clear_all), icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref), onPressed: () => _showClearAllDialog(context, ref),
@@ -31,11 +32,12 @@ class QueueScreen extends ConsumerWidget {
), ),
], ],
), ),
body: queueState.items.isEmpty body: items.isEmpty
? _buildEmptyState(context, colorScheme) ? _buildEmptyState(context, colorScheme)
: ListView.builder( : ListView.builder(
itemCount: queueState.items.length, itemCount: items.length,
itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme), itemBuilder: (context, index) =>
_buildQueueItem(context, ref, items[index], colorScheme),
), ),
); );
} }
@@ -74,11 +76,12 @@ class QueueScreen extends ConsumerWidget {
leading: item.track.coverUrl != null leading: item.track.coverUrl != null
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: item.track.coverUrl!, imageUrl: item.track.coverUrl!,
width: 48, width: 48,
height: 48, height: 48,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
), ),
) )
: Container( : Container(
+456 -184
View File
@@ -1,9 +1,12 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
@@ -12,13 +15,13 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
/// Grouped album data for history display
class _GroupedAlbum { class _GroupedAlbum {
final String albumName; final String albumName;
final String artistName; final String artistName;
final String? coverUrl; final String? coverUrl;
final List<DownloadHistoryItem> tracks; final List<DownloadHistoryItem> tracks;
final DateTime latestDownload; final DateTime latestDownload;
final String searchKey;
_GroupedAlbum({ _GroupedAlbum({
required this.albumName, required this.albumName,
@@ -26,11 +29,61 @@ class _GroupedAlbum {
this.coverUrl, this.coverUrl,
required this.tracks, required this.tracks,
required this.latestDownload, required this.latestDownload,
}); }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
String get key => '$albumName|$artistName'; String get key => '$albumName|$artistName';
} }
class _HistoryStats {
final Map<String, int> albumCounts;
final List<_GroupedAlbum> groupedAlbums;
final int albumCount;
final int singleTracks;
const _HistoryStats({
required this.albumCounts,
required this.groupedAlbums,
required this.albumCount,
required this.singleTracks,
});
}
Map<String, List<String>> _filterHistoryInIsolate(
Map<String, Object> payload,
) {
final entries = (payload['entries'] as List).cast<List>();
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
final query = (payload['query'] as String?) ?? '';
final allIds = <String>[];
final albumIds = <String>[];
final singleIds = <String>[];
for (final entry in entries) {
final id = entry[0] as String;
final albumKey = entry[1] as String;
final searchKey = entry[2] as String;
if (query.isNotEmpty && !searchKey.contains(query)) {
continue;
}
allIds.add(id);
final count = albumCounts[albumKey] ?? 0;
if (count > 1) {
albumIds.add(id);
} else if (count == 1) {
singleIds.add(id);
}
}
return {
'all': allIds,
'albums': albumIds,
'singles': singleIds,
};
}
class QueueTab extends ConsumerStatefulWidget { class QueueTab extends ConsumerStatefulWidget {
final PageController? parentPageController; final PageController? parentPageController;
final int parentPageIndex; final int parentPageIndex;
@@ -59,6 +112,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final List<String> _filterModes = ['all', 'albums', 'singles']; final List<String> _filterModes = ['all', 'albums', 'singles'];
bool _isPageControllerInitialized = false; bool _isPageControllerInitialized = false;
// Search functionality
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String _searchQuery = '';
Timer? _searchDebounce;
List<DownloadHistoryItem>? _historyItemsCache;
_HistoryStats? _historyStatsCache;
final Map<String, String> _searchIndexCache = {};
Map<String, DownloadHistoryItem> _historyItemsById = {};
List<List<String>> _historyFilterEntries = const [];
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
List<DownloadHistoryItem>? _filterItemsCache;
String _filterQueryCache = '';
bool _filterRefreshScheduled = false;
bool _isFilteringHistory = false;
int _filterRequestId = 0;
static const int _filterIsolateThreshold = 800;
@override @override
@@ -74,12 +145,178 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_filterPageController = PageController(initialPage: initialPage); _filterPageController = PageController(initialPage: initialPage);
} }
@override @override
void dispose() { void dispose() {
_filterPageController?.dispose(); _filterPageController?.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
_searchDebounce?.cancel();
super.dispose(); super.dispose();
} }
void _onSearchChanged(String value) {
_searchDebounce?.cancel();
final normalized = value.trim().toLowerCase();
_searchDebounce = Timer(const Duration(milliseconds: 180), () {
if (!mounted || _searchQuery == normalized) return;
setState(() => _searchQuery = normalized);
_requestFilterRefresh();
});
}
void _clearSearch() {
_searchDebounce?.cancel();
if (_searchQuery.isEmpty) return;
setState(() => _searchQuery = '');
_requestFilterRefresh();
}
void _ensureHistoryCaches(List<DownloadHistoryItem> items) {
if (identical(items, _historyItemsCache)) return;
_historyItemsCache = items;
_historyStatsCache = _buildHistoryStats(items);
_searchIndexCache
..clear()
..addEntries(
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
);
_historyItemsById = {for (final item in items) item.id: item};
_historyFilterEntries = List<List<String>>.generate(
items.length,
(index) {
final item = items[index];
final searchKey =
_searchIndexCache[item.id] ?? _buildSearchKey(item);
final albumKey =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
return [item.id, albumKey, searchKey];
},
growable: false,
);
_requestFilterRefresh();
}
String _buildSearchKey(DownloadHistoryItem item) {
return '${item.trackName} ${item.artistName} ${item.albumName}'
.toLowerCase();
}
bool _isFilterCacheValid(List<DownloadHistoryItem> items, String query) {
return identical(items, _filterItemsCache) && query == _filterQueryCache;
}
void _requestFilterRefresh() {
if (_filterRefreshScheduled) return;
_filterRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_filterRefreshScheduled = false;
if (!mounted) return;
_scheduleHistoryFilterUpdate();
});
}
void _scheduleHistoryFilterUpdate() {
final items = _historyItemsCache;
if (items == null) return;
final query = _searchQuery;
if (_isFilterCacheValid(items, query)) return;
final albumCounts =
_historyStatsCache?.albumCounts ?? const <String, int>{};
if (items.isEmpty) {
setState(() {
_filteredHistoryCache = const {};
_filterItemsCache = items;
_filterQueryCache = query;
_isFilteringHistory = false;
});
return;
}
if (items.length <= _filterIsolateThreshold) {
final filteredAll =
_filterHistoryItems(items, 'all', albumCounts, query);
final filteredAlbums =
_filterHistoryItems(items, 'albums', albumCounts, query);
final filteredSingles =
_filterHistoryItems(items, 'singles', albumCounts, query);
setState(() {
_filteredHistoryCache = {
'all': filteredAll,
'albums': filteredAlbums,
'singles': filteredSingles,
};
_filterItemsCache = items;
_filterQueryCache = query;
_isFilteringHistory = false;
});
return;
}
if (!_isFilteringHistory) {
setState(() => _isFilteringHistory = true);
}
final requestId = ++_filterRequestId;
final payload = <String, Object>{
'entries': _historyFilterEntries,
'albumCounts': albumCounts,
'query': query,
};
compute(_filterHistoryInIsolate, payload).then((result) {
if (!mounted || requestId != _filterRequestId) return;
final itemsById = _historyItemsById;
final filtered = <String, List<DownloadHistoryItem>>{};
for (final entry in result.entries) {
filtered[entry.key] = entry.value
.map((id) => itemsById[id])
.whereType<DownloadHistoryItem>()
.toList(growable: false);
}
setState(() {
_filteredHistoryCache = filtered;
_filterItemsCache = items;
_filterQueryCache = query;
_isFilteringHistory = false;
});
});
}
List<DownloadHistoryItem> _resolveHistoryItems({
required String filterMode,
required List<DownloadHistoryItem> allHistoryItems,
required Map<String, int> albumCounts,
}) {
final query = _searchQuery;
if (_isFilterCacheValid(allHistoryItems, query)) {
final cached = _filteredHistoryCache[filterMode];
if (cached != null) return cached;
}
if (allHistoryItems.isEmpty) return const [];
if (query.isEmpty && filterMode == 'all') return allHistoryItems;
if (allHistoryItems.length <= _filterIsolateThreshold) {
return _filterHistoryItems(
allHistoryItems,
filterMode,
albumCounts,
query,
);
}
return const [];
}
bool _shouldShowFilteringIndicator({
required List<DownloadHistoryItem> allHistoryItems,
required String filterMode,
}) {
if (allHistoryItems.isEmpty) return false;
if (_searchQuery.isEmpty && filterMode == 'all') return false;
if (allHistoryItems.length <= _filterIsolateThreshold) return false;
return !_isFilterCacheValid(allHistoryItems, _searchQuery) ||
_isFilteringHistory;
}
void _onFilterPageChanged(int index) { void _onFilterPageChanged(int index) {
final filterMode = _filterModes[index]; final filterMode = _filterModes[index];
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
@@ -93,7 +330,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
/// Enter selection mode with initial item
void _enterSelectionMode(String itemId) { void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
setState(() { setState(() {
@@ -110,7 +346,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}); });
} }
/// Toggle item selection
void _toggleSelection(String itemId) { void _toggleSelection(String itemId) {
setState(() { setState(() {
if (_selectedIds.contains(itemId)) { if (_selectedIds.contains(itemId)) {
@@ -131,7 +366,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}); });
} }
/// Delete selected items
Future<void> _deleteSelected() async { Future<void> _deleteSelected() async {
final count = _selectedIds.length; final count = _selectedIds.length;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -234,6 +468,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
} }
void _precacheCover(String? url) {
if (url == null || url.isEmpty) return;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return;
}
precacheImage(
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
context,
);
}
void _navigateToMetadataScreen(DownloadItem item) { void _navigateToMetadataScreen(DownloadItem item) {
final historyItem = ref final historyItem = ref
.read(downloadHistoryProvider) .read(downloadHistoryProvider)
@@ -252,6 +497,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
), ),
); );
_precacheCover(historyItem.coverUrl);
_searchFocusNode.unfocus();
Navigator.push( Navigator.push(
context, context,
PageRouteBuilder( PageRouteBuilder(
@@ -262,10 +509,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
transitionsBuilder: (context, animation, secondaryAnimation, child) => transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child), FadeTransition(opacity: animation, child: child),
), ),
); ).then((_) => _searchFocusNode.unfocus());
} }
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl);
_searchFocusNode.unfocus();
Navigator.push( Navigator.push(
context, context,
PageRouteBuilder( PageRouteBuilder(
@@ -276,121 +525,105 @@ class _QueueTabState extends ConsumerState<QueueTab> {
transitionsBuilder: (context, animation, secondaryAnimation, child) => transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child), FadeTransition(opacity: animation, child: child),
), ),
); ).then((_) => _searchFocusNode.unfocus());
} }
/// Filter history items based on current filter mode List<DownloadHistoryItem> _filterHistoryItems(
/// Album = track yang albumnya punya >1 track di history
/// Single = track yang albumnya cuma 1 track di history
List<DownloadHistoryItem> _filterHistoryItems(
List<DownloadHistoryItem> items, List<DownloadHistoryItem> items,
String filterMode, String filterMode,
) { Map<String, int> albumCounts, [
if (filterMode == 'all') return items; String searchQuery = '',
]) {
final albumCounts = <String, int>{}; // First apply search filter
for (final item in items) { var filteredItems = items;
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; if (searchQuery.isNotEmpty) {
albumCounts[key] = (albumCounts[key] ?? 0) + 1; final query = searchQuery;
filteredItems = items.where((item) {
final searchKey =
_searchIndexCache[item.id] ?? _buildSearchKey(item);
if (!_searchIndexCache.containsKey(item.id)) {
_searchIndexCache[item.id] = searchKey;
}
return searchKey.contains(query);
}).toList();
} }
switch (filterMode) { // Then apply filter mode
if (filterMode == 'all') return filteredItems;
switch (filterMode) {
case 'albums': case 'albums':
return items.where((item) { return filteredItems.where((item) {
final key = final key =
'${item.albumName}|${item.albumArtist ?? item.artistName}'; '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
return (albumCounts[key] ?? 0) > 1; return (albumCounts[key] ?? 0) > 1;
}).toList(); }).toList();
case 'singles': case 'singles':
return items.where((item) { return filteredItems.where((item) {
final key = final key =
'${item.albumName}|${item.albumArtist ?? item.artistName}'; '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
return (albumCounts[key] ?? 0) == 1; return (albumCounts[key] ?? 0) == 1;
}).toList(); }).toList();
default: default:
return items; return filteredItems;
} }
} }
/// Count albums vs singles for filter chips _HistoryStats _buildHistoryStats(List<DownloadHistoryItem> items) {
Map<String, int> _countAlbumsAndSingles(List<DownloadHistoryItem> items) {
final albumCounts = <String, int>{}; final albumCounts = <String, int>{};
final albumMap = <String, List<DownloadHistoryItem>>{};
for (final item in items) { for (final item in items) {
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; // Use lowercase key for case-insensitive grouping
final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
albumCounts[key] = (albumCounts[key] ?? 0) + 1; albumCounts[key] = (albumCounts[key] ?? 0) + 1;
albumMap.putIfAbsent(key, () => []).add(item);
} }
int albumTracks = 0;
int singleTracks = 0; int singleTracks = 0;
for (final item in items) { for (final item in items) {
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
if ((albumCounts[key] ?? 0) > 1) { if ((albumCounts[key] ?? 0) <= 1) {
albumTracks++;
} else {
singleTracks++; singleTracks++;
} }
} }
return {'albums': albumTracks, 'singles': 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);
});
/// Group history items by album (for Albums filter view) groupedAlbums.add(_GroupedAlbum(
List<_GroupedAlbum> _groupByAlbum(List<DownloadHistoryItem> items) { albumName: tracks.first.albumName,
final albumMap = <String, List<DownloadHistoryItem>>{}; artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverUrl: tracks.first.coverUrl,
for (final item in items) { tracks: tracks,
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; latestDownload: tracks
albumMap.putIfAbsent(key, () => []).add(item); .map((t) => t.downloadedAt)
} .reduce((a, b) => a.isAfter(b) ? a : b),
));
final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map( });
(e) {
final tracks = e.value;
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
return aNum.compareTo(bNum);
});
return _GroupedAlbum(
albumName: tracks.first.albumName,
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverUrl: tracks.first.coverUrl,
tracks: tracks,
latestDownload: tracks
.map((t) => t.downloadedAt)
.reduce((a, b) => a.isAfter(b) ? a : b),
);
},
).toList();
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
return groupedAlbums; int albumCount = 0;
} for (final count in albumCounts.values) {
if (count > 1) albumCount++;
/// Count unique albums (for filter chip badge)
int _countUniqueAlbums(List<DownloadHistoryItem> items) {
final albumKeys = <String>{};
for (final item in items) {
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
albumKeys.add(key);
} }
int count = 0; return _HistoryStats(
for (final key in albumKeys) { albumCounts: albumCounts,
final trackCount = items groupedAlbums: groupedAlbums,
.where( albumCount: albumCount,
(i) => '${i.albumName}|${i.albumArtist ?? i.artistName}' == key, singleTracks: singleTracks,
) );
.length;
if (trackCount > 1) count++;
}
return count;
} }
void _navigateToDownloadedAlbum(_GroupedAlbum album) { void _navigateToDownloadedAlbum(_GroupedAlbum album) {
_searchFocusNode.unfocus();
Navigator.push( Navigator.push(
context, context,
PageRouteBuilder( PageRouteBuilder(
@@ -405,27 +638,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
transitionsBuilder: (context, animation, secondaryAnimation, child) => transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child), FadeTransition(opacity: animation, child: child),
), ),
); ).then((_) => _searchFocusNode.unfocus());
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_initializePageController(); _initializePageController();
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
final isProcessing = ref.watch(
downloadQueueProvider.select((s) => s.isProcessing),
);
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
final queuedCount = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount),
);
final completedCount = ref.watch(
downloadQueueProvider.select((s) => s.completedCount),
);
final allHistoryItems = ref.watch( final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items), downloadHistoryProvider.select((s) => s.items),
); );
_ensureHistoryCaches(allHistoryItems);
final historyViewMode = ref.watch( final historyViewMode = ref.watch(
settingsProvider.select((s) => s.historyViewMode), settingsProvider.select((s) => s.historyViewMode),
); );
@@ -435,11 +659,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
final groupedAlbums = _groupByAlbum(allHistoryItems); final historyStats =
_historyStatsCache ?? _buildHistoryStats(allHistoryItems);
final counts = _countAlbumsAndSingles(allHistoryItems); final groupedAlbums = historyStats.groupedAlbums;
final albumCount = _countUniqueAlbums(allHistoryItems); final albumCount = historyStats.albumCount;
final singleCount = counts['singles'] ?? 0; final singleCount = historyStats.singleTracks;
final bottomPadding = MediaQuery.of(context).padding.bottom; final bottomPadding = MediaQuery.of(context).padding.bottom;
@@ -491,68 +715,82 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
}, },
), ),
), ),
if ((isProcessing || queuedCount > 0) && // Search bar - always at top
(queueItems.length > 1 || isPaused)) if (allHistoryItems.isNotEmpty || queueItems.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Card( child: GestureDetector(
child: Padding( onTap: () {},
padding: const EdgeInsets.all(12), child: TextField(
child: Row( controller: _searchController,
children: [ focusNode: _searchFocusNode,
Container( autofocus: false,
padding: const EdgeInsets.all(8), canRequestFocus: true,
decoration: BoxDecoration( decoration: InputDecoration(
color: isPaused hintText: context.l10n.historySearchHint,
? colorScheme.errorContainer prefixIcon: const Icon(Icons.search),
: colorScheme.primaryContainer, suffixIcon: _searchQuery.isNotEmpty
borderRadius: BorderRadius.circular(12), ? IconButton(
), icon: const Icon(Icons.clear),
child: Icon( onPressed: () {
isPaused ? Icons.pause : Icons.downloading, _searchController.clear();
color: isPaused _clearSearch();
? colorScheme.onErrorContainer FocusScope.of(context).unfocus();
: colorScheme.onPrimaryContainer, },
), )
: null,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
), ),
const SizedBox(width: 12), ),
Expanded( enabledBorder: OutlineInputBorder(
child: Text( borderRadius: BorderRadius.circular(28),
isPaused borderSide: BorderSide(
? 'Paused' color: colorScheme.outlineVariant,
: '$completedCount/${queueItems.length}', width: 1.5,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
), ),
FilledButton.tonal( ),
onPressed: () => ref focusedBorder: OutlineInputBorder(
.read(downloadQueueProvider.notifier) borderRadius: BorderRadius.circular(28),
.togglePause(), borderSide: BorderSide(
child: Text(isPaused ? 'Resume' : 'Pause'), color: colorScheme.primary,
width: 2.5,
), ),
], ),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
), ),
onChanged: _onSearchChanged,
onTapOutside: (_) {
FocusScope.of(context).unfocus();
},
), ),
), ),
),
), ),
),
if (queueItems.isNotEmpty) if (queueItems.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Text( child: Text(
'Downloading (${queueItems.length})', 'Downloading (${queueItems.length})',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
),
), ),
),
if (queueItems.isNotEmpty) if (queueItems.isNotEmpty)
SliverList( SliverList(
@@ -562,7 +800,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
key: ValueKey(item.id), key: ValueKey(item.id),
child: _buildQueueItem(context, item, colorScheme), child: _buildQueueItem(context, item, colorScheme),
); );
}, childCount: queueItems.length), }, childCount: queueItems.length),
), ),
if (allHistoryItems.isNotEmpty) if (allHistoryItems.isNotEmpty)
@@ -666,39 +904,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return false; return false;
}, },
child: PageView( child: PageView.builder(
controller: _filterPageController!, controller: _filterPageController!,
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
onPageChanged: _onFilterPageChanged, onPageChanged: _onFilterPageChanged,
children: [ itemCount: _filterModes.length,
_buildFilterContent( itemBuilder: (context, index) {
final filterMode = _filterModes[index];
return _buildFilterContent(
context: context, context: context,
colorScheme: colorScheme, colorScheme: colorScheme,
filterMode: 'all', filterMode: filterMode,
allHistoryItems: allHistoryItems, allHistoryItems: allHistoryItems,
historyViewMode: historyViewMode, historyViewMode: historyViewMode,
queueItems: queueItems, queueItems: queueItems,
groupedAlbums: groupedAlbums, groupedAlbums: groupedAlbums,
), albumCounts: historyStats.albumCounts,
_buildFilterContent( );
context: context, },
colorScheme: colorScheme,
filterMode: 'albums',
allHistoryItems: allHistoryItems,
historyViewMode: historyViewMode,
queueItems: queueItems,
groupedAlbums: groupedAlbums,
),
_buildFilterContent(
context: context,
colorScheme: colorScheme,
filterMode: 'singles',
allHistoryItems: allHistoryItems,
historyViewMode: historyViewMode,
queueItems: queueItems,
groupedAlbums: groupedAlbums,
),
],
), ),
), ),
), ),
@@ -710,10 +933,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
left: 0, left: 0,
right: 0, right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar( child: _buildSelectionBottomBar(
context, context,
colorScheme, colorScheme,
_filterHistoryItems(allHistoryItems, historyFilterMode), _resolveHistoryItems(
filterMode: historyFilterMode,
allHistoryItems: allHistoryItems,
albumCounts: historyStats.albumCounts,
),
bottomPadding, bottomPadding,
), ),
), ),
@@ -722,7 +949,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
/// Build content for each filter tab
Widget _buildFilterContent({ Widget _buildFilterContent({
required BuildContext context, required BuildContext context,
required ColorScheme colorScheme, required ColorScheme colorScheme,
@@ -731,8 +957,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required String historyViewMode, required String historyViewMode,
required List<DownloadItem> queueItems, required List<DownloadItem> queueItems,
required List<_GroupedAlbum> groupedAlbums, required List<_GroupedAlbum> groupedAlbums,
required Map<String, int> albumCounts,
}) { }) {
final historyItems = _filterHistoryItems(allHistoryItems, filterMode); final historyItems = _resolveHistoryItems(
filterMode: filterMode,
allHistoryItems: allHistoryItems,
albumCounts: albumCounts,
);
final showFilteringIndicator = _shouldShowFilteringIndicator(
allHistoryItems: allHistoryItems,
filterMode: filterMode,
);
// Filter grouped albums based on search query
final searchQuery = _searchQuery;
final filteredGroupedAlbums = searchQuery.isEmpty
? groupedAlbums
: groupedAlbums
.where((album) => album.searchKey.contains(searchQuery))
.toList();
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@@ -766,14 +1009,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
), ),
), ),
if (groupedAlbums.isNotEmpty && if (filteredGroupedAlbums.isNotEmpty &&
queueItems.isEmpty && queueItems.isEmpty &&
filterMode == 'albums') filterMode == 'albums')
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text( child: Text(
'${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}', '${filteredGroupedAlbums.length} ${filteredGroupedAlbums.length == 1 ? 'album' : 'albums'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -794,7 +1037,33 @@ class _QueueTabState extends ConsumerState<QueueTab> {
), ),
), ),
if (filterMode == 'albums' && groupedAlbums.isNotEmpty) if (showFilteringIndicator)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Text(
'Filtering...',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
if (filterMode == 'albums' && filteredGroupedAlbums.isNotEmpty)
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid( sliver: SliverGrid(
@@ -806,12 +1075,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
childAspectRatio: 0.75, childAspectRatio: 0.75,
), ),
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final album = groupedAlbums[index]; final album = filteredGroupedAlbums[index];
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(album.key), key: ValueKey(album.key),
child: _buildAlbumGridItem(context, album, colorScheme), child: _buildAlbumGridItem(context, album, colorScheme),
); );
}, childCount: groupedAlbums.length), }, childCount: filteredGroupedAlbums.length),
), ),
), ),
@@ -857,9 +1126,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}, childCount: historyItems.length ), }, childCount: historyItems.length ),
), ),
if (queueItems.isEmpty && if (queueItems.isEmpty &&
historyItems.isEmpty && historyItems.isEmpty &&
(filterMode != 'albums' || groupedAlbums.isEmpty)) (filterMode != 'albums' || filteredGroupedAlbums.isEmpty) &&
!showFilteringIndicator)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: _buildEmptyState( child: _buildEmptyState(
@@ -926,7 +1196,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
/// Build album grid item for grouped albums view
Widget _buildAlbumGridItem( Widget _buildAlbumGridItem(
BuildContext context, BuildContext context,
_GroupedAlbum album, _GroupedAlbum album,
@@ -943,13 +1212,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: album.coverUrl != null child: album.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: album.coverUrl!, imageUrl: album.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
memCacheWidth: 300, memCacheWidth: 300,
memCacheHeight: 300, memCacheHeight: 300,
cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
@@ -1245,13 +1515,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return item.track.coverUrl != null return item.track.coverUrl != null
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: item.track.coverUrl!, imageUrl: item.track.coverUrl!,
width: 56, width: 56,
height: 56, height: 56,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 112, memCacheWidth: 112,
memCacheHeight: 112, memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
), ),
) )
: Container( : Container(
@@ -1404,11 +1675,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: item.coverUrl != null child: item.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: item.coverUrl!, imageUrl: item.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 200, memCacheWidth: 200,
memCacheHeight: 200, memCacheHeight: 200,
cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
@@ -1613,13 +1885,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
item.coverUrl != null item.coverUrl != null
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: item.coverUrl!, imageUrl: item.coverUrl!,
width: 56, width: 56,
height: 56, height: 56,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 112, memCacheWidth: 112,
memCacheHeight: 112, memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
), ),
) )
: Container( : Container(
@@ -1736,7 +2009,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
} }
/// Filter chip widget for history filtering
class _FilterChip extends StatelessWidget { class _FilterChip extends StatelessWidget {
final String label; final String label;
final int count; final int count;
+21 -18
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.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'; import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -43,22 +45,22 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
} }
} }
void _downloadTrack(int index) { void _downloadTrack(Track track) {
final trackState = ref.read(trackProvider); final settings = ref.read(settingsProvider);
if (index >= 0 && index < trackState.tracks.length) { ref.read(downloadQueueProvider.notifier).addToQueue(
final track = trackState.tracks[index]; track,
final settings = ref.read(settingsProvider); settings.defaultService,
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')), SnackBar(content: Text('Added "${track.name}" to queue')),
); );
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider); final trackState = ref.watch(trackProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -95,11 +97,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
), ),
), ),
Expanded( Expanded(
child: trackState.tracks.isEmpty child: tracks.isEmpty
? _buildEmptyState(colorScheme) ? _buildEmptyState(colorScheme)
: ListView.builder( : ListView.builder(
itemCount: trackState.tracks.length, itemCount: tracks.length,
itemBuilder: (context, index) => _buildTrackTile(index, colorScheme), itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
), ),
), ),
], ],
@@ -129,17 +132,17 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
); );
} }
Widget _buildTrackTile(int index, ColorScheme colorScheme) { Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
final track = ref.watch(trackProvider).tracks[index];
return ListTile( return ListTile(
leading: track.coverUrl != null leading: track.coverUrl != null
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: track.coverUrl!, imageUrl: track.coverUrl!,
width: 48, width: 48,
height: 48, height: 48,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
), ),
) )
: Container( : Container(
@@ -173,9 +176,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
), ),
trailing: IconButton( trailing: IconButton(
icon: Icon(Icons.download, color: colorScheme.primary), icon: Icon(Icons.download, color: colorScheme.primary),
onPressed: () => _downloadTrack(index), onPressed: () => _downloadTrack(track),
), ),
onTap: () => _downloadTrack(index), onTap: () => _downloadTrack(track),
); );
} }
} }
+31 -5
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -14,7 +15,7 @@ class AboutPage extends StatelessWidget {
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
@@ -156,7 +157,7 @@ class AboutPage extends StatelessWidget {
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'), onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true, showDivider: true,
), ),
_AboutSettingsItem( _AboutSettingsItem(
icon: Icons.lightbulb_outline, icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest, title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle, subtitle: context.l10n.aboutFeatureRequestSubtitle,
@@ -167,6 +168,30 @@ class AboutPage extends StatelessWidget {
), ),
), ),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.telegram,
title: context.l10n.aboutTelegramChannel,
subtitle: context.l10n.aboutTelegramChannelSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.forum_outlined,
title: context.l10n.aboutTelegramChat,
subtitle: context.l10n.aboutTelegramChatSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflacchat'),
showDivider: false,
),
],
),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSupport), child: SettingsSectionHeader(title: context.l10n.aboutSupport),
), ),
@@ -252,9 +277,9 @@ class _AppHeaderCard extends StatelessWidget {
color: colorScheme.primary, color: colorScheme.primary,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Image.asset( child: Image.asset(
'assets/images/logo-transparant.png', 'assets/images/logo-transparant.png',
color: colorScheme.onPrimary, // Tint with onPrimary color color: colorScheme.onPrimary,
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect( errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
@@ -333,11 +358,12 @@ class _ContributorItem extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: 'https://github.com/$githubUsername.png', imageUrl: 'https://github.com/$githubUsername.png',
width: 40, width: 40,
height: 40, height: 40,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
width: 40, width: 40,
height: 40, height: 40,
@@ -17,7 +17,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
@@ -161,7 +161,7 @@ class _ThemePreviewCard extends StatelessWidget {
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme color: colorScheme
.surfaceContainerHighest, // Background similar to reference .surfaceContainerHighest,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -203,7 +203,7 @@ class _ThemePreviewCard extends StatelessWidget {
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.1), color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12, // Reduced from 20 for performance blurRadius: 12,
offset: const Offset(0, 8), offset: const Offset(0, 8),
), ),
], ],
@@ -707,8 +707,9 @@ static const _allLanguages = [
('ko', '한국어', Icons.language), ('ko', '한국어', Icons.language),
('nl', 'Nederlands', Icons.language), ('nl', 'Nederlands', Icons.language),
('pt', 'Português', Icons.language), ('pt', 'Português', Icons.language),
('pt_PT', 'Português (Portugal)', Icons.language), ('pt_PT', 'Português (Brasil)', Icons.language),
('ru', 'Русский', Icons.language), ('ru', 'Русский', Icons.language),
('tr', 'Türkçe', Icons.language),
('zh', '简体中文', Icons.language), ('zh', '简体中文', Icons.language),
('zh_CN', '简体中文 (中国)', Icons.language), ('zh_CN', '简体中文 (中国)', Icons.language),
('zh_TW', '繁體中文', Icons.language), ('zh_TW', '繁體中文', Icons.language),
@@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerWidget {
final isBuiltInService = _builtInServices.contains(settings.defaultService); final isBuiltInService = _builtInServices.contains(settings.defaultService);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
@@ -169,14 +169,35 @@ class DownloadSettingsPage extends ConsumerWidget {
], ],
), ),
), ),
],
], ],
), ],
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings), child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.lyrics_outlined,
title: context.l10n.lyricsMode,
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
onTap: () => _showLyricsModePicker(
context,
ref,
settings.lyricsMode,
),
showDivider: false,
),
],
), ),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
@@ -606,6 +627,89 @@ class DownloadSettingsPage extends ConsumerWidget {
} }
} }
String _getLyricsModeLabel(BuildContext context, String mode) {
switch (mode) {
case 'external':
return context.l10n.lyricsModeExternal;
case 'both':
return context.l10n.lyricsModeBoth;
default:
return context.l10n.lyricsModeEmbed;
}
}
void _showLyricsModePicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.lyricsMode,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.lyricsModeDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: Text(context.l10n.lyricsModeEmbed),
subtitle: Text(context.l10n.lyricsModeEmbedSubtitle),
trailing: current == 'embed' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLyricsMode('embed');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: Text(context.l10n.lyricsModeExternal),
subtitle: Text(context.l10n.lyricsModeExternalSubtitle),
trailing: current == 'external' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLyricsMode('external');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.library_music_outlined),
title: Text(context.l10n.lyricsModeBoth),
subtitle: Text(context.l10n.lyricsModeBothSubtitle),
trailing: current == 'both' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLyricsMode('both');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showFolderOrganizationPicker( void _showFolderOrganizationPicker(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
+1 -1
View File
@@ -581,7 +581,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
switch (step) { switch (step) {
case 0: return _storagePermissionGranted; case 0: return _storagePermissionGranted;
case 1: return _selectedDirectory != null; case 1: return _selectedDirectory != null;
case 2: return false; // Spotify step never shows checkmark (optional) case 2: return false;
} }
} }
return false; return false;
+1 -1
View File
@@ -122,7 +122,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
), ),
onChanged: (value) { onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value); ref.read(storeProvider.notifier).setSearchQuery(value);
setState(() {}); // Update suffix icon setState(() {});
}, },
), ),
), ),
+66 -44
View File
@@ -3,8 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -12,8 +13,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
/// Screen to display detailed metadata for a downloaded track
/// Designed with Material Expressive 3 style
class TrackMetadataScreen extends ConsumerStatefulWidget { class TrackMetadataScreen extends ConsumerStatefulWidget {
final DownloadHistoryItem item; final DownloadHistoryItem item;
@@ -32,6 +31,22 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Color? _dominantColor; Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern =
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
static const List<String> _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
String? _normalizeOptionalString(String? value) { String? _normalizeOptionalString(String? value) {
if (value == null) return null; if (value == null) return null;
@@ -46,7 +61,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_checkFile(); _checkFile();
_extractDominantColor(); // Delay palette extraction to avoid jitter during initial build
WidgetsBinding.instance.addPostFrameCallback((_) {
_extractDominantColor();
});
} }
@override @override
@@ -64,21 +82,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
Future<void> _extractDominantColor() async { Future<void> _extractDominantColor() async {
if (widget.item.coverUrl == null) return; final coverUrl = widget.item.coverUrl;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider( // Check cache first
CachedNetworkImageProvider(widget.item.coverUrl!), final cachedColor = PaletteService.instance.getCached(coverUrl);
maximumColorCount: 16, if (cachedColor != null) {
); if (mounted && cachedColor != _dominantColor) {
if (mounted) { setState(() => _dominantColor = cachedColor);
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
} }
} catch (_) { return;
// Ignore palette extraction errors }
// Extract using PaletteService (runs in isolate)
final color = await PaletteService.instance.extractDominantColor(coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() => _dominantColor = color);
} }
} }
@@ -87,26 +105,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (filePath.startsWith('EXISTS:')) { if (filePath.startsWith('EXISTS:')) {
filePath = filePath.substring(7); filePath = filePath.substring(7);
} }
final file = File(filePath); bool exists = false;
final exists = await file.exists();
int? size; int? size;
try {
if (exists) { final stat = await FileStat.stat(filePath);
try { exists = stat.type != FileSystemEntityType.notFound;
size = await file.length(); if (exists) {
} catch (_) {} size = stat.size;
} }
} catch (_) {}
if (mounted) {
if (mounted && (exists != _fileExists || size != _fileSize)) {
setState(() { setState(() {
_fileExists = exists; _fileExists = exists;
_fileSize = size; _fileSize = size;
}); });
}
if (exists) {
_fetchLyrics(); if (mounted && exists && _lyrics == null && !_lyricsLoading) {
} _fetchLyrics();
} }
} }
@@ -119,6 +137,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
int? get discNumber => item.discNumber; int? get discNumber => item.discNumber;
String? get releaseDate => item.releaseDate; String? get releaseDate => item.releaseDate;
String? get isrc => item.isrc; String? get isrc => item.isrc;
String? get genre => item.genre;
String? get label => item.label;
String? get copyright => item.copyright;
String get cleanFilePath { String get cleanFilePath {
final path = item.filePath; final path = item.filePath;
@@ -237,7 +258,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Background with dominant color
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -254,7 +274,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
// Cover image centered - fade out when collapsing
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
@@ -279,10 +298,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: item.coverUrl != null child: item.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: item.coverUrl!, imageUrl: item.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container( placeholder: (_, _) => Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
@@ -519,6 +539,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr), _MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
if (releaseDate != null && releaseDate!.isNotEmpty) if (releaseDate != null && releaseDate!.isNotEmpty)
_MetadataItem(context.l10n.trackReleaseDate, releaseDate!), _MetadataItem(context.l10n.trackReleaseDate, releaseDate!),
if (genre != null && genre!.isNotEmpty)
_MetadataItem(context.l10n.trackGenre, genre!),
if (label != null && label!.isNotEmpty)
_MetadataItem(context.l10n.trackLabel, label!),
if (copyright != null && copyright!.isNotEmpty)
_MetadataItem(context.l10n.trackCopyright, copyright!),
if (isrc != null && isrc!.isNotEmpty) if (isrc != null && isrc!.isNotEmpty)
_MetadataItem('ISRC', isrc!), _MetadataItem('ISRC', isrc!),
]; ];
@@ -650,7 +676,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
), ),
// Show 320kbps for MP3, bit depth/sample rate for FLAC
if (fileExtension == 'MP3') if (fileExtension == 'MP3')
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -900,10 +925,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String _cleanLrcForDisplay(String lrc) { String _cleanLrcForDisplay(String lrc) {
final lines = lrc.split('\n'); final lines = lrc.split('\n');
final cleanLines = <String>[]; final cleanLines = <String>[];
final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
for (final line in lines) { for (final line in lines) {
final cleanLine = line.replaceAll(timestampPattern, '').trim(); final cleanLine = line.replaceAll(_lrcTimestampPattern, '').trim();
if (cleanLine.isNotEmpty) { if (cleanLine.isNotEmpty) {
cleanLines.add(cleanLine); cleanLines.add(cleanLine);
} }
@@ -1025,8 +1049,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); // Close dialog Navigator.pop(context);
Navigator.pop(context); // Go back to history Navigator.pop(context);
} }
}, },
child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)), child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)),
@@ -1084,9 +1108,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
String _formatFullDate(DateTime date) { String _formatFullDate(DateTime date) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', return '${date.day} ${_months[date.month - 1]} ${date.year}, '
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${date.day} ${months[date.month - 1]} ${date.year}, '
'${date.hour.toString().padLeft(2, '0')}:' '${date.hour.toString().padLeft(2, '0')}:'
'${date.minute.toString().padLeft(2, '0')}'; '${date.minute.toString().padLeft(2, '0')}';
} }
+122
View File
@@ -0,0 +1,122 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
/// Persistent cache manager for album/track cover images.
///
/// Unlike the default cache manager which stores in temp directory
/// (can be cleared by system anytime), this stores in app support
/// directory which persists across app restarts.
class CoverCacheManager {
static const String _cacheKey = 'coverImageCache';
static const int _maxCacheObjects = 1000;
static const Duration _maxCacheAge = Duration(days: 365);
static CacheManager? _instance;
static bool _initialized = false;
static String? _cachePath;
static CacheManager get instance {
if (!_initialized || _instance == null) {
// Fallback to default cache manager if not initialized
debugPrint('CoverCacheManager: Not initialized, using DefaultCacheManager');
return DefaultCacheManager();
}
return _instance!;
}
/// Check if cache manager is initialized
static bool get isInitialized => _initialized && _instance != null;
static Future<void> initialize() async {
if (_initialized) return;
try {
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');
_instance = CacheManager(
Config(
_cacheKey,
stalePeriod: _maxCacheAge,
maxNrOfCacheObjects: _maxCacheObjects,
// Use path only (not databaseName) to store database in persistent directory
repo: JsonCacheInfoRepository(path: _cachePath),
fileSystem: IOFileSystem(_cachePath!),
fileService: HttpFileService(),
),
);
_initialized = true;
debugPrint('CoverCacheManager: Initialized successfully');
} catch (e) {
debugPrint('CoverCacheManager: Failed to initialize: $e');
// Will fallback to DefaultCacheManager
}
}
/// Clear all cached cover images.
/// Returns the number of files deleted.
static Future<void> clearCache() async {
if (!_initialized || _instance == null) return;
await _instance!.emptyCache();
}
static Future<CacheStats> getStats() async {
if (!_initialized || _cachePath == null) {
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
}
final cacheDir = Directory(_cachePath!);
if (!await cacheDir.exists()) {
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
}
int fileCount = 0;
int totalSize = 0;
try {
await for (final entity in cacheDir.list(recursive: true)) {
if (entity is File) {
fileCount++;
totalSize += await entity.length();
}
}
} catch (e) {
debugPrint('CoverCacheManager: Error getting stats: $e');
}
return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize);
}
}
/// Statistics about the cover image cache
class CacheStats {
final int fileCount;
final int totalSizeBytes;
const CacheStats({
required this.fileCount,
required this.totalSizeBytes,
});
String get formattedSize {
if (totalSizeBytes < 1024) {
return '$totalSizeBytes B';
} else if (totalSizeBytes < 1024 * 1024) {
return '${(totalSizeBytes / 1024).toStringAsFixed(1)} KB';
} else if (totalSizeBytes < 1024 * 1024 * 1024) {
return '${(totalSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(totalSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
}
}
-4
View File
@@ -7,8 +7,6 @@ import 'package:spotiflac_android/utils/logger.dart';
class CsvImportService { class CsvImportService {
static final _log = AppLogger('CsvImportService'); static final _log = AppLogger('CsvImportService');
/// Pick and parse CSV file, then enrich metadata from Deezer
/// [onProgress] callback receives (current, total) for progress updates
static Future<List<Track>> pickAndParseCsv({ static Future<List<Track>> pickAndParseCsv({
void Function(int current, int total)? onProgress, void Function(int current, int total)? onProgress,
}) async { }) async {
@@ -34,8 +32,6 @@ class CsvImportService {
return []; return [];
} }
/// Enrich tracks with metadata from Deezer using ISRC or search
/// This fetches cover URL, duration, and other metadata that CSV doesn't have
static Future<List<Track>> _enrichTracksMetadata( static Future<List<Track>> _enrichTracksMetadata(
List<Track> tracks, { List<Track> tracks, {
void Function(int current, int total)? onProgress, void Function(int current, int total)? onProgress,
+1 -18
View File
@@ -10,7 +10,6 @@ final _log = AppLogger('FFmpeg');
class FFmpegService { class FFmpegService {
static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg'); static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg');
/// Execute FFmpeg command and return result
static Future<FFmpegResult> _execute(String command) async { static Future<FFmpegResult> _execute(String command) async {
try { try {
final result = await _channel.invokeMethod('execute', {'command': command}); final result = await _channel.invokeMethod('execute', {'command': command});
@@ -26,8 +25,6 @@ class FFmpegService {
} }
} }
/// Convert M4A (DASH segments) to FLAC
/// Returns the output file path on success, null on failure
static Future<String?> convertM4aToFlac(String inputPath) async { static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = inputPath.replaceAll('.m4a', '.flac'); final outputPath = inputPath.replaceAll('.m4a', '.flac');
@@ -47,14 +44,11 @@ class FFmpegService {
return null; return null;
} }
/// Convert FLAC to MP3
/// If deleteOriginal is true, deletes the FLAC file after conversion
static Future<String?> convertFlacToMp3( static Future<String?> convertFlacToMp3(
String inputPath, { String inputPath, {
String bitrate = '320k', String bitrate = '320k',
bool deleteOriginal = true, bool deleteOriginal = true,
}) async { }) async {
// Convert in same folder, just change extension
final outputPath = inputPath.replaceAll('.flac', '.mp3'); final outputPath = inputPath.replaceAll('.flac', '.mp3');
final command = final command =
@@ -63,7 +57,6 @@ class FFmpegService {
final result = await _execute(command); final result = await _execute(command);
if (result.success) { if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) { if (deleteOriginal) {
try { try {
await File(inputPath).delete(); await File(inputPath).delete();
@@ -76,7 +69,6 @@ class FFmpegService {
return null; return null;
} }
/// Convert FLAC to M4A (AAC or ALAC)
static Future<String?> convertFlacToM4a( static Future<String?> convertFlacToM4a(
String inputPath, { String inputPath, {
String codec = 'aac', String codec = 'aac',
@@ -110,7 +102,6 @@ class FFmpegService {
return null; return null;
} }
/// Check if FFmpeg is available
static Future<bool> isAvailable() async { static Future<bool> isAvailable() async {
try { try {
final version = await _channel.invokeMethod('getVersion'); final version = await _channel.invokeMethod('getVersion');
@@ -120,7 +111,6 @@ class FFmpegService {
} }
} }
/// Get FFmpeg version info
static Future<String?> getVersion() async { static Future<String?> getVersion() async {
try { try {
final version = await _channel.invokeMethod('getVersion'); final version = await _channel.invokeMethod('getVersion');
@@ -130,8 +120,6 @@ class FFmpegService {
} }
} }
/// Embed metadata and cover art to FLAC file
/// Returns the file path on success, null on failure
static Future<String?> embedMetadata({ static Future<String?> embedMetadata({
required String flacPath, required String flacPath,
String? coverPath, String? coverPath,
@@ -211,8 +199,6 @@ class FFmpegService {
return null; return null;
} }
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({ static Future<String?> embedMetadataToMp3({
required String mp3Path, required String mp3Path,
String? coverPath, String? coverPath,
@@ -242,7 +228,6 @@ class FFmpegService {
cmdBuffer.write('-c:a copy '); cmdBuffer.write('-c:a copy ');
if (metadata != null) { if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata); final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) { id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"'); final sanitizedValue = value.replaceAll('"', '\\"');
@@ -295,7 +280,6 @@ class FFmpegService {
return null; return null;
} }
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) { static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
final id3Map = <String, String>{}; final id3Map = <String, String>{};
@@ -330,7 +314,7 @@ class FFmpegService {
id3Map['date'] = value; id3Map['date'] = value;
break; break;
case 'ISRC': case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame id3Map['TSRC'] = value;
break; break;
case 'LYRICS': case 'LYRICS':
case 'UNSYNCEDLYRICS': case 'UNSYNCEDLYRICS':
@@ -346,7 +330,6 @@ class FFmpegService {
} }
} }
/// Result of FFmpeg command execution
class FFmpegResult { class FFmpegResult {
final bool success; final bool success;
final int returnCode; final int returnCode;
+326
View File
@@ -0,0 +1,326 @@
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('HistoryDatabase');
/// 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();
static Database? _database;
HistoryDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('history.db');
return _database!;
}
Future<Database> _initDB(String fileName) async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName);
_log.i('Initializing database at: $path');
return await openDatabase(
path,
version: 1,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
}
Future<void> _createDB(Database db, int version) async {
_log.i('Creating database schema v$version');
await db.execute('''
CREATE TABLE history (
id TEXT PRIMARY KEY,
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
album_artist TEXT,
cover_url TEXT,
file_path TEXT NOT NULL,
service TEXT NOT NULL,
downloaded_at TEXT NOT NULL,
isrc TEXT,
spotify_id TEXT,
track_number INTEGER,
disc_number INTEGER,
duration INTEGER,
release_date TEXT,
quality TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
genre TEXT,
label TEXT,
copyright TEXT
)
''');
// 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('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)');
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)');
_log.i('Database schema created with indexes');
}
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion');
// Future migrations go here
}
/// Migrate data from SharedPreferences to SQLite
/// Returns true if migration was performed, false if already migrated
Future<bool> migrateFromSharedPreferences() async {
final prefs = await SharedPreferences.getInstance();
final migrationKey = 'history_migrated_to_sqlite';
if (prefs.getBool(migrationKey) == true) {
_log.d('Already migrated to SQLite');
return false;
}
final jsonStr = prefs.getString('download_history');
if (jsonStr == null || jsonStr.isEmpty) {
_log.d('No SharedPreferences history to migrate');
await prefs.setBool(migrationKey, true);
return false;
}
try {
final List<dynamic> jsonList = jsonDecode(jsonStr);
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite');
final db = await database;
final batch = db.batch();
for (final json in jsonList) {
final map = json as Map<String, dynamic>;
batch.insert(
'history',
_jsonToDbRow(map),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
// Mark as migrated but keep old data for safety
await prefs.setBool(migrationKey, true);
_log.i('Migration complete: ${jsonList.length} items');
return true;
} catch (e, stack) {
_log.e('Migration failed: $e', e, stack);
return false;
}
}
/// Convert JSON format (camelCase) to DB row (snake_case)
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
'id': json['id'],
'track_name': json['trackName'],
'artist_name': json['artistName'],
'album_name': json['albumName'],
'album_artist': json['albumArtist'],
'cover_url': json['coverUrl'],
'file_path': json['filePath'],
'service': json['service'],
'downloaded_at': json['downloadedAt'],
'isrc': json['isrc'],
'spotify_id': json['spotifyId'],
'track_number': json['trackNumber'],
'disc_number': json['discNumber'],
'duration': json['duration'],
'release_date': json['releaseDate'],
'quality': json['quality'],
'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'],
'genre': json['genre'],
'label': json['label'],
'copyright': json['copyright'],
};
}
/// Convert DB row (snake_case) to JSON format (camelCase)
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return {
'id': row['id'],
'trackName': row['track_name'],
'artistName': row['artist_name'],
'albumName': row['album_name'],
'albumArtist': row['album_artist'],
'coverUrl': row['cover_url'],
'filePath': row['file_path'],
'service': row['service'],
'downloadedAt': row['downloaded_at'],
'isrc': row['isrc'],
'spotifyId': row['spotify_id'],
'trackNumber': row['track_number'],
'discNumber': row['disc_number'],
'duration': row['duration'],
'releaseDate': row['release_date'],
'quality': row['quality'],
'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'],
'genre': row['genre'],
'label': row['label'],
'copyright': row['copyright'],
};
}
// ==================== CRUD Operations ====================
/// Insert or update a history item
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
'history',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Get all history items ordered by download date (newest first)
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
final rows = await db.query(
'history',
orderBy: 'downloaded_at DESC',
limit: limit,
offset: offset,
);
return rows.map(_dbRowToJson).toList();
}
/// Get item by ID
Future<Map<String, dynamic>?> getById(String id) async {
final db = await database;
final rows = await db.query(
'history',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Get item by Spotify ID - O(1) with index
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
final db = await database;
final rows = await db.query(
'history',
where: 'spotify_id = ?',
whereArgs: [spotifyId],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Get item by ISRC - O(1) with index
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database;
final rows = await db.query(
'history',
where: 'isrc = ?',
whereArgs: [isrc],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Check if spotify_id exists - O(1) with index
Future<bool> existsBySpotifyId(String spotifyId) async {
final db = await database;
final result = await db.rawQuery(
'SELECT 1 FROM history WHERE spotify_id = ? LIMIT 1',
[spotifyId],
);
return result.isNotEmpty;
}
/// Get all spotify_ids as Set for fast in-memory lookup
Future<Set<String>> getAllSpotifyIds() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""'
);
return rows.map((r) => r['spotify_id'] as String).toSet();
}
/// Delete by ID
Future<void> deleteById(String id) async {
final db = await database;
await db.delete('history', where: 'id = ?', whereArgs: [id]);
}
/// Delete by Spotify ID
Future<void> deleteBySpotifyId(String spotifyId) async {
final db = await database;
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
}
/// Clear all history
Future<void> clearAll() async {
final db = await database;
await db.delete('history');
_log.i('Cleared all history');
}
/// Get total count
Future<int> getCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
return Sqflite.firstIntValue(result) ?? 0;
}
/// Find existing item by spotify_id or isrc (for deduplication)
Future<Map<String, dynamic>?> findExisting({
String? spotifyId,
String? isrc,
}) async {
if (spotifyId != null && spotifyId.isNotEmpty) {
final bySpotify = await getBySpotifyId(spotifyId);
if (bySpotify != null) return bySpotify;
// Check for deezer: prefix matching
if (spotifyId.startsWith('deezer:')) {
final deezerId = spotifyId.substring(7);
final db = await database;
final rows = await db.query(
'history',
where: 'spotify_id LIKE ?',
whereArgs: ['deezer:$deezerId'],
limit: 1,
);
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
}
}
if (isrc != null && isrc.isNotEmpty) {
return await getByIsrc(isrc);
}
return null;
}
/// Close database
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
/// Service for extracting dominant colors from images
/// Uses caching to avoid re-extraction and small image size for speed
class PaletteService {
static final PaletteService instance = PaletteService._();
PaletteService._();
/// Cache for already computed colors
final Map<String, Color> _colorCache = {};
/// Extract dominant color from a network image URL
/// Uses small image size and limited colors for speed
Future<Color?> extractDominantColor(String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
return null;
}
if (_colorCache.containsKey(imageUrl)) {
return _colorCache[imageUrl];
}
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(imageUrl),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[imageUrl] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService error: $e');
return null;
}
}
/// Clear the color cache
void clearCache() {
_colorCache.clear();
}
/// Get cached color without computing
Color? getCached(String? imageUrl) {
if (imageUrl == null) return null;
return _colorCache[imageUrl];
}
}
+34 -118
View File
@@ -4,25 +4,21 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PlatformBridge'); final _log = AppLogger('PlatformBridge');
/// Bridge to communicate with Go backend via platform channels
class PlatformBridge { class PlatformBridge {
static const _channel = MethodChannel('com.zarz.spotiflac/backend'); static const _channel = MethodChannel('com.zarz.spotiflac/backend');
/// Parse and validate Spotify URL
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async { static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
_log.d('parseSpotifyUrl: $url'); _log.d('parseSpotifyUrl: $url');
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get Spotify metadata from URL
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async { static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
_log.d('getSpotifyMetadata: $url'); _log.d('getSpotifyMetadata: $url');
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url}); final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Search Spotify
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async { static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
_log.d('searchSpotify: "$query" (limit: $limit)'); _log.d('searchSpotify: "$query" (limit: $limit)');
final result = await _channel.invokeMethod('searchSpotify', { final result = await _channel.invokeMethod('searchSpotify', {
@@ -32,7 +28,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Search Spotify for tracks and artists
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
_log.d('searchSpotifyAll: "$query"'); _log.d('searchSpotifyAll: "$query"');
final result = await _channel.invokeMethod('searchSpotifyAll', { final result = await _channel.invokeMethod('searchSpotifyAll', {
@@ -43,7 +38,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Check track availability on streaming services
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async { static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); _log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
final result = await _channel.invokeMethod('checkAvailability', { final result = await _channel.invokeMethod('checkAvailability', {
@@ -53,7 +47,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Download a track from specific service
static Future<Map<String, dynamic>> downloadTrack({ static Future<Map<String, dynamic>> downloadTrack({
required String isrc, required String isrc,
required String service, required String service,
@@ -108,7 +101,6 @@ class PlatformBridge {
return response; return response;
} }
/// Download with automatic fallback to other services
static Future<Map<String, dynamic>> downloadWithFallback({ static Future<Map<String, dynamic>> downloadWithFallback({
required String isrc, required String isrc,
required String spotifyId, required String spotifyId,
@@ -129,10 +121,10 @@ class PlatformBridge {
String preferredService = 'tidal', String preferredService = 'tidal',
String? itemId, String? itemId,
int durationMs = 0, int durationMs = 0,
// Extended metadata for FLAC tagging
String? genre, String? genre,
String? label, String? label,
String? copyright, String? copyright,
String lyricsMode = 'embed',
}) async { }) async {
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
final request = jsonEncode({ final request = jsonEncode({
@@ -155,10 +147,10 @@ class PlatformBridge {
'release_date': releaseDate ?? '', 'release_date': releaseDate ?? '',
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs, 'duration_ms': durationMs,
// Extended metadata
'genre': genre ?? '', 'genre': genre ?? '',
'label': label ?? '', 'label': label ?? '',
'copyright': copyright ?? '', 'copyright': copyright ?? '',
'lyrics_mode': lyricsMode,
}); });
final result = await _channel.invokeMethod('downloadWithFallback', request); final result = await _channel.invokeMethod('downloadWithFallback', request);
@@ -180,44 +172,36 @@ class PlatformBridge {
return response; return response;
} }
/// Get download progress (legacy single download)
static Future<Map<String, dynamic>> getDownloadProgress() async { static Future<Map<String, dynamic>> getDownloadProgress() async {
final result = await _channel.invokeMethod('getDownloadProgress'); final result = await _channel.invokeMethod('getDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get progress for all active downloads (concurrent mode)
static Future<Map<String, dynamic>> getAllDownloadProgress() async { static Future<Map<String, dynamic>> getAllDownloadProgress() async {
final result = await _channel.invokeMethod('getAllDownloadProgress'); final result = await _channel.invokeMethod('getAllDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Initialize progress tracking for a download item
static Future<void> initItemProgress(String itemId) async { static Future<void> initItemProgress(String itemId) async {
await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
} }
/// Finish progress tracking for a download item
static Future<void> finishItemProgress(String itemId) async { static Future<void> finishItemProgress(String itemId) async {
await _channel.invokeMethod('finishItemProgress', {'item_id': itemId}); await _channel.invokeMethod('finishItemProgress', {'item_id': itemId});
} }
/// Clear progress tracking for a download item
static Future<void> clearItemProgress(String itemId) async { static Future<void> clearItemProgress(String itemId) async {
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
} }
/// Cancel an in-progress download
static Future<void> cancelDownload(String itemId) async { static Future<void> cancelDownload(String itemId) async {
await _channel.invokeMethod('cancelDownload', {'item_id': itemId}); await _channel.invokeMethod('cancelDownload', {'item_id': itemId});
} }
/// Set download directory
static Future<void> setDownloadDirectory(String path) async { static Future<void> setDownloadDirectory(String path) async {
await _channel.invokeMethod('setDownloadDirectory', {'path': path}); await _channel.invokeMethod('setDownloadDirectory', {'path': path});
} }
/// Check if file with ISRC already exists
static Future<Map<String, dynamic>> checkDuplicate(String outputDir, String isrc) async { static Future<Map<String, dynamic>> checkDuplicate(String outputDir, String isrc) async {
final result = await _channel.invokeMethod('checkDuplicate', { final result = await _channel.invokeMethod('checkDuplicate', {
'output_dir': outputDir, 'output_dir': outputDir,
@@ -226,7 +210,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Build filename from template
static Future<String> buildFilename(String template, Map<String, dynamic> metadata) async { static Future<String> buildFilename(String template, Map<String, dynamic> metadata) async {
final result = await _channel.invokeMethod('buildFilename', { final result = await _channel.invokeMethod('buildFilename', {
'template': template, 'template': template,
@@ -235,7 +218,6 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Sanitize filename
static Future<String> sanitizeFilename(String filename) async { static Future<String> sanitizeFilename(String filename) async {
final result = await _channel.invokeMethod('sanitizeFilename', { final result = await _channel.invokeMethod('sanitizeFilename', {
'filename': filename, 'filename': filename,
@@ -243,8 +225,6 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Fetch lyrics for a track
/// [durationMs] is the track duration in milliseconds for better matching
static Future<Map<String, dynamic>> fetchLyrics( static Future<Map<String, dynamic>> fetchLyrics(
String spotifyId, String spotifyId,
String trackName, String trackName,
@@ -260,9 +240,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get lyrics in LRC format
/// First tries to extract from embedded file, then falls back to internet
/// [durationMs] is the track duration in milliseconds for better matching
static Future<String> getLyricsLRC( static Future<String> getLyricsLRC(
String spotifyId, String spotifyId,
String trackName, String trackName,
@@ -280,7 +257,6 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Embed lyrics into an existing FLAC file
static Future<Map<String, dynamic>> embedLyricsToFile( static Future<Map<String, dynamic>> embedLyricsToFile(
String filePath, String filePath,
String lyrics, String lyrics,
@@ -292,15 +268,10 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Cleanup idle HTTP connections to prevent TCP exhaustion
/// Call this periodically during large batch downloads
static Future<void> cleanupConnections() async { static Future<void> cleanupConnections() async {
await _channel.invokeMethod('cleanupConnections'); await _channel.invokeMethod('cleanupConnections');
} }
/// Read metadata directly from a FLAC file
/// Returns all embedded metadata (title, artist, album, track number, etc.)
/// This reads from the actual file, not from cached/database data
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async { static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
final result = await _channel.invokeMethod('readFileMetadata', { final result = await _channel.invokeMethod('readFileMetadata', {
'file_path': filePath, 'file_path': filePath,
@@ -308,7 +279,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Start foreground download service to keep downloads running in background
static Future<void> startDownloadService({ static Future<void> startDownloadService({
String trackName = '', String trackName = '',
String artistName = '', String artistName = '',
@@ -321,12 +291,10 @@ class PlatformBridge {
}); });
} }
/// Stop foreground download service
static Future<void> stopDownloadService() async { static Future<void> stopDownloadService() async {
await _channel.invokeMethod('stopDownloadService'); await _channel.invokeMethod('stopDownloadService');
} }
/// Update download service notification progress
static Future<void> updateDownloadServiceProgress({ static Future<void> updateDownloadServiceProgress({
required String trackName, required String trackName,
required String artistName, required String artistName,
@@ -343,13 +311,11 @@ class PlatformBridge {
}); });
} }
/// Check if download service is running
static Future<bool> isDownloadServiceRunning() async { static Future<bool> isDownloadServiceRunning() async {
final result = await _channel.invokeMethod('isDownloadServiceRunning'); final result = await _channel.invokeMethod('isDownloadServiceRunning');
return result as bool; return result as bool;
} }
/// Set custom Spotify API credentials
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async { static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
await _channel.invokeMethod('setSpotifyCredentials', { await _channel.invokeMethod('setSpotifyCredentials', {
'client_id': clientId, 'client_id': clientId,
@@ -357,35 +323,26 @@ class PlatformBridge {
}); });
} }
/// Check if Spotify credentials are configured
/// Returns true if credentials are available (custom or env vars) /// Returns true if credentials are available (custom or env vars)
static Future<bool> hasSpotifyCredentials() async { static Future<bool> hasSpotifyCredentials() async {
final result = await _channel.invokeMethod('hasSpotifyCredentials'); final result = await _channel.invokeMethod('hasSpotifyCredentials');
return result as bool; return result as bool;
} }
/// Pre-warm track ID cache for album/playlist tracks
/// This runs in background and returns immediately
/// Speeds up subsequent downloads by caching ISRC Track ID mappings
static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async { static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async {
final tracksJson = jsonEncode(tracks); final tracksJson = jsonEncode(tracks);
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson}); await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
} }
/// Get current track cache size
static Future<int> getTrackCacheSize() async { static Future<int> getTrackCacheSize() async {
final result = await _channel.invokeMethod('getTrackCacheSize'); final result = await _channel.invokeMethod('getTrackCacheSize');
return result as int; return result as int;
} }
/// Clear track ID cache
static Future<void> clearTrackCache() async { static Future<void> clearTrackCache() async {
await _channel.invokeMethod('clearTrackCache'); await _channel.invokeMethod('clearTrackCache');
} }
// ==================== DEEZER API ====================
/// Search Deezer for tracks and artists (no API key required)
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
final result = await _channel.invokeMethod('searchDeezerAll', { final result = await _channel.invokeMethod('searchDeezerAll', {
'query': query, 'query': query,
@@ -395,7 +352,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get Deezer metadata by type and ID
static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async { static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async {
final result = await _channel.invokeMethod('getDeezerMetadata', { final result = await _channel.invokeMethod('getDeezerMetadata', {
'resource_type': resourceType, 'resource_type': resourceType,
@@ -407,20 +363,16 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Parse Deezer URL and return type and ID
static Future<Map<String, dynamic>> parseDeezerUrl(String url) async { static Future<Map<String, dynamic>> parseDeezerUrl(String url) async {
final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Search Deezer by ISRC
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async { static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc}); final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get extended metadata (genre, label) from Deezer using track ID
/// Returns {"genre": "...", "label": "..."} or null if not found
static Future<Map<String, String>?> getDeezerExtendedMetadata(String trackId) async { static Future<Map<String, String>?> getDeezerExtendedMetadata(String trackId) async {
try { try {
final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { final result = await _channel.invokeMethod('getDeezerExtendedMetadata', {
@@ -438,7 +390,6 @@ class PlatformBridge {
} }
} }
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async { static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
final result = await _channel.invokeMethod('convertSpotifyToDeezer', { final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
'resource_type': resourceType, 'resource_type': resourceType,
@@ -447,15 +398,11 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get Spotify metadata with automatic Deezer fallback on rate limit
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async { static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async {
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url}); final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
// ==================== GO BACKEND LOGS ====================
/// Get all logs from Go backend
static Future<List<Map<String, dynamic>>> getGoLogs() async { static Future<List<Map<String, dynamic>>> getGoLogs() async {
final result = await _channel.invokeMethod('getLogs'); final result = await _channel.invokeMethod('getLogs');
final logs = jsonDecode(result as String) as List<dynamic>; final logs = jsonDecode(result as String) as List<dynamic>;
@@ -468,25 +415,20 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Clear Go backend logs
static Future<void> clearGoLogs() async { static Future<void> clearGoLogs() async {
await _channel.invokeMethod('clearLogs'); await _channel.invokeMethod('clearLogs');
} }
/// Get Go backend log count
static Future<int> getGoLogCount() async { static Future<int> getGoLogCount() async {
final result = await _channel.invokeMethod('getLogCount'); final result = await _channel.invokeMethod('getLogCount');
return result as int; return result as int;
} }
/// Enable or disable Go backend logging
static Future<void> setGoLoggingEnabled(bool enabled) async { static Future<void> setGoLoggingEnabled(bool enabled) async {
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
} }
// ==================== EXTENSION SYSTEM ====================
/// Initialize the extension system
static Future<void> initExtensionSystem(String extensionsDir, String dataDir) async { static Future<void> initExtensionSystem(String extensionsDir, String dataDir) async {
_log.d('initExtensionSystem: $extensionsDir, $dataDir'); _log.d('initExtensionSystem: $extensionsDir, $dataDir');
await _channel.invokeMethod('initExtensionSystem', { await _channel.invokeMethod('initExtensionSystem', {
@@ -495,7 +437,6 @@ class PlatformBridge {
}); });
} }
/// Load all extensions from directory
static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) async { static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) async {
_log.d('loadExtensionsFromDir: $dirPath'); _log.d('loadExtensionsFromDir: $dirPath');
final result = await _channel.invokeMethod('loadExtensionsFromDir', { final result = await _channel.invokeMethod('loadExtensionsFromDir', {
@@ -504,7 +445,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Load a single extension from file
static Future<Map<String, dynamic>> loadExtensionFromPath(String filePath) async { static Future<Map<String, dynamic>> loadExtensionFromPath(String filePath) async {
_log.d('loadExtensionFromPath: $filePath'); _log.d('loadExtensionFromPath: $filePath');
final result = await _channel.invokeMethod('loadExtensionFromPath', { final result = await _channel.invokeMethod('loadExtensionFromPath', {
@@ -513,7 +453,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Unload an extension
static Future<void> unloadExtension(String extensionId) async { static Future<void> unloadExtension(String extensionId) async {
_log.d('unloadExtension: $extensionId'); _log.d('unloadExtension: $extensionId');
await _channel.invokeMethod('unloadExtension', { await _channel.invokeMethod('unloadExtension', {
@@ -521,7 +460,6 @@ class PlatformBridge {
}); });
} }
/// Remove an extension completely (unload + delete files)
static Future<void> removeExtension(String extensionId) async { static Future<void> removeExtension(String extensionId) async {
_log.d('removeExtension: $extensionId'); _log.d('removeExtension: $extensionId');
await _channel.invokeMethod('removeExtension', { await _channel.invokeMethod('removeExtension', {
@@ -529,7 +467,6 @@ class PlatformBridge {
}); });
} }
/// Upgrade an existing extension from a new package file
static Future<Map<String, dynamic>> upgradeExtension(String filePath) async { static Future<Map<String, dynamic>> upgradeExtension(String filePath) async {
_log.d('upgradeExtension: $filePath'); _log.d('upgradeExtension: $filePath');
final result = await _channel.invokeMethod('upgradeExtension', { final result = await _channel.invokeMethod('upgradeExtension', {
@@ -538,7 +475,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Check if a package file is an upgrade for an existing extension
static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async { static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
_log.d('checkExtensionUpgrade: $filePath'); _log.d('checkExtensionUpgrade: $filePath');
final result = await _channel.invokeMethod('checkExtensionUpgrade', { final result = await _channel.invokeMethod('checkExtensionUpgrade', {
@@ -547,14 +483,12 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get all installed extensions
static Future<List<Map<String, dynamic>>> getInstalledExtensions() async { static Future<List<Map<String, dynamic>>> getInstalledExtensions() async {
final result = await _channel.invokeMethod('getInstalledExtensions'); final result = await _channel.invokeMethod('getInstalledExtensions');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Enable or disable an extension
static Future<void> setExtensionEnabled(String extensionId, bool enabled) async { static Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
_log.d('setExtensionEnabled: $extensionId = $enabled'); _log.d('setExtensionEnabled: $extensionId = $enabled');
await _channel.invokeMethod('setExtensionEnabled', { await _channel.invokeMethod('setExtensionEnabled', {
@@ -563,7 +497,6 @@ class PlatformBridge {
}); });
} }
/// Set provider priority order
static Future<void> setProviderPriority(List<String> providerIds) async { static Future<void> setProviderPriority(List<String> providerIds) async {
_log.d('setProviderPriority: $providerIds'); _log.d('setProviderPriority: $providerIds');
await _channel.invokeMethod('setProviderPriority', { await _channel.invokeMethod('setProviderPriority', {
@@ -571,14 +504,12 @@ class PlatformBridge {
}); });
} }
/// Get provider priority order
static Future<List<String>> getProviderPriority() async { static Future<List<String>> getProviderPriority() async {
final result = await _channel.invokeMethod('getProviderPriority'); final result = await _channel.invokeMethod('getProviderPriority');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as String).toList(); return list.map((e) => e as String).toList();
} }
/// Set metadata provider priority order
static Future<void> setMetadataProviderPriority(List<String> providerIds) async { static Future<void> setMetadataProviderPriority(List<String> providerIds) async {
_log.d('setMetadataProviderPriority: $providerIds'); _log.d('setMetadataProviderPriority: $providerIds');
await _channel.invokeMethod('setMetadataProviderPriority', { await _channel.invokeMethod('setMetadataProviderPriority', {
@@ -586,14 +517,12 @@ class PlatformBridge {
}); });
} }
/// Get metadata provider priority order
static Future<List<String>> getMetadataProviderPriority() async { static Future<List<String>> getMetadataProviderPriority() async {
final result = await _channel.invokeMethod('getMetadataProviderPriority'); final result = await _channel.invokeMethod('getMetadataProviderPriority');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as String).toList(); return list.map((e) => e as String).toList();
} }
/// Get extension settings
static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async { static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
final result = await _channel.invokeMethod('getExtensionSettings', { final result = await _channel.invokeMethod('getExtensionSettings', {
'extension_id': extensionId, 'extension_id': extensionId,
@@ -601,7 +530,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Set extension settings
static Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async { static Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
_log.d('setExtensionSettings: $extensionId'); _log.d('setExtensionSettings: $extensionId');
await _channel.invokeMethod('setExtensionSettings', { await _channel.invokeMethod('setExtensionSettings', {
@@ -610,8 +538,6 @@ class PlatformBridge {
}); });
} }
/// Invoke an action on an extension (e.g., button click handler like "startLogin")
/// Returns the result from the JS function
static Future<Map<String, dynamic>> invokeExtensionAction(String extensionId, String actionName) async { static Future<Map<String, dynamic>> invokeExtensionAction(String extensionId, String actionName) async {
_log.d('invokeExtensionAction: $extensionId.$actionName'); _log.d('invokeExtensionAction: $extensionId.$actionName');
final result = await _channel.invokeMethod('invokeExtensionAction', { final result = await _channel.invokeMethod('invokeExtensionAction', {
@@ -624,7 +550,6 @@ class PlatformBridge {
return jsonDecode(result) as Map<String, dynamic>; return jsonDecode(result) as Map<String, dynamic>;
} }
/// Search tracks using extension providers
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async { static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
_log.d('searchTracksWithExtensions: "$query"'); _log.d('searchTracksWithExtensions: "$query"');
final result = await _channel.invokeMethod('searchTracksWithExtensions', { final result = await _channel.invokeMethod('searchTracksWithExtensions', {
@@ -635,7 +560,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Download with extension providers (includes fallback)
static Future<Map<String, dynamic>> downloadWithExtensions({ static Future<Map<String, dynamic>> downloadWithExtensions({
required String isrc, required String isrc,
required String spotifyId, required String spotifyId,
@@ -655,9 +579,10 @@ class PlatformBridge {
String? releaseDate, String? releaseDate,
String? itemId, String? itemId,
int durationMs = 0, int durationMs = 0,
String? source, // Extension ID that provided this track (prioritize this extension) String? source,
String? genre, String? genre,
String? label, String? label,
String lyricsMode = 'embed',
}) async { }) async {
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
final request = jsonEncode({ final request = jsonEncode({
@@ -679,24 +604,21 @@ class PlatformBridge {
'release_date': releaseDate ?? '', 'release_date': releaseDate ?? '',
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs, 'duration_ms': durationMs,
'source': source ?? '', // Extension ID that provided this track 'source': source ?? '',
'genre': genre ?? '', 'genre': genre ?? '',
'label': label ?? '', 'label': label ?? '',
'lyrics_mode': lyricsMode,
}); });
final result = await _channel.invokeMethod('downloadWithExtensions', request); final result = await _channel.invokeMethod('downloadWithExtensions', request);
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Cleanup all extensions (call on app close)
static Future<void> cleanupExtensions() async { static Future<void> cleanupExtensions() async {
_log.d('cleanupExtensions'); _log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions');
} }
// ==================== EXTENSION AUTH API ====================
/// Get pending auth request for an extension (if any)
static Future<Map<String, dynamic>?> getExtensionPendingAuth(String extensionId) async { static Future<Map<String, dynamic>?> getExtensionPendingAuth(String extensionId) async {
final result = await _channel.invokeMethod('getExtensionPendingAuth', { final result = await _channel.invokeMethod('getExtensionPendingAuth', {
'extension_id': extensionId, 'extension_id': extensionId,
@@ -705,7 +627,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Set auth code for an extension (after OAuth callback)
static Future<void> setExtensionAuthCode(String extensionId, String authCode) async { static Future<void> setExtensionAuthCode(String extensionId, String authCode) async {
_log.d('setExtensionAuthCode: $extensionId'); _log.d('setExtensionAuthCode: $extensionId');
await _channel.invokeMethod('setExtensionAuthCode', { await _channel.invokeMethod('setExtensionAuthCode', {
@@ -714,7 +635,6 @@ class PlatformBridge {
}); });
} }
/// Set tokens for an extension (after token exchange)
static Future<void> setExtensionTokens( static Future<void> setExtensionTokens(
String extensionId, { String extensionId, {
required String accessToken, required String accessToken,
@@ -730,14 +650,12 @@ class PlatformBridge {
}); });
} }
/// Clear pending auth request for an extension
static Future<void> clearExtensionPendingAuth(String extensionId) async { static Future<void> clearExtensionPendingAuth(String extensionId) async {
await _channel.invokeMethod('clearExtensionPendingAuth', { await _channel.invokeMethod('clearExtensionPendingAuth', {
'extension_id': extensionId, 'extension_id': extensionId,
}); });
} }
/// Check if extension is authenticated
static Future<bool> isExtensionAuthenticated(String extensionId) async { static Future<bool> isExtensionAuthenticated(String extensionId) async {
final result = await _channel.invokeMethod('isExtensionAuthenticated', { final result = await _channel.invokeMethod('isExtensionAuthenticated', {
'extension_id': extensionId, 'extension_id': extensionId,
@@ -745,16 +663,12 @@ class PlatformBridge {
return result as bool; return result as bool;
} }
/// Get all pending auth requests (for polling)
static Future<List<Map<String, dynamic>>> getAllPendingAuthRequests() async { static Future<List<Map<String, dynamic>>> getAllPendingAuthRequests() async {
final result = await _channel.invokeMethod('getAllPendingAuthRequests'); final result = await _channel.invokeMethod('getAllPendingAuthRequests');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
// ==================== EXTENSION FFMPEG API ====================
/// Get pending FFmpeg command for execution
static Future<Map<String, dynamic>?> getPendingFFmpegCommand(String commandId) async { static Future<Map<String, dynamic>?> getPendingFFmpegCommand(String commandId) async {
final result = await _channel.invokeMethod('getPendingFFmpegCommand', { final result = await _channel.invokeMethod('getPendingFFmpegCommand', {
'command_id': commandId, 'command_id': commandId,
@@ -763,7 +677,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Set FFmpeg command result
static Future<void> setFFmpegCommandResult( static Future<void> setFFmpegCommandResult(
String commandId, { String commandId, {
required bool success, required bool success,
@@ -778,16 +691,12 @@ class PlatformBridge {
}); });
} }
/// Get all pending FFmpeg commands
static Future<List<Map<String, dynamic>>> getAllPendingFFmpegCommands() async { static Future<List<Map<String, dynamic>>> getAllPendingFFmpegCommands() async {
final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); final result = await _channel.invokeMethod('getAllPendingFFmpegCommands');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
// ==================== EXTENSION CUSTOM SEARCH ====================
/// Perform custom search using an extension
static Future<List<Map<String, dynamic>>> customSearchWithExtension( static Future<List<Map<String, dynamic>>> customSearchWithExtension(
String extensionId, String extensionId,
String query, { String query, {
@@ -802,17 +711,12 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Get all extensions that provide custom search
static Future<List<Map<String, dynamic>>> getSearchProviders() async { static Future<List<Map<String, dynamic>>> getSearchProviders() async {
final result = await _channel.invokeMethod('getSearchProviders'); final result = await _channel.invokeMethod('getSearchProviders');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
// ==================== EXTENSION URL HANDLER ====================
/// Handle a URL with any matching extension
/// Returns null if no extension can handle the URL
static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async { static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async {
try { try {
final result = await _channel.invokeMethod('handleURLWithExtension', { final result = await _channel.invokeMethod('handleURLWithExtension', {
@@ -825,8 +729,6 @@ class PlatformBridge {
} }
} }
/// Find an extension that can handle the given URL
/// Returns extension ID or null if none found
static Future<String?> findURLHandler(String url) async { static Future<String?> findURLHandler(String url) async {
final result = await _channel.invokeMethod('findURLHandler', { final result = await _channel.invokeMethod('findURLHandler', {
'url': url, 'url': url,
@@ -835,14 +737,12 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Get all extensions that handle custom URLs
static Future<List<Map<String, dynamic>>> getURLHandlers() async { static Future<List<Map<String, dynamic>>> getURLHandlers() async {
final result = await _channel.invokeMethod('getURLHandlers'); final result = await _channel.invokeMethod('getURLHandlers');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Get album tracks using an extension
static Future<Map<String, dynamic>?> getAlbumWithExtension( static Future<Map<String, dynamic>?> getAlbumWithExtension(
String extensionId, String extensionId,
String albumId, String albumId,
@@ -860,7 +760,6 @@ class PlatformBridge {
} }
} }
/// Get playlist tracks using an extension
static Future<Map<String, dynamic>?> getPlaylistWithExtension( static Future<Map<String, dynamic>?> getPlaylistWithExtension(
String extensionId, String extensionId,
String playlistId, String playlistId,
@@ -878,7 +777,6 @@ class PlatformBridge {
} }
} }
/// Get artist info and albums using an extension
static Future<Map<String, dynamic>?> getArtistWithExtension( static Future<Map<String, dynamic>?> getArtistWithExtension(
String extensionId, String extensionId,
String artistId, String artistId,
@@ -896,9 +794,35 @@ class PlatformBridge {
} }
} }
// ==================== EXTENSION POST-PROCESSING ==================== /// Get extension home feed
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
try {
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
'extension_id': extensionId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getExtensionHomeFeed failed: $e');
return null;
}
}
/// Get extension browse categories
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
try {
final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
'extension_id': extensionId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getExtensionBrowseCategories failed: $e');
return null;
}
}
/// Run post-processing hooks on a file
static Future<Map<String, dynamic>> runPostProcessing( static Future<Map<String, dynamic>> runPostProcessing(
String filePath, { String filePath, {
Map<String, dynamic>? metadata, Map<String, dynamic>? metadata,
@@ -910,22 +834,18 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get all extensions that provide post-processing
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async { static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
final result = await _channel.invokeMethod('getPostProcessingProviders'); final result = await _channel.invokeMethod('getPostProcessingProviders');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
// ==================== EXTENSION STORE ====================
/// Initialize extension store
static Future<void> initExtensionStore(String cacheDir) async { static Future<void> initExtensionStore(String cacheDir) async {
_log.d('initExtensionStore: $cacheDir'); _log.d('initExtensionStore: $cacheDir');
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
} }
/// Get all extensions from store with installation status
static Future<List<Map<String, dynamic>>> getStoreExtensions({bool forceRefresh = false}) async { static Future<List<Map<String, dynamic>>> getStoreExtensions({bool forceRefresh = false}) async {
_log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); _log.d('getStoreExtensions (forceRefresh: $forceRefresh)');
final result = await _channel.invokeMethod('getStoreExtensions', { final result = await _channel.invokeMethod('getStoreExtensions', {
@@ -935,7 +855,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Search extensions in store
static Future<List<Map<String, dynamic>>> searchStoreExtensions(String query, {String? category}) async { static Future<List<Map<String, dynamic>>> searchStoreExtensions(String query, {String? category}) async {
_log.d('searchStoreExtensions: "$query" (category: $category)'); _log.d('searchStoreExtensions: "$query" (category: $category)');
final result = await _channel.invokeMethod('searchStoreExtensions', { final result = await _channel.invokeMethod('searchStoreExtensions', {
@@ -946,14 +865,12 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Get store categories
static Future<List<String>> getStoreCategories() async { static Future<List<String>> getStoreCategories() async {
final result = await _channel.invokeMethod('getStoreCategories'); final result = await _channel.invokeMethod('getStoreCategories');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.cast<String>(); return list.cast<String>();
} }
/// Download extension from store
static Future<String> downloadStoreExtension(String extensionId, String destDir) async { static Future<String> downloadStoreExtension(String extensionId, String destDir) async {
_log.i('downloadStoreExtension: $extensionId to $destDir'); _log.i('downloadStoreExtension: $extensionId to $destDir');
final result = await _channel.invokeMethod('downloadStoreExtension', { final result = await _channel.invokeMethod('downloadStoreExtension', {
@@ -963,7 +880,6 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Clear store cache
static Future<void> clearStoreCache() async { static Future<void> clearStoreCache() async {
_log.d('clearStoreCache'); _log.d('clearStoreCache');
await _channel.invokeMethod('clearStoreCache'); await _channel.invokeMethod('clearStoreCache');
-10
View File
@@ -4,7 +4,6 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('ShareIntent'); final _log = AppLogger('ShareIntent');
/// Service to handle incoming share intents from other apps (e.g., Spotify)
class ShareIntentService { class ShareIntentService {
static final ShareIntentService _instance = ShareIntentService._internal(); static final ShareIntentService _instance = ShareIntentService._internal();
factory ShareIntentService() => _instance; factory ShareIntentService() => _instance;
@@ -15,17 +14,14 @@ class ShareIntentService {
bool _initialized = false; bool _initialized = false;
String? _pendingUrl; // Store URL received before listener is ready String? _pendingUrl; // Store URL received before listener is ready
/// Stream of shared Spotify URLs
Stream<String> get sharedUrlStream => _sharedUrlController.stream; Stream<String> get sharedUrlStream => _sharedUrlController.stream;
/// Get pending URL that was received before listener was ready
String? consumePendingUrl() { String? consumePendingUrl() {
final url = _pendingUrl; final url = _pendingUrl;
_pendingUrl = null; _pendingUrl = null;
return url; return url;
} }
/// Initialize the service and start listening for share intents
Future<void> initialize() async { Future<void> initialize() async {
if (_initialized) return; if (_initialized) return;
_initialized = true; _initialized = true;
@@ -58,11 +54,6 @@ class ShareIntentService {
} }
} }
/// Extract Spotify URL from shared text
/// Handles various formats:
/// - Direct URL: https://open.spotify.com/track/xxx
/// - With text: "Check out this song! https://open.spotify.com/track/xxx"
/// - Spotify URI: spotify:track:xxx
String? _extractSpotifyUrl(String text) { String? _extractSpotifyUrl(String text) {
if (text.isEmpty) return null; if (text.isEmpty) return null;
@@ -83,7 +74,6 @@ class ShareIntentService {
return null; return null;
} }
/// Dispose resources
void dispose() { void dispose() {
_mediaSubscription?.cancel(); _mediaSubscription?.cancel();
_sharedUrlController.close(); _sharedUrlController.close();

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