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:
zarzet committed 2026-10-19 00:37:42 +07:00
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 -1
View File
@@ -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';
+141
View File
@@ -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)';
}
+388
View File
@@ -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 -1
View File
@@ -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';
+21
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+1 -1
View File
@@ -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(