From ce21896ff5a7ae3cfa863eefafe3aefc022009f3 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:28:52 +0700 Subject: [PATCH] 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. --- .../local_track_batch_actions.dart | 2 +- lib/models/download_result.dart | 2 +- lib/models/ffmpeg_models.dart | 141 +++++++ lib/models/library_database_models.dart | 388 +++++++++++++++++ lib/models/unified_library_item.dart | 2 +- lib/providers/deleted_library_files.dart | 21 + .../download_progress.dart | 0 lib/providers/download_queue_provider.dart | 2 +- lib/services/deleted_library_files.dart | 22 +- lib/services/ffmpeg_models.dart | 142 +------ lib/{utils => services}/ffmpeg_reenrich.dart | 0 lib/services/library_database_models.dart | 389 +----------------- test/download_progress_test.dart | 2 +- 13 files changed, 558 insertions(+), 555 deletions(-) create mode 100644 lib/models/ffmpeg_models.dart create mode 100644 lib/models/library_database_models.dart create mode 100644 lib/providers/deleted_library_files.dart rename lib/{services => providers}/download_progress.dart (100%) rename lib/{utils => services}/ffmpeg_reenrich.dart (100%) diff --git a/lib/controllers/local_track_batch_actions.dart b/lib/controllers/local_track_batch_actions.dart index 5af1966b..be821356 100644 --- a/lib/controllers/local_track_batch_actions.dart +++ b/lib/controllers/local_track_batch_actions.dart @@ -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'; diff --git a/lib/models/download_result.dart b/lib/models/download_result.dart index ab67b066..56526ac7 100644 --- a/lib/models/download_result.dart +++ b/lib/models/download_result.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'; diff --git a/lib/models/ffmpeg_models.dart b/lib/models/ffmpeg_models.dart new file mode 100644 index 00000000..b905d7e3 --- /dev/null +++ b/lib/models/ffmpeg_models.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 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 result, + ) { + final rawDecryption = result['decryption']; + if (rawDecryption is Map) { + final descriptorJson = Map.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 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)'; +} diff --git a/lib/models/library_database_models.dart b/lib/models/library_database_models.dart new file mode 100644 index 00000000..1cad77ed --- /dev/null +++ b/lib/models/library_database_models.dart @@ -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 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 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 isrcs; + final Set matchKeys; + + const LocalLibraryLookupIndex({ + this.isrcs = const {}, + this.matchKeys = const {}, + }); +} + +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 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 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> 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, + }); +} diff --git a/lib/models/unified_library_item.dart b/lib/models/unified_library_item.dart index ad37a49a..09a3001c 100644 --- a/lib/models/unified_library_item.dart +++ b/lib/models/unified_library_item.dart @@ -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'; diff --git a/lib/providers/deleted_library_files.dart b/lib/providers/deleted_library_files.dart new file mode 100644 index 00000000..244bf26a --- /dev/null +++ b/lib/providers/deleted_library_files.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 removeDeletedLibraryFileEntries( + ProviderContainer container, + Iterable filePaths, +) async { + final paths = filePaths.toSet(); + if (paths.isEmpty) return; + await container + .read(downloadHistoryProvider.notifier) + .removePhysicalFiles(paths); + await container + .read(localLibraryProvider.notifier) + .removePhysicalFiles(paths); +} diff --git a/lib/services/download_progress.dart b/lib/providers/download_progress.dart similarity index 100% rename from lib/services/download_progress.dart rename to lib/providers/download_progress.dart diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index d18855c3..bd765cb0 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -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'; diff --git a/lib/services/deleted_library_files.dart b/lib/services/deleted_library_files.dart index 244bf26a..94488e09 100644 --- a/lib/services/deleted_library_files.dart +++ b/lib/services/deleted_library_files.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 removeDeletedLibraryFileEntries( - ProviderContainer container, - Iterable 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'; diff --git a/lib/services/ffmpeg_models.dart b/lib/services/ffmpeg_models.dart index b905d7e3..0681f943 100644 --- a/lib/services/ffmpeg_models.dart +++ b/lib/services/ffmpeg_models.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 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 result, - ) { - final rawDecryption = result['decryption']; - if (rawDecryption is Map) { - final descriptorJson = Map.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 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'; diff --git a/lib/utils/ffmpeg_reenrich.dart b/lib/services/ffmpeg_reenrich.dart similarity index 100% rename from lib/utils/ffmpeg_reenrich.dart rename to lib/services/ffmpeg_reenrich.dart diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart index 1cad77ed..b43b91d2 100644 --- a/lib/services/library_database_models.dart +++ b/lib/services/library_database_models.dart @@ -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 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 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 isrcs; - final Set matchKeys; - - const LocalLibraryLookupIndex({ - this.isrcs = const {}, - this.matchKeys = const {}, - }); -} - -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 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 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> 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'; diff --git a/test/download_progress_test.dart b/test/download_progress_test.dart index e3e21553..9e54738d 100644 --- a/test/download_progress_test.dart +++ b/test/download_progress_test.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(