feat: add resolve API with SongLink fallback, fix multi-artist tags (#288), and cleanup

Resolve API (api.zarz.moe):
- Refactor songlink.go: Spotify URLs use resolve API, non-Spotify uses SongLink API
- Add SongLink fallback when resolve API fails for Spotify (two-layer resilience)
- Remove dead code: page parser, XOR-obfuscated keys, legacy helpers

Multi-artist tag fix (#288):
- Add RewriteSplitArtistTags() in Go to rewrite ARTIST/ALBUMARTIST as split Vorbis comments
- Wire method channel handler in Android (MainActivity.kt) and iOS (AppDelegate.swift)
- Add PlatformBridge.rewriteSplitArtistTags() in Dart
- Call native FLAC rewriter after FFmpeg embed when split_vorbis mode is active
- Extract deezerTrackArtistDisplay() helper to use Contributors in album/playlist tracks

Code cleanup:
- Remove unused imports, dead code, and redundant comments across Go and Dart
- Fix build: remove stale getQobuzDebugKey() reference in deezer_download.go
This commit is contained in:
zarzet
2026-04-01 02:45:19 +07:00
parent a1aa1319ce
commit f511f30ad0
63 changed files with 427 additions and 1046 deletions
-21
View File
@@ -11,11 +11,6 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('ClickableMetadata');
/// Navigate to an artist screen by searching Deezer for the artist ID.
///
/// If [artistId] is provided and valid, navigates directly.
/// Otherwise, searches Deezer by [artistName] to resolve the ID first.
/// For extension-based content, pass [extensionId] to use ExtensionArtistScreen.
Future<void> navigateToArtist(
BuildContext context, {
required String artistName,
@@ -95,11 +90,6 @@ Future<void> navigateToArtist(
}
}
/// Navigate to an album screen by searching Deezer for the album ID.
///
/// If [albumId] is provided and valid, navigates directly.
/// Otherwise, searches Deezer by [albumName] (optionally with [artistName]) to resolve the ID.
/// For extension-based content, pass [extensionId] to use ExtensionAlbumScreen.
Future<void> navigateToAlbum(
BuildContext context, {
required String albumName,
@@ -217,9 +207,6 @@ void _pushAlbumScreen(
String? coverUrl,
String? extensionId,
}) {
// Built-in providers (tidal, qobuz, deezer) use AlbumScreen which
// detects the provider from the album ID prefix. Only true JS extensions
// should use ExtensionAlbumScreen.
const builtInProviders = {'tidal', 'qobuz', 'deezer'};
final isExtension =
extensionId != null && !builtInProviders.contains(extensionId);
@@ -297,10 +284,6 @@ void _showUnavailable(BuildContext context, String type) {
).showSnackBar(SnackBar(content: Text('$type information not available')));
}
/// A reusable widget that makes text tappable to navigate to an artist screen.
///
/// Wraps the text in a GestureDetector that, when tapped, looks up the artist
/// via Deezer search and navigates to the ArtistScreen.
class ClickableArtistName extends StatefulWidget {
final String artistName;
final String? artistId;
@@ -526,10 +509,6 @@ bool _canNavigateArtistDirectly({
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
/// A reusable widget that makes text tappable to navigate to an album screen.
///
/// Wraps the text in a GestureDetector that, when tapped, looks up the album
/// via Deezer search and navigates to the AlbumScreen.
class ClickableAlbumName extends StatelessWidget {
final String albumName;
final String? albumId;
-17
View File
@@ -47,26 +47,20 @@ bool isValidIosWritablePath(String path) {
if (path.isEmpty) return false;
if (!path.startsWith('/')) return false;
// Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) {
return false;
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
return false;
}
// Reject stale paths where an old sandbox container path has been embedded
// inside the current Documents directory.
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
return false;
}
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
// This handles cases where FilePicker returns container root
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
@@ -74,7 +68,6 @@ bool isValidIosWritablePath(String path) {
final match = containerPattern.firstMatch(path);
if (match != null) {
final remainingPath = path.substring(match.end);
// Valid paths should have something after the UUID
if (remainingPath.isEmpty || remainingPath == '/') {
return false;
}
@@ -111,13 +104,10 @@ Future<String> validateOrFixIosPath(
candidates.add(trimmed);
}
// Some pickers can return absolute iOS paths without the leading slash.
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
candidates.add('/$trimmed');
}
// Recover legacy relative iOS path format:
// Data/Application/<UUID>/Documents/<subdir>
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
trimmed,
);
@@ -127,7 +117,6 @@ Future<String> validateOrFixIosPath(
);
}
// Generic salvage for relative paths containing `Documents/...`.
if (!trimmed.startsWith('/')) {
final documentsMarker = 'Documents/';
final index = trimmed.indexOf(documentsMarker);
@@ -143,7 +132,6 @@ Future<String> validateOrFixIosPath(
}
}
// Fall back to app Documents directory
final musicDir = Directory('${docDir.path}/$subfolder');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
@@ -185,7 +173,6 @@ IosPathValidationResult validateIosPath(String path) {
);
}
// Check if it's the container root
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
@@ -194,7 +181,6 @@ IosPathValidationResult validateIosPath(String path) {
);
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
@@ -213,7 +199,6 @@ IosPathValidationResult validateIosPath(String path) {
);
}
// Check for container root without subdirectory
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
@@ -263,7 +248,6 @@ String stripCueTrackSuffix(String path) {
Future<bool> fileExists(String? path) async {
if (path == null || path.isEmpty) return false;
// For CUE virtual paths, check if the base .cue file exists
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
if (isContentUri(realPath)) {
return PlatformBridge.safExists(realPath);
@@ -288,7 +272,6 @@ Future<void> deleteFile(String? path) async {
Future<FileAccessStat?> fileStat(String? path) async {
if (path == null || path.isEmpty) return null;
// For CUE virtual paths, stat the base .cue file
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
if (isContentUri(realPath)) {
final stat = await PlatformBridge.safStat(realPath);
-5
View File
@@ -25,9 +25,6 @@ const _audioExtensions = <String>[
const _maxPathMatchKeyCacheSize = 6000;
final Map<String, Set<String>> _pathMatchKeyCache = <String, Set<String>>{};
/// Strips a trailing audio extension from [path] if present.
/// Returns the path without extension, or `null` if no known audio extension
/// was found.
String? _stripAudioExtension(String path) {
final lower = path.toLowerCase();
for (final ext in _audioExtensions) {
@@ -115,8 +112,6 @@ Set<String> buildPathMatchKeys(String? filePath) {
addNormalized(cleaned);
// Add extension-stripped variants so that a file converted from one audio
// format to another (e.g. Song.flac → Song.opus) still matches.
final extensionStrippedKeys = <String>{};
for (final key in keys) {
final stripped = _stripAudioExtension(key);