mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
refactor(layering): move model files to models and fix inverted imports
library_database_models and ffmpeg_models are plain data types, so they live in models (re-exports stay at the old paths). deleted_library_files uses providers and download_progress uses provider state, so both move to providers, and ffmpeg_reenrich is a service. models no longer import from services for these.
This commit is contained in:
1 parent
ab8a329d2d
commit
ce21896ff5
13 files changed
+558
-555
No files matched your search
@@ -16,7 +16,7 @@ import 'package:spotiflac_android/services/deleted_library_files.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_reenrich.dart';
|
||||
import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_models.dart';
|
||||
import 'package:spotiflac_android/models/ffmpeg_models.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/download_error_type.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/// Describes an extension-requested decryption step without coupling callers
|
||||
/// to the FFmpeg execution service.
|
||||
class DownloadDecryptionDescriptor {
|
||||
final String strategy;
|
||||
final String key;
|
||||
final String? inputFormat;
|
||||
final String? outputExtension;
|
||||
|
||||
const DownloadDecryptionDescriptor({
|
||||
required this.strategy,
|
||||
required this.key,
|
||||
this.inputFormat,
|
||||
this.outputExtension,
|
||||
});
|
||||
|
||||
factory DownloadDecryptionDescriptor.fromJson(Map<String, dynamic> json) {
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: (json['strategy'] as String? ?? '').trim(),
|
||||
key: (json['key'] as String? ?? '').trim(),
|
||||
inputFormat: (json['input_format'] as String?)?.trim(),
|
||||
outputExtension: (json['output_extension'] as String?)?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
static DownloadDecryptionDescriptor? fromDownloadResult(
|
||||
Map<String, dynamic> result,
|
||||
) {
|
||||
final rawDecryption = result['decryption'];
|
||||
if (rawDecryption is Map) {
|
||||
final descriptorJson = Map<String, dynamic>.from(rawDecryption);
|
||||
descriptorJson['output_extension'] ??= result['output_extension'];
|
||||
final descriptor = DownloadDecryptionDescriptor.fromJson(descriptorJson);
|
||||
if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' &&
|
||||
descriptor.key.isNotEmpty) {
|
||||
return descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
final legacyKey = (result['decryption_key'] as String?)?.trim() ?? '';
|
||||
if (legacyKey.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: 'ffmpeg.mov_key',
|
||||
key: legacyKey,
|
||||
inputFormat: 'mov',
|
||||
outputExtension: (result['output_extension'] as String?)?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
String get normalizedStrategy {
|
||||
switch (strategy.trim().toLowerCase()) {
|
||||
case '':
|
||||
case 'ffmpeg.mov_key':
|
||||
case 'ffmpeg_mov_key':
|
||||
case 'mov_decryption_key':
|
||||
case 'mp4_decryption_key':
|
||||
case 'ffmpeg.mp4_decryption_key':
|
||||
return 'ffmpeg.mov_key';
|
||||
default:
|
||||
return strategy.trim();
|
||||
}
|
||||
}
|
||||
|
||||
String? get normalizedOutputExtension {
|
||||
final trimmed = (outputExtension ?? '').trim().toLowerCase();
|
||||
if (trimmed.isEmpty) return null;
|
||||
return trimmed.startsWith('.') ? trimmed : '.$trimmed';
|
||||
}
|
||||
}
|
||||
|
||||
class CueSplitTrackInfo {
|
||||
final int number;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String isrc;
|
||||
final String composer;
|
||||
final double startSec;
|
||||
final double endSec;
|
||||
|
||||
CueSplitTrackInfo({
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
this.isrc = '',
|
||||
this.composer = '',
|
||||
required this.startSec,
|
||||
required this.endSec,
|
||||
});
|
||||
|
||||
factory CueSplitTrackInfo.fromJson(Map<String, dynamic> json) {
|
||||
return CueSplitTrackInfo(
|
||||
number: json['number'] as int? ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
artist: json['artist'] as String? ?? '',
|
||||
isrc: json['isrc'] as String? ?? '',
|
||||
composer: json['composer'] as String? ?? '',
|
||||
startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0,
|
||||
endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegResult {
|
||||
final bool success;
|
||||
final int returnCode;
|
||||
final String output;
|
||||
|
||||
FFmpegResult({
|
||||
required this.success,
|
||||
required this.returnCode,
|
||||
required this.output,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of an EBU R128 loudness scan, used to compute ReplayGain tags.
|
||||
class ReplayGainResult {
|
||||
/// Track gain in dB, e.g. "-6.50 dB".
|
||||
final String trackGain;
|
||||
|
||||
/// Track peak as a linear ratio, e.g. "0.988831".
|
||||
final String trackPeak;
|
||||
|
||||
/// Raw integrated loudness in LUFS (needed for album gain computation).
|
||||
final double integratedLufs;
|
||||
|
||||
/// Raw true peak as linear ratio (needed for album peak computation).
|
||||
final double truePeakLinear;
|
||||
|
||||
const ReplayGainResult({
|
||||
required this.trackGain,
|
||||
required this.trackPeak,
|
||||
required this.integratedLufs,
|
||||
required this.truePeakLinear,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ReplayGainResult(trackGain: $trackGain, trackPeak: $trackPeak)';
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
|
||||
// Row models and query descriptors for the local library database.
|
||||
|
||||
// v4 records ReplayGain availability; older rows rescan once.
|
||||
const libraryAudioMetadataScanVersion = 4;
|
||||
const libraryLyricsMetadataScanVersion = 3;
|
||||
|
||||
int libraryIncrementalSnapshotModTime({
|
||||
required int storedModTime,
|
||||
required int storedScanVersion,
|
||||
}) {
|
||||
return storedScanVersion >= libraryAudioMetadataScanVersion
|
||||
? storedModTime
|
||||
: -1;
|
||||
}
|
||||
|
||||
class LocalLibraryItem {
|
||||
static const legacySourceId = 'legacy';
|
||||
|
||||
final String id;
|
||||
final String sourceId;
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String? albumArtist;
|
||||
final String filePath;
|
||||
final String? coverPath;
|
||||
final DateTime scannedAt;
|
||||
final int? fileModTime;
|
||||
final String? isrc;
|
||||
final int? trackNumber;
|
||||
final int? totalTracks;
|
||||
final int? discNumber;
|
||||
final int? totalDiscs;
|
||||
final int? duration;
|
||||
final String? releaseDate;
|
||||
final int? bitDepth;
|
||||
final int? sampleRate;
|
||||
final int? bitrate; // average kbps for both lossless and lossy audio
|
||||
final String? genre;
|
||||
final String? composer;
|
||||
final String? label;
|
||||
final String? copyright;
|
||||
final bool explicit;
|
||||
final bool hasLyrics;
|
||||
final bool hasReplayGain;
|
||||
final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a
|
||||
|
||||
const LocalLibraryItem({
|
||||
required this.id,
|
||||
this.sourceId = legacySourceId,
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
this.albumArtist,
|
||||
required this.filePath,
|
||||
this.coverPath,
|
||||
required this.scannedAt,
|
||||
this.fileModTime,
|
||||
this.isrc,
|
||||
this.trackNumber,
|
||||
this.totalTracks,
|
||||
this.discNumber,
|
||||
this.totalDiscs,
|
||||
this.duration,
|
||||
this.releaseDate,
|
||||
this.bitDepth,
|
||||
this.sampleRate,
|
||||
this.bitrate,
|
||||
this.genre,
|
||||
this.composer,
|
||||
this.label,
|
||||
this.copyright,
|
||||
this.explicit = false,
|
||||
this.hasLyrics = false,
|
||||
this.hasReplayGain = false,
|
||||
this.format,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'sourceId': sourceId,
|
||||
'trackName': trackName,
|
||||
'artistName': artistName,
|
||||
'albumName': albumName,
|
||||
'albumArtist': albumArtist,
|
||||
'filePath': filePath,
|
||||
'coverPath': coverPath,
|
||||
'scannedAt': scannedAt.toIso8601String(),
|
||||
'fileModTime': fileModTime,
|
||||
'isrc': isrc,
|
||||
'trackNumber': trackNumber,
|
||||
'totalTracks': totalTracks,
|
||||
'discNumber': discNumber,
|
||||
'totalDiscs': totalDiscs,
|
||||
'duration': duration,
|
||||
'releaseDate': releaseDate,
|
||||
'bitDepth': bitDepth,
|
||||
'sampleRate': sampleRate,
|
||||
'bitrate': bitrate,
|
||||
'genre': genre,
|
||||
'composer': composer,
|
||||
'label': label,
|
||||
'copyright': copyright,
|
||||
'explicit': explicit,
|
||||
'hasLyrics': hasLyrics,
|
||||
'hasReplayGain': hasReplayGain,
|
||||
'format': format,
|
||||
};
|
||||
|
||||
factory LocalLibraryItem.fromJson(Map<String, dynamic> json) =>
|
||||
LocalLibraryItem(
|
||||
id: json['id'] as String,
|
||||
sourceId: json['sourceId'] as String? ?? legacySourceId,
|
||||
trackName: json['trackName'] as String,
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
filePath: json['filePath'] as String,
|
||||
coverPath: json['coverPath'] as String?,
|
||||
scannedAt: DateTime.parse(json['scannedAt'] as String),
|
||||
fileModTime: (json['fileModTime'] as num?)?.toInt(),
|
||||
isrc: json['isrc'] as String?,
|
||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
bitDepth: (json['bitDepth'] as num?)?.toInt(),
|
||||
sampleRate: (json['sampleRate'] as num?)?.toInt(),
|
||||
bitrate: (json['bitrate'] as num?)?.toInt(),
|
||||
genre: json['genre'] as String?,
|
||||
composer: json['composer'] as String?,
|
||||
label: json['label'] as String?,
|
||||
copyright: json['copyright'] as String?,
|
||||
explicit: json['explicit'] == true || json['explicit'] == 1,
|
||||
hasLyrics: json['hasLyrics'] == true || json['hasLyrics'] == 1,
|
||||
// Scan results carry the raw replaygain_* tags; stored rows the flag.
|
||||
hasReplayGain: metadataHasReplayGain(json),
|
||||
format: json['format'] as String?,
|
||||
);
|
||||
|
||||
LocalLibraryItem withAudioMetadata({
|
||||
int? duration,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrate,
|
||||
bool? explicit,
|
||||
bool? hasLyrics,
|
||||
bool? hasReplayGain,
|
||||
String? format,
|
||||
}) {
|
||||
return LocalLibraryItem(
|
||||
id: id,
|
||||
sourceId: sourceId,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
albumName: albumName,
|
||||
albumArtist: albumArtist,
|
||||
filePath: filePath,
|
||||
coverPath: coverPath,
|
||||
scannedAt: scannedAt,
|
||||
fileModTime: fileModTime,
|
||||
isrc: isrc,
|
||||
trackNumber: trackNumber,
|
||||
totalTracks: totalTracks,
|
||||
discNumber: discNumber,
|
||||
totalDiscs: totalDiscs,
|
||||
duration: duration ?? this.duration,
|
||||
releaseDate: releaseDate,
|
||||
bitDepth: bitDepth ?? this.bitDepth,
|
||||
sampleRate: sampleRate ?? this.sampleRate,
|
||||
bitrate: bitrate ?? this.bitrate,
|
||||
genre: genre,
|
||||
composer: composer,
|
||||
label: label,
|
||||
copyright: copyright,
|
||||
explicit: explicit ?? this.explicit,
|
||||
hasLyrics: hasLyrics ?? this.hasLyrics,
|
||||
hasReplayGain: hasReplayGain ?? this.hasReplayGain,
|
||||
format: format ?? this.format,
|
||||
);
|
||||
}
|
||||
|
||||
String get matchKey =>
|
||||
'${sqlite.normalizeLookupText(trackName)}|${sqlite.normalizeLookupText(artistName)}';
|
||||
String get albumKey =>
|
||||
'${sqlite.normalizeLookupText(albumName)}|${sqlite.normalizeLookupText(albumArtist ?? artistName)}';
|
||||
}
|
||||
|
||||
class LocalLibrarySource {
|
||||
final String id;
|
||||
final String path;
|
||||
final String displayName;
|
||||
final String? bookmark;
|
||||
final String? volumeId;
|
||||
final bool isRemovable;
|
||||
final bool enabled;
|
||||
final bool available;
|
||||
final int trackCount;
|
||||
final DateTime? lastScannedAt;
|
||||
final DateTime? lastSeenAt;
|
||||
final String? lastScanError;
|
||||
|
||||
const LocalLibrarySource({
|
||||
required this.id,
|
||||
required this.path,
|
||||
required this.displayName,
|
||||
this.bookmark,
|
||||
this.volumeId,
|
||||
this.isRemovable = false,
|
||||
this.enabled = true,
|
||||
this.available = true,
|
||||
this.trackCount = 0,
|
||||
this.lastScannedAt,
|
||||
this.lastSeenAt,
|
||||
this.lastScanError,
|
||||
});
|
||||
|
||||
bool get isIndexed => lastScannedAt != null || trackCount > 0;
|
||||
|
||||
LocalLibrarySource copyWith({
|
||||
bool? enabled,
|
||||
bool? available,
|
||||
int? trackCount,
|
||||
DateTime? lastScannedAt,
|
||||
DateTime? lastSeenAt,
|
||||
String? lastScanError,
|
||||
bool clearLastScanError = false,
|
||||
}) {
|
||||
return LocalLibrarySource(
|
||||
id: id,
|
||||
path: path,
|
||||
displayName: displayName,
|
||||
bookmark: bookmark,
|
||||
volumeId: volumeId,
|
||||
isRemovable: isRemovable,
|
||||
enabled: enabled ?? this.enabled,
|
||||
available: available ?? this.available,
|
||||
trackCount: trackCount ?? this.trackCount,
|
||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||
lastSeenAt: lastSeenAt ?? this.lastSeenAt,
|
||||
lastScanError: clearLastScanError
|
||||
? null
|
||||
: lastScanError ?? this.lastScanError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalLibrarySortMode { album, title, artist, latest, quality }
|
||||
|
||||
class LocalLibraryLookupIndex {
|
||||
final Set<String> isrcs;
|
||||
final Set<String> matchKeys;
|
||||
|
||||
const LocalLibraryLookupIndex({
|
||||
this.isrcs = const <String>{},
|
||||
this.matchKeys = const <String>{},
|
||||
});
|
||||
}
|
||||
|
||||
class LocalLibraryBatchLookupRequest {
|
||||
final String? id;
|
||||
final String? isrc;
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
|
||||
const LocalLibraryBatchLookupRequest({
|
||||
this.id,
|
||||
this.isrc,
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
});
|
||||
}
|
||||
|
||||
class IsrcDuplicateEntry {
|
||||
final String id;
|
||||
final String source; // 'downloaded' (history row) or 'local' (library row)
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String filePath;
|
||||
final int? bitDepth;
|
||||
final int? sampleRate;
|
||||
final int? bitrate;
|
||||
final String? format;
|
||||
|
||||
const IsrcDuplicateEntry({
|
||||
required this.id,
|
||||
required this.source,
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
required this.filePath,
|
||||
this.bitDepth,
|
||||
this.sampleRate,
|
||||
this.bitrate,
|
||||
this.format,
|
||||
});
|
||||
}
|
||||
|
||||
class IsrcDuplicateGroup {
|
||||
final String isrc;
|
||||
final List<IsrcDuplicateEntry> entries;
|
||||
|
||||
const IsrcDuplicateGroup({required this.isrc, required this.entries});
|
||||
}
|
||||
|
||||
class QueueLibraryDbQuery {
|
||||
final bool includeSingleTrackAlbums;
|
||||
final String? albumArtist;
|
||||
final int limit;
|
||||
final int offset;
|
||||
final String filterMode;
|
||||
final String? searchQuery;
|
||||
final String? source;
|
||||
final String? quality;
|
||||
final String? format;
|
||||
final String? metadata;
|
||||
final String sortMode;
|
||||
final bool includeLocal;
|
||||
final QueueLibraryDbCursor? cursor;
|
||||
|
||||
const QueueLibraryDbQuery({
|
||||
this.includeSingleTrackAlbums = false,
|
||||
this.albumArtist,
|
||||
this.limit = 100,
|
||||
this.offset = 0,
|
||||
this.filterMode = 'all',
|
||||
this.searchQuery,
|
||||
this.source,
|
||||
this.quality,
|
||||
this.format,
|
||||
this.metadata,
|
||||
this.sortMode = 'latest',
|
||||
this.includeLocal = true,
|
||||
this.cursor,
|
||||
});
|
||||
}
|
||||
|
||||
/// Opaque seek cursor for queue Library pagination.
|
||||
///
|
||||
/// Values follow the active SQL order (including its unique tie-breaker), so
|
||||
/// later pages can seek from the last row instead of making SQLite discard an
|
||||
/// ever-growing OFFSET prefix.
|
||||
class QueueLibraryDbCursor {
|
||||
final List<Object> values;
|
||||
|
||||
const QueueLibraryDbCursor(this.values);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! QueueLibraryDbCursor ||
|
||||
other.values.length != values.length) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (values[i] != other.values[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hashAll(values);
|
||||
}
|
||||
|
||||
class QueueLibraryDbPage {
|
||||
final List<Map<String, dynamic>> rows;
|
||||
final QueueLibraryDbCursor? nextCursor;
|
||||
|
||||
const QueueLibraryDbPage({required this.rows, required this.nextCursor});
|
||||
}
|
||||
|
||||
class QueueLibraryCounts {
|
||||
final int allTrackCount;
|
||||
final int albumCount;
|
||||
final int singleTrackCount;
|
||||
|
||||
const QueueLibraryCounts({
|
||||
required this.allTrackCount,
|
||||
required this.albumCount,
|
||||
required this.singleTrackCount,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:spotiflac_android/models/library_collections.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_history.dart';
|
||||
import 'package:spotiflac_android/services/library_database_models.dart';
|
||||
import 'package:spotiflac_android/models/library_database_models.dart';
|
||||
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
|
||||
/// Call only after deletion succeeds. A download can also have a scanned row;
|
||||
/// removing just one index makes its other row visible as a ghost entry.
|
||||
/// Capture the app container before I/O so leaving the screen cannot prevent
|
||||
/// cleanup after a slow network deletion.
|
||||
Future<void> removeDeletedLibraryFileEntries(
|
||||
ProviderContainer container,
|
||||
Iterable<String> filePaths,
|
||||
) async {
|
||||
final paths = filePaths.toSet();
|
||||
if (paths.isEmpty) return;
|
||||
await container
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.removePhysicalFiles(paths);
|
||||
await container
|
||||
.read(localLibraryProvider.notifier)
|
||||
.removePhysicalFiles(paths);
|
||||
}
|
||||
File renamed without changes.
@@ -19,7 +19,7 @@ import 'package:spotiflac_android/providers/download_verification_retry_guard.da
|
||||
import 'package:spotiflac_android/providers/download_queue_state.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_policy.dart';
|
||||
import 'package:spotiflac_android/services/download_queue_persistence.dart';
|
||||
import 'package:spotiflac_android/services/download_progress.dart';
|
||||
import 'package:spotiflac_android/providers/download_progress.dart';
|
||||
import 'package:spotiflac_android/services/download_file_finalizer.dart';
|
||||
import 'package:spotiflac_android/services/download_container_finalizer.dart';
|
||||
import 'package:spotiflac_android/services/download_completion_audio.dart';
|
||||
|
||||
@@ -1,21 +1 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
|
||||
/// Call only after deletion succeeds. A download can also have a scanned row;
|
||||
/// removing just one index makes its other row visible as a ghost entry.
|
||||
/// Capture the app container before I/O so leaving the screen cannot prevent
|
||||
/// cleanup after a slow network deletion.
|
||||
Future<void> removeDeletedLibraryFileEntries(
|
||||
ProviderContainer container,
|
||||
Iterable<String> filePaths,
|
||||
) async {
|
||||
final paths = filePaths.toSet();
|
||||
if (paths.isEmpty) return;
|
||||
await container
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.removePhysicalFiles(paths);
|
||||
await container
|
||||
.read(localLibraryProvider.notifier)
|
||||
.removePhysicalFiles(paths);
|
||||
}
|
||||
export 'package:spotiflac_android/providers/deleted_library_files.dart';
|
||||
@@ -1,141 +1 @@
|
||||
/// Describes an extension-requested decryption step without coupling callers
|
||||
/// to the FFmpeg execution service.
|
||||
class DownloadDecryptionDescriptor {
|
||||
final String strategy;
|
||||
final String key;
|
||||
final String? inputFormat;
|
||||
final String? outputExtension;
|
||||
|
||||
const DownloadDecryptionDescriptor({
|
||||
required this.strategy,
|
||||
required this.key,
|
||||
this.inputFormat,
|
||||
this.outputExtension,
|
||||
});
|
||||
|
||||
factory DownloadDecryptionDescriptor.fromJson(Map<String, dynamic> json) {
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: (json['strategy'] as String? ?? '').trim(),
|
||||
key: (json['key'] as String? ?? '').trim(),
|
||||
inputFormat: (json['input_format'] as String?)?.trim(),
|
||||
outputExtension: (json['output_extension'] as String?)?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
static DownloadDecryptionDescriptor? fromDownloadResult(
|
||||
Map<String, dynamic> result,
|
||||
) {
|
||||
final rawDecryption = result['decryption'];
|
||||
if (rawDecryption is Map) {
|
||||
final descriptorJson = Map<String, dynamic>.from(rawDecryption);
|
||||
descriptorJson['output_extension'] ??= result['output_extension'];
|
||||
final descriptor = DownloadDecryptionDescriptor.fromJson(descriptorJson);
|
||||
if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' &&
|
||||
descriptor.key.isNotEmpty) {
|
||||
return descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
final legacyKey = (result['decryption_key'] as String?)?.trim() ?? '';
|
||||
if (legacyKey.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DownloadDecryptionDescriptor(
|
||||
strategy: 'ffmpeg.mov_key',
|
||||
key: legacyKey,
|
||||
inputFormat: 'mov',
|
||||
outputExtension: (result['output_extension'] as String?)?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
String get normalizedStrategy {
|
||||
switch (strategy.trim().toLowerCase()) {
|
||||
case '':
|
||||
case 'ffmpeg.mov_key':
|
||||
case 'ffmpeg_mov_key':
|
||||
case 'mov_decryption_key':
|
||||
case 'mp4_decryption_key':
|
||||
case 'ffmpeg.mp4_decryption_key':
|
||||
return 'ffmpeg.mov_key';
|
||||
default:
|
||||
return strategy.trim();
|
||||
}
|
||||
}
|
||||
|
||||
String? get normalizedOutputExtension {
|
||||
final trimmed = (outputExtension ?? '').trim().toLowerCase();
|
||||
if (trimmed.isEmpty) return null;
|
||||
return trimmed.startsWith('.') ? trimmed : '.$trimmed';
|
||||
}
|
||||
}
|
||||
|
||||
class CueSplitTrackInfo {
|
||||
final int number;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String isrc;
|
||||
final String composer;
|
||||
final double startSec;
|
||||
final double endSec;
|
||||
|
||||
CueSplitTrackInfo({
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
this.isrc = '',
|
||||
this.composer = '',
|
||||
required this.startSec,
|
||||
required this.endSec,
|
||||
});
|
||||
|
||||
factory CueSplitTrackInfo.fromJson(Map<String, dynamic> json) {
|
||||
return CueSplitTrackInfo(
|
||||
number: json['number'] as int? ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
artist: json['artist'] as String? ?? '',
|
||||
isrc: json['isrc'] as String? ?? '',
|
||||
composer: json['composer'] as String? ?? '',
|
||||
startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0,
|
||||
endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegResult {
|
||||
final bool success;
|
||||
final int returnCode;
|
||||
final String output;
|
||||
|
||||
FFmpegResult({
|
||||
required this.success,
|
||||
required this.returnCode,
|
||||
required this.output,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of an EBU R128 loudness scan, used to compute ReplayGain tags.
|
||||
class ReplayGainResult {
|
||||
/// Track gain in dB, e.g. "-6.50 dB".
|
||||
final String trackGain;
|
||||
|
||||
/// Track peak as a linear ratio, e.g. "0.988831".
|
||||
final String trackPeak;
|
||||
|
||||
/// Raw integrated loudness in LUFS (needed for album gain computation).
|
||||
final double integratedLufs;
|
||||
|
||||
/// Raw true peak as linear ratio (needed for album peak computation).
|
||||
final double truePeakLinear;
|
||||
|
||||
const ReplayGainResult({
|
||||
required this.trackGain,
|
||||
required this.trackPeak,
|
||||
required this.integratedLufs,
|
||||
required this.truePeakLinear,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ReplayGainResult(trackGain: $trackGain, trackPeak: $trackPeak)';
|
||||
}
|
||||
export 'package:spotiflac_android/models/ffmpeg_models.dart';
|
||||
File renamed without changes.
@@ -1,388 +1 @@
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
|
||||
// Row models and query descriptors for the local library database.
|
||||
|
||||
// v4 records ReplayGain availability; older rows rescan once.
|
||||
const libraryAudioMetadataScanVersion = 4;
|
||||
const libraryLyricsMetadataScanVersion = 3;
|
||||
|
||||
int libraryIncrementalSnapshotModTime({
|
||||
required int storedModTime,
|
||||
required int storedScanVersion,
|
||||
}) {
|
||||
return storedScanVersion >= libraryAudioMetadataScanVersion
|
||||
? storedModTime
|
||||
: -1;
|
||||
}
|
||||
|
||||
class LocalLibraryItem {
|
||||
static const legacySourceId = 'legacy';
|
||||
|
||||
final String id;
|
||||
final String sourceId;
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String? albumArtist;
|
||||
final String filePath;
|
||||
final String? coverPath;
|
||||
final DateTime scannedAt;
|
||||
final int? fileModTime;
|
||||
final String? isrc;
|
||||
final int? trackNumber;
|
||||
final int? totalTracks;
|
||||
final int? discNumber;
|
||||
final int? totalDiscs;
|
||||
final int? duration;
|
||||
final String? releaseDate;
|
||||
final int? bitDepth;
|
||||
final int? sampleRate;
|
||||
final int? bitrate; // average kbps for both lossless and lossy audio
|
||||
final String? genre;
|
||||
final String? composer;
|
||||
final String? label;
|
||||
final String? copyright;
|
||||
final bool explicit;
|
||||
final bool hasLyrics;
|
||||
final bool hasReplayGain;
|
||||
final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a
|
||||
|
||||
const LocalLibraryItem({
|
||||
required this.id,
|
||||
this.sourceId = legacySourceId,
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
this.albumArtist,
|
||||
required this.filePath,
|
||||
this.coverPath,
|
||||
required this.scannedAt,
|
||||
this.fileModTime,
|
||||
this.isrc,
|
||||
this.trackNumber,
|
||||
this.totalTracks,
|
||||
this.discNumber,
|
||||
this.totalDiscs,
|
||||
this.duration,
|
||||
this.releaseDate,
|
||||
this.bitDepth,
|
||||
this.sampleRate,
|
||||
this.bitrate,
|
||||
this.genre,
|
||||
this.composer,
|
||||
this.label,
|
||||
this.copyright,
|
||||
this.explicit = false,
|
||||
this.hasLyrics = false,
|
||||
this.hasReplayGain = false,
|
||||
this.format,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'sourceId': sourceId,
|
||||
'trackName': trackName,
|
||||
'artistName': artistName,
|
||||
'albumName': albumName,
|
||||
'albumArtist': albumArtist,
|
||||
'filePath': filePath,
|
||||
'coverPath': coverPath,
|
||||
'scannedAt': scannedAt.toIso8601String(),
|
||||
'fileModTime': fileModTime,
|
||||
'isrc': isrc,
|
||||
'trackNumber': trackNumber,
|
||||
'totalTracks': totalTracks,
|
||||
'discNumber': discNumber,
|
||||
'totalDiscs': totalDiscs,
|
||||
'duration': duration,
|
||||
'releaseDate': releaseDate,
|
||||
'bitDepth': bitDepth,
|
||||
'sampleRate': sampleRate,
|
||||
'bitrate': bitrate,
|
||||
'genre': genre,
|
||||
'composer': composer,
|
||||
'label': label,
|
||||
'copyright': copyright,
|
||||
'explicit': explicit,
|
||||
'hasLyrics': hasLyrics,
|
||||
'hasReplayGain': hasReplayGain,
|
||||
'format': format,
|
||||
};
|
||||
|
||||
factory LocalLibraryItem.fromJson(Map<String, dynamic> json) =>
|
||||
LocalLibraryItem(
|
||||
id: json['id'] as String,
|
||||
sourceId: json['sourceId'] as String? ?? legacySourceId,
|
||||
trackName: json['trackName'] as String,
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
filePath: json['filePath'] as String,
|
||||
coverPath: json['coverPath'] as String?,
|
||||
scannedAt: DateTime.parse(json['scannedAt'] as String),
|
||||
fileModTime: (json['fileModTime'] as num?)?.toInt(),
|
||||
isrc: json['isrc'] as String?,
|
||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
bitDepth: (json['bitDepth'] as num?)?.toInt(),
|
||||
sampleRate: (json['sampleRate'] as num?)?.toInt(),
|
||||
bitrate: (json['bitrate'] as num?)?.toInt(),
|
||||
genre: json['genre'] as String?,
|
||||
composer: json['composer'] as String?,
|
||||
label: json['label'] as String?,
|
||||
copyright: json['copyright'] as String?,
|
||||
explicit: json['explicit'] == true || json['explicit'] == 1,
|
||||
hasLyrics: json['hasLyrics'] == true || json['hasLyrics'] == 1,
|
||||
// Scan results carry the raw replaygain_* tags; stored rows the flag.
|
||||
hasReplayGain: metadataHasReplayGain(json),
|
||||
format: json['format'] as String?,
|
||||
);
|
||||
|
||||
LocalLibraryItem withAudioMetadata({
|
||||
int? duration,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrate,
|
||||
bool? explicit,
|
||||
bool? hasLyrics,
|
||||
bool? hasReplayGain,
|
||||
String? format,
|
||||
}) {
|
||||
return LocalLibraryItem(
|
||||
id: id,
|
||||
sourceId: sourceId,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
albumName: albumName,
|
||||
albumArtist: albumArtist,
|
||||
filePath: filePath,
|
||||
coverPath: coverPath,
|
||||
scannedAt: scannedAt,
|
||||
fileModTime: fileModTime,
|
||||
isrc: isrc,
|
||||
trackNumber: trackNumber,
|
||||
totalTracks: totalTracks,
|
||||
discNumber: discNumber,
|
||||
totalDiscs: totalDiscs,
|
||||
duration: duration ?? this.duration,
|
||||
releaseDate: releaseDate,
|
||||
bitDepth: bitDepth ?? this.bitDepth,
|
||||
sampleRate: sampleRate ?? this.sampleRate,
|
||||
bitrate: bitrate ?? this.bitrate,
|
||||
genre: genre,
|
||||
composer: composer,
|
||||
label: label,
|
||||
copyright: copyright,
|
||||
explicit: explicit ?? this.explicit,
|
||||
hasLyrics: hasLyrics ?? this.hasLyrics,
|
||||
hasReplayGain: hasReplayGain ?? this.hasReplayGain,
|
||||
format: format ?? this.format,
|
||||
);
|
||||
}
|
||||
|
||||
String get matchKey =>
|
||||
'${sqlite.normalizeLookupText(trackName)}|${sqlite.normalizeLookupText(artistName)}';
|
||||
String get albumKey =>
|
||||
'${sqlite.normalizeLookupText(albumName)}|${sqlite.normalizeLookupText(albumArtist ?? artistName)}';
|
||||
}
|
||||
|
||||
class LocalLibrarySource {
|
||||
final String id;
|
||||
final String path;
|
||||
final String displayName;
|
||||
final String? bookmark;
|
||||
final String? volumeId;
|
||||
final bool isRemovable;
|
||||
final bool enabled;
|
||||
final bool available;
|
||||
final int trackCount;
|
||||
final DateTime? lastScannedAt;
|
||||
final DateTime? lastSeenAt;
|
||||
final String? lastScanError;
|
||||
|
||||
const LocalLibrarySource({
|
||||
required this.id,
|
||||
required this.path,
|
||||
required this.displayName,
|
||||
this.bookmark,
|
||||
this.volumeId,
|
||||
this.isRemovable = false,
|
||||
this.enabled = true,
|
||||
this.available = true,
|
||||
this.trackCount = 0,
|
||||
this.lastScannedAt,
|
||||
this.lastSeenAt,
|
||||
this.lastScanError,
|
||||
});
|
||||
|
||||
bool get isIndexed => lastScannedAt != null || trackCount > 0;
|
||||
|
||||
LocalLibrarySource copyWith({
|
||||
bool? enabled,
|
||||
bool? available,
|
||||
int? trackCount,
|
||||
DateTime? lastScannedAt,
|
||||
DateTime? lastSeenAt,
|
||||
String? lastScanError,
|
||||
bool clearLastScanError = false,
|
||||
}) {
|
||||
return LocalLibrarySource(
|
||||
id: id,
|
||||
path: path,
|
||||
displayName: displayName,
|
||||
bookmark: bookmark,
|
||||
volumeId: volumeId,
|
||||
isRemovable: isRemovable,
|
||||
enabled: enabled ?? this.enabled,
|
||||
available: available ?? this.available,
|
||||
trackCount: trackCount ?? this.trackCount,
|
||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||
lastSeenAt: lastSeenAt ?? this.lastSeenAt,
|
||||
lastScanError: clearLastScanError
|
||||
? null
|
||||
: lastScanError ?? this.lastScanError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalLibrarySortMode { album, title, artist, latest, quality }
|
||||
|
||||
class LocalLibraryLookupIndex {
|
||||
final Set<String> isrcs;
|
||||
final Set<String> matchKeys;
|
||||
|
||||
const LocalLibraryLookupIndex({
|
||||
this.isrcs = const <String>{},
|
||||
this.matchKeys = const <String>{},
|
||||
});
|
||||
}
|
||||
|
||||
class LocalLibraryBatchLookupRequest {
|
||||
final String? id;
|
||||
final String? isrc;
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
|
||||
const LocalLibraryBatchLookupRequest({
|
||||
this.id,
|
||||
this.isrc,
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
});
|
||||
}
|
||||
|
||||
class IsrcDuplicateEntry {
|
||||
final String id;
|
||||
final String source; // 'downloaded' (history row) or 'local' (library row)
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String filePath;
|
||||
final int? bitDepth;
|
||||
final int? sampleRate;
|
||||
final int? bitrate;
|
||||
final String? format;
|
||||
|
||||
const IsrcDuplicateEntry({
|
||||
required this.id,
|
||||
required this.source,
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
required this.filePath,
|
||||
this.bitDepth,
|
||||
this.sampleRate,
|
||||
this.bitrate,
|
||||
this.format,
|
||||
});
|
||||
}
|
||||
|
||||
class IsrcDuplicateGroup {
|
||||
final String isrc;
|
||||
final List<IsrcDuplicateEntry> entries;
|
||||
|
||||
const IsrcDuplicateGroup({required this.isrc, required this.entries});
|
||||
}
|
||||
|
||||
class QueueLibraryDbQuery {
|
||||
final bool includeSingleTrackAlbums;
|
||||
final String? albumArtist;
|
||||
final int limit;
|
||||
final int offset;
|
||||
final String filterMode;
|
||||
final String? searchQuery;
|
||||
final String? source;
|
||||
final String? quality;
|
||||
final String? format;
|
||||
final String? metadata;
|
||||
final String sortMode;
|
||||
final bool includeLocal;
|
||||
final QueueLibraryDbCursor? cursor;
|
||||
|
||||
const QueueLibraryDbQuery({
|
||||
this.includeSingleTrackAlbums = false,
|
||||
this.albumArtist,
|
||||
this.limit = 100,
|
||||
this.offset = 0,
|
||||
this.filterMode = 'all',
|
||||
this.searchQuery,
|
||||
this.source,
|
||||
this.quality,
|
||||
this.format,
|
||||
this.metadata,
|
||||
this.sortMode = 'latest',
|
||||
this.includeLocal = true,
|
||||
this.cursor,
|
||||
});
|
||||
}
|
||||
|
||||
/// Opaque seek cursor for queue Library pagination.
|
||||
///
|
||||
/// Values follow the active SQL order (including its unique tie-breaker), so
|
||||
/// later pages can seek from the last row instead of making SQLite discard an
|
||||
/// ever-growing OFFSET prefix.
|
||||
class QueueLibraryDbCursor {
|
||||
final List<Object> values;
|
||||
|
||||
const QueueLibraryDbCursor(this.values);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! QueueLibraryDbCursor ||
|
||||
other.values.length != values.length) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (values[i] != other.values[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hashAll(values);
|
||||
}
|
||||
|
||||
class QueueLibraryDbPage {
|
||||
final List<Map<String, dynamic>> rows;
|
||||
final QueueLibraryDbCursor? nextCursor;
|
||||
|
||||
const QueueLibraryDbPage({required this.rows, required this.nextCursor});
|
||||
}
|
||||
|
||||
class QueueLibraryCounts {
|
||||
final int allTrackCount;
|
||||
final int albumCount;
|
||||
final int singleTrackCount;
|
||||
|
||||
const QueueLibraryCounts({
|
||||
required this.allTrackCount,
|
||||
required this.albumCount,
|
||||
required this.singleTrackCount,
|
||||
});
|
||||
}
|
||||
export 'package:spotiflac_android/models/library_database_models.dart';
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_state.dart';
|
||||
import 'package:spotiflac_android/services/download_progress.dart';
|
||||
import 'package:spotiflac_android/providers/download_progress.dart';
|
||||
|
||||
DownloadItem _item({DownloadStatus status = DownloadStatus.downloading}) =>
|
||||
DownloadItem(
|
||||
|
||||
Reference in new issue
Block a user