mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
refactor(metadata): separate editor state, autofill and artwork ownership
This commit is contained in:
1 parent
3047d5974c
commit
e77996ddbd
11 files changed
+1682
-1396
No files matched your search
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
/// Editable tags and request generations, independent of sheet navigation.
|
||||
class MetadataEditorController {
|
||||
static const fieldKeys = <String>[
|
||||
'title',
|
||||
'artist',
|
||||
'album',
|
||||
'album_artist',
|
||||
'date',
|
||||
'track_number',
|
||||
'total_tracks',
|
||||
'disc_number',
|
||||
'total_discs',
|
||||
'genre',
|
||||
'isrc',
|
||||
'lyrics',
|
||||
'label',
|
||||
'copyright',
|
||||
'composer',
|
||||
'comment',
|
||||
'album_type',
|
||||
'explicit',
|
||||
'upc',
|
||||
'cover',
|
||||
];
|
||||
|
||||
final Map<String, TextEditingController> _fields;
|
||||
final Set<String> autoFillFields = {};
|
||||
bool explicit;
|
||||
int _generation = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
MetadataEditorController(Map<String, String> initialValues)
|
||||
: _fields = {
|
||||
for (final key in fieldKeys)
|
||||
if (key != 'explicit' && key != 'cover')
|
||||
key: TextEditingController(text: initialValues[key] ?? ''),
|
||||
},
|
||||
explicit = parseExplicitFlag(initialValues['explicit']) == true {
|
||||
for (final field in _fields.values) {
|
||||
var previousText = field.text;
|
||||
field.addListener(() {
|
||||
if (field.text == previousText) return;
|
||||
previousText = field.text;
|
||||
invalidateLookup();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
TextEditingController? operator [](String key) => _fields[key];
|
||||
|
||||
Map<String, String> get values => {
|
||||
for (final entry in _fields.entries) entry.key: entry.value.text,
|
||||
'explicit': explicit ? '1' : '0',
|
||||
};
|
||||
|
||||
int beginLookup() => ++_generation;
|
||||
void invalidateLookup() => _generation++;
|
||||
bool isCurrent(int generation) => !_disposed && generation == _generation;
|
||||
|
||||
int apply(Map<String, String> values) {
|
||||
var filled = 0;
|
||||
for (final key in autoFillFields) {
|
||||
final value = values[key];
|
||||
if (value == null) continue;
|
||||
if (key == 'explicit') {
|
||||
final parsed = parseExplicitFlag(value);
|
||||
if (parsed == null) continue;
|
||||
explicit = parsed;
|
||||
} else {
|
||||
final field = _fields[key];
|
||||
if (field == null) continue;
|
||||
field.text = value;
|
||||
}
|
||||
filled++;
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
void selectEmptyFields({required bool hasCover}) {
|
||||
autoFillFields
|
||||
..clear()
|
||||
..addAll(_fields.keys.where((key) => _fields[key]!.text.trim().isEmpty));
|
||||
if (!hasCover) autoFillFields.add('cover');
|
||||
}
|
||||
|
||||
Map<String, String> saveMetadata({required String artistTagMode}) {
|
||||
final tags = values;
|
||||
tags['track_total'] = tags.remove('total_tracks')!;
|
||||
tags['disc_total'] = tags.remove('total_discs')!;
|
||||
tags['compilation'] =
|
||||
tags['album_type']!.trim().toLowerCase() == 'compilation' ? '1' : '0';
|
||||
tags['artist_tag_mode'] = artistTagMode;
|
||||
return tags;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
for (final field in _fields.values) {
|
||||
field.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState {
|
||||
backgroundColor: context.isMornye
|
||||
? colorScheme.surfaceContainerLow
|
||||
: colorScheme.surface,
|
||||
builder: (sheetContext) => _EditMetadataSheet(
|
||||
builder: (sheetContext) => EditMetadataSheet(
|
||||
colorScheme: colorScheme,
|
||||
initialValues: initialValues,
|
||||
filePath: cleanFilePath,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,22 +3,12 @@ import 'dart:io';
|
||||
import 'dart:ui' show ImageFilter;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
import 'package:flutter/cupertino.dart'
|
||||
show
|
||||
CupertinoButton,
|
||||
CupertinoIcons,
|
||||
CupertinoTextField,
|
||||
CupertinoActivityIndicator;
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
|
||||
import 'package:spotiflac_android/widgets/app_switch.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoButton, CupertinoIcons;
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:spotiflac_android/services/conversion_library_service.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
@@ -31,7 +21,6 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart'
|
||||
show readPlaybackFileMetadataWithRetry;
|
||||
@@ -48,7 +37,6 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/user_facing_error.dart';
|
||||
@@ -62,7 +50,6 @@ import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart'
|
||||
show HeaderMetaRow, HeaderMetaItem, HeaderCircleButton, HeaderFilledButton;
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_metadata_row.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
@@ -76,6 +63,7 @@ import 'package:spotiflac_android/widgets/metadata_barcode.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/constants/music_services.dart';
|
||||
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_edit_sheet.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
part 'track_metadata_screen_cover.dart';
|
||||
@@ -83,7 +71,6 @@ part 'track_metadata_screen_display.dart';
|
||||
part 'track_metadata_screen_menu.dart';
|
||||
part 'track_metadata_mornye.dart';
|
||||
|
||||
part 'track_metadata_edit_sheet.dart';
|
||||
part 'track_metadata_cards.dart';
|
||||
part 'track_metadata_lyrics.dart';
|
||||
part 'track_metadata_convert.dart';
|
||||
@@ -91,6 +78,15 @@ part 'track_metadata_actions.dart';
|
||||
|
||||
final _log = AppLogger('TrackMetadata');
|
||||
|
||||
class _MetadataItem {
|
||||
final String label;
|
||||
final String value;
|
||||
final String rawValue;
|
||||
|
||||
_MetadataItem(this.label, this.value, {String? rawValue})
|
||||
: rawValue = rawValue ?? value;
|
||||
}
|
||||
|
||||
class _EmbeddedCoverPreviewCacheEntry {
|
||||
final String previewPath;
|
||||
final String? sourceValidationToken;
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
class MetadataAutofillResult {
|
||||
final Map<String, String> values;
|
||||
final String? coverUrl;
|
||||
final String sourceId;
|
||||
final String? lyricsSource;
|
||||
|
||||
const MetadataAutofillResult({
|
||||
required this.values,
|
||||
required this.coverUrl,
|
||||
required this.sourceId,
|
||||
required this.lyricsSource,
|
||||
});
|
||||
}
|
||||
|
||||
/// Provider lookup and enrichment without widget state or navigation access.
|
||||
class MetadataAutofillService {
|
||||
static final _log = AppLogger('MetadataAutofill');
|
||||
static final RegExp _metadataCollapsePattern = RegExp(
|
||||
r'[^\p{L}\p{M}\p{N}]+',
|
||||
unicode: true,
|
||||
);
|
||||
static final RegExp _metadataWhitespacePattern = RegExp(r'\s+');
|
||||
static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
|
||||
static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$');
|
||||
static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$');
|
||||
|
||||
Future<List<Map<String, dynamic>>> search(
|
||||
String query, {
|
||||
Extension? provider,
|
||||
}) {
|
||||
if (provider == null) {
|
||||
return PlatformBridge.searchTracksWithMetadataProviders(query, limit: 5);
|
||||
}
|
||||
if (provider.hasCustomSearch) {
|
||||
final filter = provider.searchBehavior?.filterIdForKind('track');
|
||||
return PlatformBridge.customSearchWithExtension(
|
||||
provider.id,
|
||||
query,
|
||||
options: {'limit': 5, 'filter': ?filter},
|
||||
);
|
||||
}
|
||||
return PlatformBridge.searchTracksWithMetadataProvider(
|
||||
provider.id,
|
||||
query,
|
||||
limit: 5,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> resolveDetails(
|
||||
Map<String, dynamic> candidate, {
|
||||
required String providerId,
|
||||
required bool includeCover,
|
||||
}) async {
|
||||
var resolved = candidate;
|
||||
if (providerId.isEmpty) return resolved;
|
||||
final trackId = candidate['id']?.toString().trim() ?? '';
|
||||
if (trackId.isNotEmpty) {
|
||||
try {
|
||||
final details = await PlatformBridge.getProviderMetadata(
|
||||
providerId,
|
||||
'track',
|
||||
trackId,
|
||||
);
|
||||
resolved = {
|
||||
...candidate,
|
||||
for (final entry in unwrapTrackPayload(details).entries)
|
||||
if (entry.value != null && entry.value.toString().trim().isNotEmpty)
|
||||
entry.key: entry.value,
|
||||
};
|
||||
} catch (error) {
|
||||
_log.w(
|
||||
'Detailed metadata lookup failed for $providerId/$trackId: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (includeCover && coverUrl(resolved) == null) {
|
||||
final albumId = resolved['album_id']?.toString().trim() ?? '';
|
||||
if (albumId.isNotEmpty) {
|
||||
try {
|
||||
final details = await PlatformBridge.getProviderMetadata(
|
||||
providerId,
|
||||
'album',
|
||||
albumId,
|
||||
);
|
||||
final album = details['album_info'] ?? details['album'];
|
||||
final url = coverUrl(album is Map<String, dynamic> ? album : details);
|
||||
if (url != null) resolved = {...resolved, 'cover_url': url};
|
||||
} catch (error) {
|
||||
_log.w(
|
||||
'Album artwork lookup failed for $providerId/$albumId: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
Future<MetadataAutofillResult?> enrich(
|
||||
Map<String, dynamic>? selectedBest, {
|
||||
required Map<String, String> currentValues,
|
||||
required Set<String> fields,
|
||||
required String? providerId,
|
||||
required String? sourceTrackId,
|
||||
required int durationMs,
|
||||
required Future<void> Function() syncLyricsSettings,
|
||||
required bool Function() isCurrent,
|
||||
}) async {
|
||||
if (!isCurrent()) return null;
|
||||
final title = currentValues['title']?.trim() ?? '';
|
||||
final artist = currentValues['artist']?.trim() ?? '';
|
||||
final currentIsrc = currentValues['isrc']?.trim().toUpperCase() ?? '';
|
||||
final usesAutomaticProvider = providerId?.isNotEmpty != true;
|
||||
final shouldFetchLyrics = fields.contains('lyrics');
|
||||
String? deezerId;
|
||||
String? lyricsSourceName;
|
||||
final enriched = <String, String>{};
|
||||
if (selectedBest != null) {
|
||||
mergeTrackData(enriched, selectedBest);
|
||||
}
|
||||
|
||||
final enrichedIsrc = (enriched['isrc'] ?? '').trim();
|
||||
final needsIsrc = fields.contains('isrc') && enrichedIsrc.isEmpty;
|
||||
final needsExtended =
|
||||
fields.contains('genre') ||
|
||||
fields.contains('label') ||
|
||||
fields.contains('copyright') ||
|
||||
fields.contains('composer');
|
||||
|
||||
final rawSpotifyId = selectedBest == null
|
||||
? spotifyTrackIdFromValue(sourceTrackId)
|
||||
: spotifyTrackId(selectedBest);
|
||||
|
||||
deezerId ??= selectedBest == null ? null : deezerTrackId(selectedBest);
|
||||
final candidateIsrc = enrichedIsrc.toUpperCase();
|
||||
final deezerLookupIsrc = looksLikeIsrc(currentIsrc)
|
||||
? currentIsrc
|
||||
: (looksLikeIsrc(candidateIsrc) ? candidateIsrc : '');
|
||||
|
||||
if (usesAutomaticProvider && (needsIsrc || needsExtended)) {
|
||||
try {
|
||||
if (deezerId == null && deezerLookupIsrc.isNotEmpty) {
|
||||
final deezerResult = await PlatformBridge.searchDeezerByISRC(
|
||||
deezerLookupIsrc,
|
||||
);
|
||||
deezerId = deezerTrackId(deezerResult);
|
||||
mergeTrackData(enriched, deezerResult);
|
||||
}
|
||||
|
||||
if (deezerId == null && rawSpotifyId != null) {
|
||||
// Spotify IDs can be mapped through SongLink to a Deezer track.
|
||||
final deezerData = await PlatformBridge.convertSpotifyToDeezer(
|
||||
'track',
|
||||
rawSpotifyId,
|
||||
);
|
||||
final trackData = deezerData['track'];
|
||||
if (trackData is Map<String, dynamic>) {
|
||||
deezerId = deezerTrackId(trackData);
|
||||
mergeTrackData(enriched, trackData);
|
||||
}
|
||||
deezerId ??= deezerTrackId(deezerData);
|
||||
}
|
||||
} catch (_) {
|
||||
// Deezer resolution is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCurrent()) return null;
|
||||
|
||||
if (usesAutomaticProvider &&
|
||||
needsIsrc &&
|
||||
(enriched['isrc'] ?? '').trim().isEmpty &&
|
||||
deezerId != null) {
|
||||
try {
|
||||
final deezerMeta = await PlatformBridge.getProviderMetadata(
|
||||
'deezer',
|
||||
'track',
|
||||
deezerId,
|
||||
);
|
||||
final trackData = unwrapTrackPayload(deezerMeta);
|
||||
mergeTrackData(enriched, trackData);
|
||||
final deezerIsrc = (trackData['isrc'] ?? '').toString().trim();
|
||||
if (deezerIsrc.isNotEmpty) {
|
||||
enriched['isrc'] = deezerIsrc;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!isCurrent()) return null;
|
||||
|
||||
if (usesAutomaticProvider && needsExtended && deezerId != null) {
|
||||
try {
|
||||
final extended = await PlatformBridge.getDeezerExtendedMetadata(
|
||||
deezerId,
|
||||
);
|
||||
if (extended != null) {
|
||||
enriched['genre'] = extended['genre'] ?? '';
|
||||
enriched['label'] = extended['label'] ?? '';
|
||||
enriched['copyright'] = extended['copyright'] ?? '';
|
||||
}
|
||||
} catch (_) {
|
||||
// Extended metadata is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldFetchLyrics) {
|
||||
await syncLyricsSettings();
|
||||
|
||||
if (!isCurrent()) return null;
|
||||
|
||||
final lyricsTitle =
|
||||
((selectedBest?['name'] ?? selectedBest?['title'] ?? title)
|
||||
.toString())
|
||||
.trim();
|
||||
final lyricsArtist =
|
||||
((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist)
|
||||
.toString())
|
||||
.trim();
|
||||
|
||||
if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) {
|
||||
try {
|
||||
final lyricsResult = await PlatformBridge.getLyricsLRCWithSource(
|
||||
rawSpotifyId ?? '',
|
||||
lyricsTitle,
|
||||
lyricsArtist,
|
||||
durationMs: durationMs,
|
||||
);
|
||||
final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? '';
|
||||
final lyricsSource = lyricsResult['source']?.toString().trim() ?? '';
|
||||
if (lyricsSource.isNotEmpty) lyricsSourceName = lyricsSource;
|
||||
final instrumental =
|
||||
(lyricsResult['instrumental'] as bool? ?? false) ||
|
||||
lyricsText == '[instrumental:true]';
|
||||
if (!instrumental && lyricsText.isNotEmpty) {
|
||||
enriched['lyrics'] = lyricsText;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Lyrics autofill failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCurrent()) return null;
|
||||
|
||||
final availableValues = <String, String>{};
|
||||
for (final entry in enriched.entries) {
|
||||
final value = entry.value.trim();
|
||||
final isExplicitValue = entry.key == 'explicit';
|
||||
if (value.isNotEmpty &&
|
||||
(isExplicitValue || value != '0') &&
|
||||
value != 'null') {
|
||||
availableValues[entry.key] = value;
|
||||
}
|
||||
}
|
||||
final artworkUrl = selectedBest == null ? null : coverUrl(selectedBest);
|
||||
|
||||
return MetadataAutofillResult(
|
||||
values: availableValues,
|
||||
coverUrl: artworkUrl,
|
||||
sourceId: providerId?.isNotEmpty == true
|
||||
? providerId!
|
||||
: selectedBest?['provider_id']?.toString().trim() ?? '',
|
||||
lyricsSource: selectedBest == null ? lyricsSourceName : null,
|
||||
);
|
||||
}
|
||||
|
||||
List<Extension> orderedProviders(ExtensionState state) {
|
||||
final providersById = <String, Extension>{
|
||||
for (final extension in state.extensions)
|
||||
if (extension.enabled && extension.hasMetadataProvider)
|
||||
extension.id: extension,
|
||||
};
|
||||
final ordered = <Extension>[];
|
||||
for (final id in state.metadataProviderPriority) {
|
||||
final extension = providersById.remove(id);
|
||||
if (extension != null) ordered.add(extension);
|
||||
}
|
||||
final remaining = providersById.values.toList()
|
||||
..sort(
|
||||
(a, b) =>
|
||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
);
|
||||
return [...ordered, ...remaining];
|
||||
}
|
||||
|
||||
String normalizeText(String value) {
|
||||
final collapsed = value
|
||||
.toLowerCase()
|
||||
.replaceAll(_metadataCollapsePattern, ' ')
|
||||
.trim();
|
||||
return collapsed.replaceAll(_metadataWhitespacePattern, ' ');
|
||||
}
|
||||
|
||||
bool looksLikeIsrc(String value) {
|
||||
return _isrcPattern.hasMatch(value.trim().toUpperCase());
|
||||
}
|
||||
|
||||
String? spotifyTrackIdFromValue(Object? value) {
|
||||
final raw = value?.toString().trim() ?? '';
|
||||
if (raw.isEmpty) return null;
|
||||
|
||||
if (_spotifyTrackIdPattern.hasMatch(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (raw.startsWith('spotify:')) {
|
||||
final parts = raw.split(':');
|
||||
final last = parts.isNotEmpty ? parts.last.trim() : '';
|
||||
if (_spotifyTrackIdPattern.hasMatch(last)) {
|
||||
return last;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(raw);
|
||||
if (uri != null &&
|
||||
uri.host.contains('spotify.com') &&
|
||||
uri.pathSegments.length >= 2 &&
|
||||
uri.pathSegments.first == 'track') {
|
||||
final candidate = uri.pathSegments[1].trim();
|
||||
if (_spotifyTrackIdPattern.hasMatch(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? deezerTrackIdFromValue(Object? value) {
|
||||
final raw = value?.toString().trim() ?? '';
|
||||
if (raw.isEmpty) return null;
|
||||
|
||||
if (_deezerTrackIdPattern.hasMatch(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (raw.startsWith('deezer:')) {
|
||||
final parts = raw.split(':');
|
||||
final last = parts.isNotEmpty ? parts.last.trim() : '';
|
||||
if (_deezerTrackIdPattern.hasMatch(last)) {
|
||||
return last;
|
||||
}
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(raw);
|
||||
if (uri != null && uri.host.contains('deezer.com')) {
|
||||
final trackIndex = uri.pathSegments.indexOf('track');
|
||||
if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) {
|
||||
final candidate = uri.pathSegments[trackIndex + 1].trim();
|
||||
if (_deezerTrackIdPattern.hasMatch(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? spotifyTrackId(Map<String, dynamic> track) {
|
||||
for (final candidate in [track['spotify_id'], track['id']]) {
|
||||
final spotifyId = spotifyTrackIdFromValue(candidate);
|
||||
if (spotifyId != null) return spotifyId;
|
||||
}
|
||||
|
||||
final externalLinks = track['external_links'];
|
||||
if (externalLinks is Map) {
|
||||
final spotifyId = spotifyTrackIdFromValue(externalLinks['spotify']);
|
||||
if (spotifyId != null) return spotifyId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? deezerTrackId(Map<String, dynamic> track) {
|
||||
for (final candidate in [
|
||||
track['deezer_id'],
|
||||
track['spotify_id'],
|
||||
track['id'],
|
||||
]) {
|
||||
final deezerId = deezerTrackIdFromValue(candidate);
|
||||
if (deezerId != null) return deezerId;
|
||||
}
|
||||
|
||||
final externalLinks = track['external_links'];
|
||||
if (externalLinks is Map) {
|
||||
final deezerId = deezerTrackIdFromValue(externalLinks['deezer']);
|
||||
if (deezerId != null) return deezerId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> unwrapTrackPayload(Map<String, dynamic> payload) {
|
||||
final track = payload['track'];
|
||||
if (track is Map<String, dynamic>) {
|
||||
return track;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
void mergeTrackData(
|
||||
Map<String, String> enriched,
|
||||
Map<String, dynamic> track,
|
||||
) {
|
||||
void put(String key, Object? value) {
|
||||
final text = value?.toString().trim() ?? '';
|
||||
if (text.isNotEmpty && text != 'null') {
|
||||
enriched[key] = text;
|
||||
}
|
||||
}
|
||||
|
||||
put('title', track['name'] ?? track['title']);
|
||||
put('artist', track['artists'] ?? track['artist']);
|
||||
put('album', track['album_name'] ?? track['album']);
|
||||
put('album_artist', track['album_artist']);
|
||||
put('date', track['release_date']);
|
||||
put('track_number', track['track_number']);
|
||||
put('total_tracks', track['total_tracks']);
|
||||
put('disc_number', track['disc_number']);
|
||||
put('total_discs', track['total_discs']);
|
||||
put('isrc', track['isrc']);
|
||||
put('genre', track['genre']);
|
||||
put('label', track['label']);
|
||||
put('copyright', track['copyright']);
|
||||
put('composer', track['composer']);
|
||||
put('comment', track['comment']);
|
||||
put('album_type', track['album_type'] ?? track['albumType']);
|
||||
put('explicit', track['explicit'] ?? track['is_explicit']);
|
||||
put('upc', track['upc'] ?? track['barcode']);
|
||||
}
|
||||
|
||||
int matchScore(
|
||||
Map<String, dynamic> track, {
|
||||
required String currentTitle,
|
||||
required String currentArtist,
|
||||
required String currentAlbum,
|
||||
required String currentIsrc,
|
||||
}) {
|
||||
int textScore(String current, Object? value, int exact, int partial) {
|
||||
final candidate = normalizeText((value ?? '').toString());
|
||||
if (current.isEmpty || candidate.isEmpty) return 0;
|
||||
if (candidate == current) return exact;
|
||||
return _metadataTextMatches(current, candidate) ? partial : 0;
|
||||
}
|
||||
|
||||
final candidateIsrc = track['isrc']?.toString().trim().toUpperCase() ?? '';
|
||||
return (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc
|
||||
? 10000
|
||||
: 0) +
|
||||
textScore(currentTitle, track['name'] ?? track['title'], 400, 180) +
|
||||
textScore(
|
||||
currentArtist,
|
||||
track['artists'] ?? track['artist'],
|
||||
320,
|
||||
140,
|
||||
) +
|
||||
textScore(currentAlbum, track['album_name'] ?? track['album'], 120, 50);
|
||||
}
|
||||
|
||||
bool _metadataTextMatches(String current, String candidate) {
|
||||
if (current.isEmpty || candidate.isEmpty) return false;
|
||||
return current == candidate ||
|
||||
candidate.contains(current) ||
|
||||
current.contains(candidate);
|
||||
}
|
||||
|
||||
bool matchIsConfident(
|
||||
Map<String, dynamic> track, {
|
||||
required String currentTitle,
|
||||
required String currentArtist,
|
||||
required String currentAlbum,
|
||||
required String currentIsrc,
|
||||
}) {
|
||||
final candidateIsrc = (track['isrc']?.toString() ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final candidateTitle = normalizeText(
|
||||
(track['name'] ?? track['title'] ?? '').toString(),
|
||||
);
|
||||
final candidateArtist = normalizeText(
|
||||
(track['artists'] ?? track['artist'] ?? '').toString(),
|
||||
);
|
||||
final candidateAlbum = normalizeText(
|
||||
(track['album_name'] ?? track['album'] ?? '').toString(),
|
||||
);
|
||||
|
||||
final titleMatches = _metadataTextMatches(currentTitle, candidateTitle);
|
||||
final artistMatches = _metadataTextMatches(currentArtist, candidateArtist);
|
||||
final albumMatches = _metadataTextMatches(currentAlbum, candidateAlbum);
|
||||
|
||||
if (currentTitle.isNotEmpty && currentArtist.isNotEmpty) {
|
||||
return titleMatches && artistMatches;
|
||||
}
|
||||
if (currentTitle.isNotEmpty && currentAlbum.isNotEmpty) {
|
||||
return titleMatches && albumMatches;
|
||||
}
|
||||
if (currentTitle.isNotEmpty) {
|
||||
return titleMatches;
|
||||
}
|
||||
if (currentAlbum.isNotEmpty) {
|
||||
return albumMatches;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
String? coverUrl(Map<String, dynamic> track) {
|
||||
String? fromValue(Object? value) {
|
||||
if (value is String) {
|
||||
final normalized = value.trim();
|
||||
return normalized.isEmpty ? null : normalized;
|
||||
}
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
final resolved = fromValue(item);
|
||||
if (resolved != null) return resolved;
|
||||
}
|
||||
}
|
||||
if (value is Map) {
|
||||
for (final key in const ['url', 'original', 'large', 'src']) {
|
||||
final resolved = fromValue(value[key]);
|
||||
if (resolved != null) return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return fromValue(track['cover_url']) ?? fromValue(track['images']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
typedef MetadataCoverDetails = ({int width, int height, int bytes});
|
||||
|
||||
class MetadataCoverPreview {
|
||||
final String path;
|
||||
final String tempDir;
|
||||
final MetadataCoverDetails? details;
|
||||
|
||||
const MetadataCoverPreview({
|
||||
required this.path,
|
||||
required this.tempDir,
|
||||
this.details,
|
||||
});
|
||||
}
|
||||
|
||||
/// Owns editor artwork, including resources created after a sheet is closed.
|
||||
/// A save lease keeps every owned file alive until the writer has finished.
|
||||
class MetadataCoverResources {
|
||||
static final _log = AppLogger('MetadataCoverResources');
|
||||
final Set<String> _directories = {};
|
||||
final Set<String> _deferredReleases = {};
|
||||
bool _disposed = false;
|
||||
int _leases = 0;
|
||||
int _pending = 0;
|
||||
|
||||
Future<void> Function() retain() {
|
||||
assert(!_disposed);
|
||||
_leases++;
|
||||
var released = false;
|
||||
return () async {
|
||||
if (released) return;
|
||||
released = true;
|
||||
_leases--;
|
||||
await _cleanupIfIdle();
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true;
|
||||
await _cleanupIfIdle();
|
||||
}
|
||||
|
||||
Future<void> _cleanupIfIdle() async {
|
||||
if (_leases != 0) return;
|
||||
final directories = _disposed && _pending == 0
|
||||
? _directories.toList()
|
||||
: _deferredReleases.toList();
|
||||
await Future.wait(directories.map(release));
|
||||
}
|
||||
|
||||
Future<void> release(String? directory) async {
|
||||
if (directory == null || !_directories.contains(directory)) return;
|
||||
if (_leases != 0) {
|
||||
_deferredReleases.add(directory);
|
||||
return;
|
||||
}
|
||||
_directories.remove(directory);
|
||||
_deferredReleases.remove(directory);
|
||||
try {
|
||||
final dir = Directory(directory);
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<MetadataCoverDetails?> readDetails(String path) async {
|
||||
try {
|
||||
final dimensions = await FFmpegService.probeImageDimensions(path);
|
||||
if (dimensions == null) return null;
|
||||
return (
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
bytes: await File(path).length(),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<MetadataCoverPreview> _create(
|
||||
String prefix,
|
||||
String filename,
|
||||
Future<void> Function(String path) write, {
|
||||
bool probe = true,
|
||||
}) async {
|
||||
_pending++;
|
||||
String? directory;
|
||||
try {
|
||||
directory = (await Directory.systemTemp.createTemp(prefix)).path;
|
||||
_directories.add(directory);
|
||||
final path = '$directory${Platform.pathSeparator}$filename';
|
||||
await write(path);
|
||||
final file = File(path);
|
||||
if (!await file.exists() || await file.length() == 0) {
|
||||
throw StateError('Artwork file is empty or inaccessible');
|
||||
}
|
||||
return MetadataCoverPreview(
|
||||
path: path,
|
||||
tempDir: directory,
|
||||
details: probe ? await readDetails(path) : null,
|
||||
);
|
||||
} catch (_) {
|
||||
await release(directory);
|
||||
rethrow;
|
||||
} finally {
|
||||
_pending--;
|
||||
await _cleanupIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
Future<MetadataCoverPreview?> download(String url) async {
|
||||
try {
|
||||
return await _create('metadata_cover_', 'cover.jpg', (path) async {
|
||||
final result = await PlatformBridge.downloadCoverToFile(url, path);
|
||||
if (result['error'] != null || result['success'] == false) {
|
||||
throw StateError(
|
||||
result['error']?.toString() ?? 'Cover download failed',
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
_log.w('Could not download metadata artwork: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<MetadataCoverPreview?> extract(String filePath) async {
|
||||
try {
|
||||
return await _create('metadata_embedded_cover_', 'cover.jpg', (
|
||||
path,
|
||||
) async {
|
||||
final result = await PlatformBridge.extractCoverToFile(filePath, path);
|
||||
if (result['error'] != null) {
|
||||
throw StateError(result['error'].toString());
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<MetadataCoverPreview> copyPicked({
|
||||
required String extension,
|
||||
String? sourcePath,
|
||||
Uint8List? bytes,
|
||||
}) {
|
||||
return _create('metadata_selected_cover_', 'cover.$extension', (
|
||||
path,
|
||||
) async {
|
||||
if (sourcePath?.isNotEmpty == true) {
|
||||
await File(sourcePath!).copy(path);
|
||||
} else if (bytes?.isNotEmpty == true) {
|
||||
await File(path).writeAsBytes(bytes!, flush: true);
|
||||
} else {
|
||||
throw StateError('Unable to read selected image');
|
||||
}
|
||||
}, probe: false);
|
||||
}
|
||||
|
||||
Future<MetadataCoverPreview> resize(String source, int maxDimension) {
|
||||
return _create('metadata_resized_cover_', 'cover.jpg', (path) async {
|
||||
final resized = await FFmpegService.resizeCoverArt(
|
||||
inputPath: source,
|
||||
outputPath: path,
|
||||
maxDimension: maxDimension,
|
||||
);
|
||||
if (!resized) throw StateError('Cover resize failed');
|
||||
}, probe: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/services/metadata_autofill_service.dart';
|
||||
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
|
||||
import 'package:spotiflac_android/utils/isrc_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
class MetadataCandidateTile extends StatelessWidget {
|
||||
final Map<String, dynamic> candidate;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const MetadataCandidateTile({
|
||||
super.key,
|
||||
required this.candidate,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = (candidate['name'] ?? candidate['title'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final artists = (candidate['artists'] ?? candidate['artist'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final album = (candidate['album_name'] ?? candidate['album'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final date = candidate['release_date']?.toString().trim() ?? '';
|
||||
final isrc = candidate['isrc']?.toString().trim() ?? '';
|
||||
final subtitle = [
|
||||
artists,
|
||||
album,
|
||||
if (date.isNotEmpty) date,
|
||||
if (isrc.isNotEmpty) 'ISRC ${formatIsrcForDisplay(isrc)}',
|
||||
].where((value) => value.isNotEmpty).join('\n');
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
leading: _MetadataCandidateArtwork(
|
||||
coverUrl: MetadataAutofillService().coverUrl(candidate),
|
||||
),
|
||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: subtitle.isEmpty
|
||||
? null
|
||||
: Text(subtitle, maxLines: 4, overflow: TextOverflow.ellipsis),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataCandidateArtwork extends StatefulWidget {
|
||||
final String? coverUrl;
|
||||
|
||||
const _MetadataCandidateArtwork({required this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_MetadataCandidateArtwork> createState() =>
|
||||
_MetadataCandidateArtworkState();
|
||||
}
|
||||
|
||||
class _MetadataCandidateArtworkState extends State<_MetadataCandidateArtwork> {
|
||||
final _covers = MetadataCoverResources();
|
||||
String? _coverPath;
|
||||
String? _tempDir;
|
||||
({int width, int height, int bytes})? _details;
|
||||
int _generation = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.coverUrl?.isNotEmpty == true) {
|
||||
unawaited(_loadCover());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCover() async {
|
||||
final generation = ++_generation;
|
||||
final preview = await _covers.download(widget.coverUrl!);
|
||||
if (!mounted || generation != _generation) {
|
||||
await _covers.release(preview?.tempDir);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_coverPath = preview?.path;
|
||||
_tempDir = preview?.tempDir;
|
||||
_details = preview?.details;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _MetadataCandidateArtwork oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.coverUrl == oldWidget.coverUrl) return;
|
||||
_generation++;
|
||||
unawaited(_covers.release(_tempDir));
|
||||
_tempDir = null;
|
||||
_coverPath = null;
|
||||
_details = null;
|
||||
if (widget.coverUrl?.isNotEmpty == true) unawaited(_loadCover());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_covers.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final coverUrl = widget.coverUrl;
|
||||
final details = _details;
|
||||
final image = _coverPath != null
|
||||
? Image.file(
|
||||
File(_coverPath!),
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: (56 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||
cacheHeight: (56 * MediaQuery.devicePixelRatioOf(context)).round(),
|
||||
filterQuality: FilterQuality.low,
|
||||
)
|
||||
: coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: coverUrl,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.music_note),
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: 72,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(borderRadius: BorderRadius.circular(10), child: image),
|
||||
if (details != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${details.width}×${details.height} · '
|
||||
'${(details.bytes / 1024).toStringAsFixed(1)} KB',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 9,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
Color metadataEditorFieldFill(BuildContext context, ColorScheme cs) {
|
||||
if (context.isMornye) return Colors.transparent;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), cs.surface)
|
||||
: cs.surface;
|
||||
}
|
||||
|
||||
class MetadataTextField extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String? hint;
|
||||
final TextInputType? keyboard;
|
||||
final int maxLines;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
|
||||
const MetadataTextField(
|
||||
this.colorScheme,
|
||||
this.label,
|
||||
this.controller, {
|
||||
super.key,
|
||||
this.hint,
|
||||
this.keyboard,
|
||||
this.maxLines = 1,
|
||||
this.inputFormatters,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = colorScheme;
|
||||
final radius = BorderRadius.circular(14);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (context.isMornye) ...[
|
||||
CupertinoTextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
placeholder: hint,
|
||||
cursorColor: cs.primary,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
|
||||
decoration: null,
|
||||
),
|
||||
const Divider(height: 0.5),
|
||||
] else
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
cursorColor: cs.primary,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
filled: true,
|
||||
fillColor: metadataEditorFieldFill(context, cs),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: cs.primary, width: 1.5),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/metadata_autofill_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final service = MetadataAutofillService();
|
||||
|
||||
Extension provider(String id, String name, {bool enabled = true}) =>
|
||||
Extension(
|
||||
id: id,
|
||||
name: id,
|
||||
displayName: name,
|
||||
version: '1.0.0',
|
||||
description: 'Metadata fixture',
|
||||
enabled: enabled,
|
||||
status: 'loaded',
|
||||
hasMetadataProvider: true,
|
||||
);
|
||||
|
||||
test(
|
||||
'configured providers precede remaining enabled alphabetical providers',
|
||||
() {
|
||||
final ordered = service.orderedProviders(
|
||||
ExtensionState(
|
||||
extensions: [
|
||||
provider('third', 'Zeta'),
|
||||
provider('second', 'Alpha'),
|
||||
provider('first', 'Beta'),
|
||||
provider('disabled', 'Disabled', enabled: false),
|
||||
],
|
||||
metadataProviderPriority: const ['missing', 'third', 'third'],
|
||||
),
|
||||
);
|
||||
expect(ordered.map((extension) => extension.id), [
|
||||
'third',
|
||||
'second',
|
||||
'first',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('Unicode title/artist and exact ISRC matches retain confidence', () {
|
||||
expect(service.normalizeText(' 春の歌 — 歌手 '), '春の歌 歌手');
|
||||
expect(
|
||||
service.matchIsConfident(
|
||||
{'name': '春の歌', 'artists': '歌手'},
|
||||
currentTitle: '春の歌',
|
||||
currentArtist: '歌手',
|
||||
currentAlbum: '',
|
||||
currentIsrc: '',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
service.matchIsConfident(
|
||||
{
|
||||
'name': 'Other Song',
|
||||
'artists': 'Other Artist',
|
||||
'isrc': 'USABC1234567',
|
||||
},
|
||||
currentTitle: 'song',
|
||||
currentArtist: 'artist',
|
||||
currentAlbum: '',
|
||||
currentIsrc: 'USABC1234567',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
service.matchIsConfident(
|
||||
{'name': 'Song', 'artists': 'Another Performer'},
|
||||
currentTitle: 'song',
|
||||
currentArtist: 'artist',
|
||||
currentAlbum: '',
|
||||
currentIsrc: '',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'selected-provider enrichment filters unknown counts but keeps explicit zero',
|
||||
() async {
|
||||
final result = await service.enrich(
|
||||
{
|
||||
'title': 'Song',
|
||||
'track_number': 0,
|
||||
'explicit': 0,
|
||||
'images': [
|
||||
{'large': 'https://example.invalid/cover.jpg'},
|
||||
],
|
||||
},
|
||||
currentValues: const {},
|
||||
fields: {'title', 'explicit', 'cover'},
|
||||
providerId: 'example-provider',
|
||||
sourceTrackId: null,
|
||||
durationMs: 0,
|
||||
syncLyricsSettings: () async {},
|
||||
isCurrent: () => true,
|
||||
);
|
||||
expect(result!.values, {'title': 'Song', 'explicit': '0'});
|
||||
expect(result.coverUrl, 'https://example.invalid/cover.jpg');
|
||||
expect(result.sourceId, 'example-provider');
|
||||
},
|
||||
);
|
||||
|
||||
test('cancelled enrichment performs no lyrics work', () async {
|
||||
var synced = false;
|
||||
final result = await service.enrich(
|
||||
null,
|
||||
currentValues: const {'title': 'Song', 'artist': 'Artist'},
|
||||
fields: {'lyrics'},
|
||||
providerId: null,
|
||||
sourceTrackId: null,
|
||||
durationMs: 0,
|
||||
syncLyricsSettings: () async {
|
||||
synced = true;
|
||||
},
|
||||
isCurrent: () => false,
|
||||
);
|
||||
expect(result, isNull);
|
||||
expect(synced, isFalse);
|
||||
});
|
||||
|
||||
for (final instrumental in [false, true]) {
|
||||
test('lyrics-only enrichment handles instrumental=$instrumental', () async {
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
var synced = false;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'getLyricsLRCWithSource');
|
||||
expect(synced, isTrue);
|
||||
return jsonEncode({
|
||||
'lyrics': instrumental ? '[instrumental:true]' : '[00:01.00]Lyric',
|
||||
'instrumental': instrumental,
|
||||
'source': 'Example Lyrics',
|
||||
});
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
final result = await service.enrich(
|
||||
null,
|
||||
currentValues: const {'title': 'Song', 'artist': 'Artist'},
|
||||
fields: {'lyrics'},
|
||||
providerId: null,
|
||||
sourceTrackId: null,
|
||||
durationMs: 180000,
|
||||
syncLyricsSettings: () async {
|
||||
synced = true;
|
||||
},
|
||||
isCurrent: () => true,
|
||||
);
|
||||
expect(result!.lyricsSource, 'Example Lyrics');
|
||||
expect(result.values.containsKey('lyrics'), !instrumental);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'closing an editor preserves artwork until its save lease finishes',
|
||||
() async {
|
||||
final resources = MetadataCoverResources();
|
||||
final preview = await resources.copyPicked(
|
||||
extension: 'png',
|
||||
bytes: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
final release = resources.retain();
|
||||
await resources.dispose();
|
||||
expect(await File(preview.path).readAsBytes(), [1, 2, 3]);
|
||||
await release();
|
||||
expect(await Directory(preview.tempDir).exists(), isFalse);
|
||||
await release();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'disposal during a file creation cleans the completed resource',
|
||||
() async {
|
||||
final resources = MetadataCoverResources();
|
||||
final pending = resources.copyPicked(
|
||||
extension: 'jpg',
|
||||
bytes: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
await resources.dispose();
|
||||
final preview = await pending;
|
||||
expect(await Directory(preview.tempDir).exists(), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'replacing artwork defers release until every save lease finishes',
|
||||
() async {
|
||||
final resources = MetadataCoverResources();
|
||||
addTearDown(resources.dispose);
|
||||
final preview = await resources.copyPicked(
|
||||
extension: 'png',
|
||||
bytes: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
final firstSave = resources.retain();
|
||||
final secondSave = resources.retain();
|
||||
await resources.release(preview.tempDir);
|
||||
expect(await File(preview.path).readAsBytes(), [1, 2, 3]);
|
||||
await firstSave();
|
||||
expect(await File(preview.path).exists(), isTrue);
|
||||
await secondSave();
|
||||
expect(await Directory(preview.tempDir).exists(), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'releasing a selected image leaves other editor resources intact',
|
||||
() async {
|
||||
final resources = MetadataCoverResources();
|
||||
addTearDown(resources.dispose);
|
||||
final first = await resources.copyPicked(
|
||||
extension: 'png',
|
||||
bytes: Uint8List.fromList([1]),
|
||||
);
|
||||
final second = await resources.copyPicked(
|
||||
extension: 'jpg',
|
||||
bytes: Uint8List.fromList([2]),
|
||||
);
|
||||
await resources.release(first.tempDir);
|
||||
expect(await Directory(first.tempDir).exists(), isFalse);
|
||||
expect(await File(second.path).exists(), isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/controllers/metadata_editor_controller.dart';
|
||||
|
||||
void main() {
|
||||
test('save snapshots preserve empty tags and explicit track/disc totals', () {
|
||||
final editor = MetadataEditorController({
|
||||
'title': 'Song',
|
||||
'artist': 'Artist One; Artist Two',
|
||||
'total_tracks': '12',
|
||||
'total_discs': '2',
|
||||
'album_type': ' Compilation ',
|
||||
'explicit': 'true',
|
||||
});
|
||||
final tags = editor.saveMetadata(artistTagMode: 'all');
|
||||
editor['title']!.text = 'Changed';
|
||||
editor.dispose();
|
||||
|
||||
expect(tags['title'], 'Song');
|
||||
expect(tags['artist'], 'Artist One; Artist Two');
|
||||
expect(tags['lyrics'], '');
|
||||
expect(tags['track_total'], '12');
|
||||
expect(tags['disc_total'], '2');
|
||||
expect(tags['compilation'], '1');
|
||||
expect(tags['explicit'], '1');
|
||||
expect(tags['artist_tag_mode'], 'all');
|
||||
expect(tags.containsKey('total_tracks'), isFalse);
|
||||
expect(tags.containsKey('total_discs'), isFalse);
|
||||
});
|
||||
|
||||
test('edits and disposal invalidate asynchronous lookup results', () {
|
||||
final editor = MetadataEditorController({'title': 'Song'});
|
||||
final first = editor.beginLookup();
|
||||
expect(editor.isCurrent(first), isTrue);
|
||||
editor['title']!.text = 'Other Song';
|
||||
expect(editor.isCurrent(first), isFalse);
|
||||
final second = editor.beginLookup();
|
||||
editor.dispose();
|
||||
expect(editor.isCurrent(second), isFalse);
|
||||
});
|
||||
|
||||
test('caret and composition changes keep pending lookup results current', () {
|
||||
final editor = MetadataEditorController({'title': 'Song'});
|
||||
addTearDown(editor.dispose);
|
||||
final generation = editor.beginLookup();
|
||||
final title = editor['title']!;
|
||||
title.selection = const TextSelection.collapsed(offset: 2);
|
||||
expect(editor.isCurrent(generation), isTrue);
|
||||
title.value = title.value.copyWith(
|
||||
composing: const TextRange(start: 0, end: 4),
|
||||
);
|
||||
expect(editor.isCurrent(generation), isTrue);
|
||||
title.clearComposing();
|
||||
expect(editor.isCurrent(generation), isTrue);
|
||||
title.text = 'Other Song';
|
||||
expect(editor.isCurrent(generation), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'empty-field selection and apply respect manual tags and explicit false',
|
||||
() {
|
||||
final editor = MetadataEditorController({'title': 'Manual Title'});
|
||||
addTearDown(editor.dispose);
|
||||
editor.selectEmptyFields(hasCover: true);
|
||||
expect(editor.autoFillFields, isNot(contains('title')));
|
||||
expect(editor.autoFillFields, isNot(contains('cover')));
|
||||
expect(editor.autoFillFields, isNot(contains('explicit')));
|
||||
editor.autoFillFields.add('explicit');
|
||||
editor.explicit = true;
|
||||
final filled = editor.apply({
|
||||
'title': 'Online Title',
|
||||
'artist': 'Online Artist',
|
||||
'explicit': '0',
|
||||
});
|
||||
expect(filled, 2);
|
||||
expect(editor['title']!.text, 'Manual Title');
|
||||
expect(editor['artist']!.text, 'Online Artist');
|
||||
expect(editor.explicit, isFalse);
|
||||
editor.selectEmptyFields(hasCover: false);
|
||||
expect(editor.autoFillFields, contains('cover'));
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user