mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 07:18:49 +02:00
594 lines
18 KiB
Dart
594 lines
18 KiB
Dart
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
|
|
part of 'download_queue_provider.dart';
|
|
|
|
extension _DownloadQueuePaths on DownloadQueueNotifier {
|
|
Directory _defaultDocumentsOutputDir(String documentsPath) {
|
|
return Directory('$documentsPath/$_defaultOutputFolderName');
|
|
}
|
|
|
|
Directory _defaultAndroidMusicOutputDir(String storageRootPath) {
|
|
return Directory('$storageRootPath/$_defaultAndroidMusicSubpath');
|
|
}
|
|
|
|
Future<Directory> _ensureDefaultDocumentsOutputDir() async {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
final musicDir = _defaultDocumentsOutputDir(dir.path);
|
|
if (!await musicDir.exists()) {
|
|
await musicDir.create(recursive: true);
|
|
}
|
|
return musicDir;
|
|
}
|
|
|
|
Future<Directory?> _ensureDefaultAndroidMusicOutputDir() async {
|
|
final dir = await getExternalStorageDirectory();
|
|
if (dir == null) return null;
|
|
|
|
final musicDir = _defaultAndroidMusicOutputDir(
|
|
dir.parent.parent.parent.parent.path,
|
|
);
|
|
if (!await musicDir.exists()) {
|
|
await musicDir.create(recursive: true);
|
|
}
|
|
return musicDir;
|
|
}
|
|
|
|
Future<void> _initOutputDir() async {
|
|
if (state.outputDir.isEmpty) {
|
|
try {
|
|
if (Platform.isIOS) {
|
|
final musicDir = await _ensureDefaultDocumentsOutputDir();
|
|
state = state.copyWith(outputDir: musicDir.path);
|
|
} else {
|
|
final musicDir =
|
|
await _ensureDefaultAndroidMusicOutputDir() ??
|
|
await _ensureDefaultDocumentsOutputDir();
|
|
state = state.copyWith(outputDir: musicDir.path);
|
|
}
|
|
} catch (e) {
|
|
final musicDir = await _ensureDefaultDocumentsOutputDir();
|
|
state = state.copyWith(outputDir: musicDir.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _ensureDirExists(String path, {String? label}) async {
|
|
if (_ensuredDirs.contains(path)) return;
|
|
final dir = Directory(path);
|
|
if (!await dir.exists()) {
|
|
await dir.create(recursive: true);
|
|
if (label != null) {
|
|
_log.d('Created $label: $path');
|
|
} else {
|
|
_log.d('Created folder: $path');
|
|
}
|
|
}
|
|
_ensuredDirs.add(path);
|
|
}
|
|
|
|
bool _shouldTreatAsSingleRelease(Track track) {
|
|
if (track.isSingle) {
|
|
return true;
|
|
}
|
|
|
|
final normalizedAlbumType = normalizeOptionalString(
|
|
track.albumType,
|
|
)?.toLowerCase();
|
|
if (normalizedAlbumType != null && normalizedAlbumType.isNotEmpty) {
|
|
return false;
|
|
}
|
|
|
|
final totalTracks = track.totalTracks;
|
|
if (totalTracks == 1) {
|
|
return true;
|
|
}
|
|
|
|
final normalizedAlbumName = normalizeOptionalString(
|
|
track.albumName,
|
|
)?.toLowerCase();
|
|
if (normalizedAlbumName == 'single' || normalizedAlbumName == 'singles') {
|
|
return totalTracks == null || totalTracks <= 2;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
Future<String> _buildOutputDir(
|
|
Track track,
|
|
String folderOrganization, {
|
|
bool separateSingles = false,
|
|
String albumFolderStructure = 'artist_album',
|
|
bool createPlaylistFolder = false,
|
|
bool useAlbumArtistForFolders = true,
|
|
bool usePrimaryArtistOnly = false,
|
|
bool filterContributingArtistsInAlbumArtist = false,
|
|
String? playlistName,
|
|
}) async {
|
|
final baseDir = state.outputDir;
|
|
if (createPlaylistFolder &&
|
|
folderOrganization != 'playlist' &&
|
|
playlistName != null &&
|
|
playlistName.isNotEmpty) {
|
|
final playlistFolder = _sanitizeFolderName(playlistName);
|
|
if (playlistFolder.isNotEmpty) {
|
|
await _ensureDirExists(
|
|
'$baseDir${Platform.pathSeparator}$playlistFolder',
|
|
label: 'Playlist folder',
|
|
);
|
|
}
|
|
}
|
|
|
|
final relativeDir = await _buildRelativeOutputDir(
|
|
track,
|
|
folderOrganization,
|
|
separateSingles: separateSingles,
|
|
albumFolderStructure: albumFolderStructure,
|
|
createPlaylistFolder: createPlaylistFolder,
|
|
useAlbumArtistForFolders: useAlbumArtistForFolders,
|
|
usePrimaryArtistOnly: usePrimaryArtistOnly,
|
|
filterContributingArtistsInAlbumArtist:
|
|
filterContributingArtistsInAlbumArtist,
|
|
playlistName: playlistName,
|
|
);
|
|
if (relativeDir.isEmpty) {
|
|
return baseDir;
|
|
}
|
|
|
|
final fullPath =
|
|
'$baseDir${Platform.pathSeparator}'
|
|
'${relativeDir.replaceAll('/', Platform.pathSeparator)}';
|
|
await _ensureDirExists(fullPath);
|
|
return fullPath;
|
|
}
|
|
|
|
String _sanitizeFolderName(String name) {
|
|
final buffer = StringBuffer();
|
|
for (final rune in name.runes) {
|
|
if (rune < 0x20 || rune == 0x7f) {
|
|
continue;
|
|
}
|
|
final char = String.fromCharCode(rune);
|
|
if (_invalidFolderChars.hasMatch(char)) {
|
|
buffer.write(' ');
|
|
continue;
|
|
}
|
|
buffer.write(char);
|
|
}
|
|
|
|
var sanitized = buffer.toString().trim();
|
|
sanitized = sanitized.replaceAll(_trimDotsAndSpacesRegex, '');
|
|
sanitized = sanitized.replaceAll(_multiWhitespaceRegex, ' ');
|
|
sanitized = sanitized.replaceAll(_multiUnderscoreRegex, '_');
|
|
sanitized = sanitized.replaceAll(_trimUnderscoresAndSpacesRegex, '');
|
|
|
|
if (sanitized.isEmpty) {
|
|
return 'Unknown';
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
String _truncateUtf8Bytes(String value, int maxBytes) {
|
|
if (maxBytes <= 0 || utf8.encode(value).length <= maxBytes) {
|
|
return value;
|
|
}
|
|
|
|
final buffer = StringBuffer();
|
|
var usedBytes = 0;
|
|
for (final rune in value.runes) {
|
|
final char = String.fromCharCode(rune);
|
|
final charBytes = utf8.encode(char).length;
|
|
if (usedBytes + charBytes > maxBytes) break;
|
|
buffer.write(char);
|
|
usedBytes += charBytes;
|
|
}
|
|
return buffer.toString();
|
|
}
|
|
|
|
String _trimSafeName(String value) {
|
|
var trimmed = value.trim();
|
|
trimmed = trimmed.replaceAll(_trimDotsAndSpacesRegex, '');
|
|
trimmed = trimmed.replaceAll(_trimUnderscoresAndSpacesRegex, '');
|
|
return trimmed.isEmpty ? 'Unknown' : trimmed;
|
|
}
|
|
|
|
String _sanitizeSafRelativeDir(String relativeDir) {
|
|
if (relativeDir.trim().isEmpty) return '';
|
|
final parts = relativeDir
|
|
.split('/')
|
|
.map(_sanitizeFolderName)
|
|
.map((part) {
|
|
final truncated = _truncateUtf8Bytes(
|
|
part,
|
|
_maxSafDirSegmentUtf8Bytes,
|
|
);
|
|
return _trimSafeName(truncated);
|
|
})
|
|
.where((part) => part.isNotEmpty && part != '.' && part != '..')
|
|
.toList(growable: false);
|
|
return parts.join('/');
|
|
}
|
|
|
|
Future<String> _buildSafFileName(
|
|
String baseName,
|
|
String outputExt, {
|
|
String qualityVariant = '',
|
|
}) async {
|
|
final extBytes = utf8.encode(outputExt).length;
|
|
final maxBaseBytes = max(1, _maxSafFilenameUtf8Bytes - extBytes);
|
|
if (qualityVariant.isNotEmpty && baseName.contains(qualityVariant)) {
|
|
final rawPrefix = baseName
|
|
.replaceAll(qualityVariant, '')
|
|
.replaceFirst(RegExp(r'[\s_-]+$'), '');
|
|
final sanitizedPrefix = await PlatformBridge.sanitizeFilename(rawPrefix);
|
|
final suffix = ' - $qualityVariant';
|
|
final prefixBytes = max(1, maxBaseBytes - utf8.encode(suffix).length);
|
|
final truncatedPrefix = _trimSafeName(
|
|
_truncateUtf8Bytes(sanitizedPrefix, prefixBytes),
|
|
);
|
|
return '$truncatedPrefix$suffix$outputExt';
|
|
}
|
|
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
|
|
final truncated = _truncateUtf8Bytes(sanitized, maxBaseBytes);
|
|
return '${_trimSafeName(truncated)}$outputExt';
|
|
}
|
|
|
|
static final _featuredArtistPattern = RegExp(
|
|
r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
String _extractPrimaryArtist(String artist) {
|
|
final match = _featuredArtistPattern.firstMatch(artist);
|
|
if (match != null && match.start > 0) {
|
|
return artist.substring(0, match.start).trim();
|
|
}
|
|
return artist;
|
|
}
|
|
|
|
Future<String> _buildRelativeOutputDir(
|
|
Track track,
|
|
String folderOrganization, {
|
|
bool separateSingles = false,
|
|
String albumFolderStructure = 'artist_album',
|
|
bool createPlaylistFolder = false,
|
|
bool useAlbumArtistForFolders = true,
|
|
bool usePrimaryArtistOnly = false,
|
|
bool filterContributingArtistsInAlbumArtist = false,
|
|
String? playlistName,
|
|
}) async {
|
|
final playlistPrefix =
|
|
createPlaylistFolder &&
|
|
folderOrganization != 'playlist' &&
|
|
playlistName != null &&
|
|
playlistName.isNotEmpty
|
|
? _sanitizeFolderName(playlistName)
|
|
: '';
|
|
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
|
|
var folderArtist = useAlbumArtistForFolders
|
|
? normalizedAlbumArtist ?? track.artistName
|
|
: track.artistName;
|
|
if (useAlbumArtistForFolders &&
|
|
filterContributingArtistsInAlbumArtist &&
|
|
normalizedAlbumArtist != null) {
|
|
folderArtist = _extractPrimaryArtist(folderArtist);
|
|
}
|
|
if (usePrimaryArtistOnly) {
|
|
folderArtist = _extractPrimaryArtist(folderArtist);
|
|
}
|
|
|
|
if (separateSingles) {
|
|
final isSingle = _shouldTreatAsSingleRelease(track);
|
|
final artistName = _sanitizeFolderName(folderArtist);
|
|
|
|
if (albumFolderStructure == 'artist_album_singles') {
|
|
if (isSingle) {
|
|
return _joinRelativePath(playlistPrefix, '$artistName/Singles');
|
|
}
|
|
final albumName = _sanitizeFolderName(track.albumName);
|
|
return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
|
|
}
|
|
|
|
if (albumFolderStructure == 'artist_album_flat') {
|
|
if (isSingle) {
|
|
return _joinRelativePath(playlistPrefix, artistName);
|
|
}
|
|
final albumName = _sanitizeFolderName(track.albumName);
|
|
return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
|
|
}
|
|
|
|
if (isSingle) {
|
|
return _joinRelativePath(playlistPrefix, 'Singles');
|
|
}
|
|
|
|
final albumName = _sanitizeFolderName(track.albumName);
|
|
final year = _extractYear(track.releaseDate);
|
|
switch (albumFolderStructure) {
|
|
case 'album_only':
|
|
return _joinRelativePath(playlistPrefix, 'Albums/$albumName');
|
|
case 'artist_year_album':
|
|
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
|
return _joinRelativePath(
|
|
playlistPrefix,
|
|
'Albums/$artistName/$yearAlbum',
|
|
);
|
|
case 'year_album':
|
|
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
|
return _joinRelativePath(playlistPrefix, 'Albums/$yearAlbum');
|
|
default:
|
|
return _joinRelativePath(
|
|
playlistPrefix,
|
|
'Albums/$artistName/$albumName',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (folderOrganization == 'none') {
|
|
return playlistPrefix;
|
|
}
|
|
|
|
switch (folderOrganization) {
|
|
case 'playlist':
|
|
if (playlistName != null && playlistName.isNotEmpty) {
|
|
return _sanitizeFolderName(playlistName);
|
|
}
|
|
return '';
|
|
case 'artist':
|
|
return _joinRelativePath(
|
|
playlistPrefix,
|
|
_sanitizeFolderName(folderArtist),
|
|
);
|
|
case 'album':
|
|
return _joinRelativePath(
|
|
playlistPrefix,
|
|
_sanitizeFolderName(track.albumName),
|
|
);
|
|
case 'artist_album':
|
|
final artistName = _sanitizeFolderName(folderArtist);
|
|
final albumName = _sanitizeFolderName(track.albumName);
|
|
return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
|
|
default:
|
|
return playlistPrefix;
|
|
}
|
|
}
|
|
|
|
String _joinRelativePath(String prefix, String suffix) {
|
|
if (prefix.isEmpty) return suffix;
|
|
if (suffix.isEmpty) return prefix;
|
|
return '$prefix/$suffix';
|
|
}
|
|
|
|
String? _extensionPreferredOutputExt(String service) {
|
|
final normalizedService = service.trim().toLowerCase();
|
|
if (normalizedService.isEmpty) return null;
|
|
|
|
final extensionState = ref.read(extensionProvider);
|
|
for (final ext in extensionState.extensions) {
|
|
if (!ext.enabled || !ext.hasDownloadProvider) continue;
|
|
if (ext.id.toLowerCase() != normalizedService) continue;
|
|
|
|
final preferred = ext.preferredDownloadOutputExtension;
|
|
if (preferred == null) return null;
|
|
|
|
final normalized = preferred.startsWith('.')
|
|
? preferred.toLowerCase()
|
|
: '.${preferred.toLowerCase()}';
|
|
if (normalized == '.mp4') {
|
|
return '.m4a';
|
|
}
|
|
const allowed = <String>{'.flac', '.m4a', '.mp3', '.opus'};
|
|
if (allowed.contains(normalized)) {
|
|
return normalized;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
bool _extensionPreservesNativeOutputExt(String service, String ext) {
|
|
final normalizedService = service.trim().toLowerCase();
|
|
final normalizedExt = ext.trim().toLowerCase();
|
|
if (normalizedService.isEmpty || normalizedExt.isEmpty) return false;
|
|
|
|
final extensionState = ref.read(extensionProvider);
|
|
return extensionState.extensions.any(
|
|
(ext) =>
|
|
ext.enabled &&
|
|
ext.hasDownloadProvider &&
|
|
ext.id.toLowerCase() == normalizedService &&
|
|
ext.preservedNativeOutputExtensions.contains(normalizedExt),
|
|
);
|
|
}
|
|
|
|
bool _extensionRequiresNativeContainerConversion(String service) {
|
|
final normalizedService = service.trim().toLowerCase();
|
|
if (normalizedService.isEmpty) return false;
|
|
|
|
final extensionState = ref.read(extensionProvider);
|
|
return extensionState.extensions.any(
|
|
(ext) =>
|
|
ext.enabled &&
|
|
ext.hasDownloadProvider &&
|
|
(ext.id.toLowerCase() == normalizedService ||
|
|
ext.replacesBuiltInProviders.contains(normalizedService)) &&
|
|
ext.requiresNativeContainerConversion,
|
|
);
|
|
}
|
|
|
|
bool _shouldRequestContainerConversion(String service, String outputExt) {
|
|
return outputExt.trim().toLowerCase() == '.flac' &&
|
|
_extensionRequiresNativeContainerConversion(service);
|
|
}
|
|
|
|
String _determineOutputExt(String quality, String service) {
|
|
final extensionPreferred = _extensionPreferredOutputExt(service);
|
|
if (extensionPreferred != null) {
|
|
return extensionPreferred;
|
|
}
|
|
if (_downloadProviderReplacesLegacyProvider(service, 'tidal') &&
|
|
quality == 'HIGH') {
|
|
return '.m4a';
|
|
}
|
|
final q = quality.toLowerCase();
|
|
if (q == 'alac' || q.startsWith('aac')) return '.m4a';
|
|
if (q.startsWith('opus')) return '.opus';
|
|
if (q.startsWith('mp3')) return '.mp3';
|
|
return '.flac';
|
|
}
|
|
|
|
bool _downloadProviderReplacesLegacyProvider(
|
|
String service,
|
|
String legacyProviderId,
|
|
) {
|
|
return ref
|
|
.read(extensionProvider.notifier)
|
|
.downloadProviderReplacesLegacyProvider(service, legacyProviderId);
|
|
}
|
|
|
|
String _mimeTypeForExt(String ext) {
|
|
switch (ext.toLowerCase()) {
|
|
case '.m4a':
|
|
case '.mp4':
|
|
return 'audio/mp4';
|
|
case '.mp3':
|
|
return 'audio/mpeg';
|
|
case '.opus':
|
|
return 'audio/ogg';
|
|
case '.flac':
|
|
return 'audio/flac';
|
|
case '.lrc':
|
|
return 'application/octet-stream';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
String? _normalizeAudioExt(Object? value) {
|
|
final raw = value?.toString().trim().toLowerCase();
|
|
if (raw == null || raw.isEmpty) return null;
|
|
final normalized = raw.startsWith('.') ? raw : '.$raw';
|
|
const allowed = {'.flac', '.m4a', '.mp4', '.mp3', '.opus', '.ogg', '.aac'};
|
|
return allowed.contains(normalized) ? normalized : null;
|
|
}
|
|
|
|
String? _downloadResultOutputExt(
|
|
Map<String, dynamic> result, {
|
|
String? filePath,
|
|
}) {
|
|
final explicit =
|
|
_normalizeAudioExt(result['actual_extension']) ??
|
|
_normalizeAudioExt(result['output_extension']) ??
|
|
_normalizeAudioExt(result['actual_container']) ??
|
|
_normalizeAudioExt(result['container']);
|
|
if (explicit != null) return explicit;
|
|
|
|
for (final candidate in <String?>[
|
|
result['file_name'] as String?,
|
|
filePath,
|
|
result['file_path'] as String?,
|
|
]) {
|
|
if (candidate == null) continue;
|
|
final lower = candidate.trim().toLowerCase();
|
|
for (final ext in const [
|
|
'.flac',
|
|
'.m4a',
|
|
'.mp4',
|
|
'.mp3',
|
|
'.opus',
|
|
'.ogg',
|
|
'.aac',
|
|
]) {
|
|
if (lower.endsWith(ext)) return ext;
|
|
}
|
|
}
|
|
|
|
// Generic safety net: when neither an explicit extension field nor a
|
|
// recognizable path suffix is available (e.g. SAF content URIs that drop
|
|
// the suffix), fall back to the actual audio codec reported by the backend
|
|
// probe. This keeps any extension that returns a non-FLAC container (Opus,
|
|
// MP3, AAC) from being mislabeled as FLAC.
|
|
final codec = normalizeAudioFormatValue(
|
|
result['audio_codec']?.toString() ??
|
|
result['actual_audio_codec']?.toString() ??
|
|
result['format']?.toString(),
|
|
);
|
|
switch (codec) {
|
|
case 'opus':
|
|
return '.opus';
|
|
case 'mp3':
|
|
return '.mp3';
|
|
case 'aac':
|
|
case 'alac':
|
|
case 'm4a':
|
|
return '.m4a';
|
|
case 'flac':
|
|
return '.flac';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<String?> _getSafMimeType(String uri) async {
|
|
try {
|
|
final stat = await PlatformBridge.safStat(uri);
|
|
return stat['mime_type'] as String?;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String? _extractYear(String? releaseDate) {
|
|
if (releaseDate == null || releaseDate.isEmpty) return null;
|
|
final match = _yearRegex.firstMatch(releaseDate);
|
|
return match?.group(1);
|
|
}
|
|
|
|
int _validPlaylistPosition(DownloadItem item) {
|
|
final position = item.playlistPosition;
|
|
if (position == null || position <= 0) return 0;
|
|
return position;
|
|
}
|
|
|
|
String _filenameFormatForItem(DownloadItem item, String baseFormat) {
|
|
final trimmed = baseFormat.trim();
|
|
if (trimmed.isEmpty) {
|
|
return baseFormat;
|
|
}
|
|
var effective = trimmed;
|
|
if (item.fromBatch &&
|
|
!_batchUniqueFilenameTokenPattern.hasMatch(effective)) {
|
|
effective = '$effective - {track:02} - {title}';
|
|
}
|
|
if (item.preserveQualityVariant) {
|
|
effective = effective.replaceAll(
|
|
RegExp(r'\{quality\}', caseSensitive: false),
|
|
'{quality_variant}',
|
|
);
|
|
}
|
|
if (item.preserveQualityVariant &&
|
|
!_qualityFilenameTokenPattern.hasMatch(effective)) {
|
|
effective = '$effective - {quality_variant}';
|
|
}
|
|
return effective;
|
|
}
|
|
|
|
Map<String, dynamic> _filenameMetadataForTrack(
|
|
Track track, {
|
|
required String quality,
|
|
String qualityVariant = '',
|
|
int playlistPosition = 0,
|
|
}) {
|
|
return {
|
|
'title': track.name,
|
|
'artist': track.artistName,
|
|
'album': track.albumName,
|
|
'track': track.trackNumber ?? 0,
|
|
'disc': track.discNumber ?? 0,
|
|
'year': _extractYear(track.releaseDate) ?? '',
|
|
'date': track.releaseDate ?? '',
|
|
'playlist_position': playlistPosition,
|
|
'playlistPosition': playlistPosition,
|
|
'quality': quality,
|
|
'quality_variant': qualityVariant,
|
|
};
|
|
}
|
|
}
|