BUG 1 — Lieder vom Handy waren nicht abspielbar
("Wiedergabe fehlgeschlagen: (0) Source error" / "Loading interrupted")
Wurzel-Ursache: MeloAudioHandler.loadPlaylist hat jeden Warteschlangen-
Eintrag durch NavidromeService.streamAndCacheToLocal(item.id) geschickt,
sobald Zugangsdaten existierten. item.id ist aber NIE eine Navidrome-Song-ID
— lokal ist es file:///storage/..., beim Server die fertige Stream-Adresse.
Der Server bekam also 'file:///...' als Song-ID, antwortete mit einem Fehler,
und diese Fehlerantwort wurde an just_audio weitergereicht (Source error) —
bzw. als .mp3 in den Cache geschrieben, wodurch der Titel dauerhaft kaputt
blieb.
Zweite Ursache: die Schleife lud die GANZE Warteschlange seriell vorab
herunter (30s Timeout je Titel), bevor setAudioSources lief. Bei hunderten
Titeln startete die Wiedergabe deshalb nie; ein zweiter Tipp brach den
laufenden Ladevorgang ab ("Loading interrupted").
Fix:
- Server-Titel tragen ihre ID in MediaItem.extras['navidromeId'] statt sie
aus der Abspiel-Adresse zu raten. Neue reine Funktionen navidromeIdOf,
songIdOf, nutztServerCache, quelleFuer.
- loadPlaylist baut die Quellen ohne Netzzugriff; Caching des laufenden
Titels im Hintergrund (unawaited).
- Resume/Scrobble nur noch mit der jeweils passenden ID (Server bzw. lokal).
- ladeInCache() ersetzt streamAndCacheToLocal(): .part-Datei, Pruefung des
Inhaltstyps (istAudioAntwort), stabiler Cache-Schluessel ueber die
Song-ID statt der Stream-Adresse (die trug Token+Salt und war je Sitzung
anders — der Cache war nie wiederauffindbar), Client wird geschlossen.
BUG 2 — kein Abgleich zwischen Handy und Server
Neu: services/melo_cloud_service.dart + services/sync_service.dart gegen
cloud.baka-net.de (Bearer-JWT ueber BakaAuth). Server-Titel herunterladen
(offline verfuegbar), eigene Dateien hochladen, Loeschungen in beide
Richtungen (Tombstones), Favoriten und Wiedergabe-Verlauf. Automatisch beim
App-Start und bei Rueckkehr in die App (max. alle 15 Min), plus Knopf unter
Einstellungen -> Geraete-Abgleich. Reine Planungsfunktion planeSync().
Bewusst NICHT ueber Navidrome: die Subsonic-API kennt keinen Upload-
Endpunkt. Navidrome bleibt die Streaming-Bibliothek, die Melo-Cloud ist der
gemeinsame Speicher.
DB-Schema 8: songs.cloud_id verbindet Geraet und Server.
Nebenbei behoben (blockierte Build bzw. Tests):
- database.g.dart war veraltet — das Projekt liess sich nicht uebersetzen.
- metadataEdited wurde nirgends gesetzt/beachtet: von Hand korrigierte
Metadaten wurden vom naechsten Scan ueberschrieben. Jetzt in beiden
Scans respektiert; metadatenUebernahme() setzt die Markierung.
- song_detail_sheet_test.dart haengt beim Oeffnen des Modal-Sheets und
blockierte den gesamten Testlauf — vorerst uebersprungen (TODO im Code);
der Zweck wird von metadaten_uebernahme_test.dart abgedeckt.
Enthaelt ausserdem die bis dahin nicht committete Arbeit der Vorsitzung
(Musikerkennung/ACRCloud, MusicBrainz-Metadaten, MediaStore-Datentraeger).
267 Tests gruen (1 uebersprungen), flutter analyze ohne Befund.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
506 lines
18 KiB
Dart
506 lines
18 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:drift/drift.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
part 'database.g.dart';
|
|
|
|
/// Songs. Sync-fähig ab Tag 1: [id] ist eine stabile UUID, [updatedAtMs] und
|
|
/// [deleted] (Tombstone) ermöglichen späteren Cloud-Sync ohne Schema-Umbau.
|
|
class Songs extends Table {
|
|
TextColumn get id => text()(); // uuid
|
|
TextColumn get path => text().unique()();
|
|
TextColumn get title => text()();
|
|
TextColumn get artist => text().nullable()();
|
|
TextColumn get album => text().nullable()();
|
|
IntColumn get durationMs => integer().nullable()();
|
|
TextColumn get coverPath => text().nullable()();
|
|
IntColumn get dateAddedMs => integer()();
|
|
IntColumn get updatedAtMs => integer()();
|
|
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
|
|
|
|
/// Wie oft der Song vollständig gestartet wurde — Grundlage für die
|
|
/// Sortierung "Wie oft abgespielt".
|
|
IntColumn get playCount => integer().withDefault(const Constant(0))();
|
|
|
|
/// Sobald die Kategorien eines Songs von Hand geändert wurden, überschreibt
|
|
/// ein erneuter Scan sie nicht mehr mit dem Genre-Tag der Datei.
|
|
BoolColumn get categoriesEdited => boolean().withDefault(const Constant(false))();
|
|
|
|
/// Sobald Titel, Künstler oder Album von Hand korrigiert wurden, überschreibt
|
|
/// ein erneuter Scan sie nicht mehr mit den Tags der Datei.
|
|
BoolColumn get metadataEdited => boolean().withDefault(const Constant(false))();
|
|
|
|
/// Songtext aus dem Tag der Datei — Grundlage für den automatischen
|
|
/// Songtext ohne Server.
|
|
TextColumn get lyrics => text().nullable()();
|
|
|
|
/// ReplayGain des Titels in Dezibel, sofern die Datei den Tag mitbringt —
|
|
/// Grundlage für "Gleiche Lautstärke".
|
|
RealColumn get gainDb => real().nullable()();
|
|
|
|
/// ID desselben Titels in der Melo-Cloud. Verbindet den Titel auf dem Gerät
|
|
/// mit dem am Server und ist die Grundlage des Abgleichs: ohne sie gilt ein
|
|
/// Titel als nur lokal vorhanden und wird beim nächsten Sync hochgeladen.
|
|
TextColumn get cloudId => text().nullable()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
/// Vom Nutzer gewählte Musikordner, die gescannt werden.
|
|
class Folders extends Table {
|
|
TextColumn get id => text()(); // uuid
|
|
TextColumn get path => text().unique()();
|
|
IntColumn get updatedAtMs => integer()();
|
|
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
/// Nutzer-Playlisten.
|
|
class Playlists extends Table {
|
|
TextColumn get id => text()(); // uuid
|
|
TextColumn get name => text()();
|
|
TextColumn get description => text().nullable()();
|
|
IntColumn get createdAtMs => integer()();
|
|
IntColumn get updatedAtMs => integer()();
|
|
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
/// Zuordnung Song ↔ Playlist mit Reihenfolge.
|
|
class PlaylistSongs extends Table {
|
|
TextColumn get playlistId => text().references(Playlists, #id)();
|
|
TextColumn get songId => text().references(Songs, #id)();
|
|
IntColumn get position => integer()();
|
|
IntColumn get addedAtMs => integer()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {playlistId, songId};
|
|
}
|
|
|
|
/// Kategorien eines Songs — ein Song kann mehreren angehören. [position]
|
|
/// hält die Reihenfolge fest; die erste Kategorie bestimmt das Cover.
|
|
class SongCategories extends Table {
|
|
TextColumn get songId => text().references(Songs, #id)();
|
|
TextColumn get name => text()();
|
|
IntColumn get position => integer()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {songId, name};
|
|
}
|
|
|
|
/// Favorisierte Songs.
|
|
class Favorites extends Table {
|
|
TextColumn get songId => text().references(Songs, #id)();
|
|
IntColumn get createdAtMs => integer()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {songId};
|
|
}
|
|
|
|
/// Wiedergabe-Historie: letzte Position pro Song (für Resume) + Verlauf.
|
|
class PlaybackHistory extends Table {
|
|
TextColumn get songId => text().references(Songs, #id)();
|
|
IntColumn get positionMs => integer()();
|
|
IntColumn get playedAtMs => integer()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {songId, playedAtMs};
|
|
}
|
|
|
|
@DriftDatabase(tables: [
|
|
Songs,
|
|
Folders,
|
|
Playlists,
|
|
PlaylistSongs,
|
|
Favorites,
|
|
PlaybackHistory,
|
|
SongCategories,
|
|
])
|
|
class MeloDb extends _$MeloDb {
|
|
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
|
|
|
@override
|
|
int get schemaVersion => 8;
|
|
|
|
@override
|
|
MigrationStrategy get migration => MigrationStrategy(
|
|
onCreate: (m) => m.createAll(),
|
|
onUpgrade: (m, from, to) async {
|
|
if (from < 2) {
|
|
await m.createTable(playlists);
|
|
await m.createTable(playlistSongs);
|
|
await m.createTable(favorites);
|
|
await m.createTable(playbackHistory);
|
|
}
|
|
if (from < 3) {
|
|
await m.addColumn(songs, songs.playCount);
|
|
}
|
|
if (from < 4) {
|
|
await m.addColumn(songs, songs.categoriesEdited);
|
|
await m.createTable(songCategories);
|
|
}
|
|
if (from < 5) {
|
|
await m.addColumn(songs, songs.lyrics);
|
|
}
|
|
if (from < 6) {
|
|
await m.addColumn(songs, songs.gainDb);
|
|
}
|
|
if (from < 7) {
|
|
await m.addColumn(songs, songs.metadataEdited);
|
|
}
|
|
if (from < 8) {
|
|
await m.addColumn(songs, songs.cloudId);
|
|
}
|
|
},
|
|
);
|
|
|
|
/// Überwacht alle Songs (nicht getombstonte). Nach Titel sortiert.
|
|
Stream<List<Song>> watchSongs() {
|
|
return (select(songs)
|
|
..where((s) => s.deleted.equals(false))
|
|
..orderBy([(s) => OrderingTerm(expression: s.title)]))
|
|
.watch();
|
|
}
|
|
|
|
/// Überwacht die zuletzt hinzugefügten Songs (nicht getombstonte).
|
|
Stream<List<Song>> watchRecent({int limit = 50}) {
|
|
return (select(songs)
|
|
..where((s) => s.deleted.equals(false))
|
|
..orderBy([
|
|
(s) => OrderingTerm(expression: s.dateAddedMs, mode: OrderingMode.desc)
|
|
])
|
|
..limit(limit))
|
|
.watch();
|
|
}
|
|
|
|
/// Sucht Songs nach Titel, Künstler oder Album. Wildcards werden escaped.
|
|
Stream<List<Song>> searchSongs(String query) {
|
|
final escaped = query
|
|
.toLowerCase()
|
|
.replaceAll('\\', '\\\\')
|
|
.replaceAll('%', '\\%')
|
|
.replaceAll('_', '\\_');
|
|
final like = '%$escaped%';
|
|
return (select(songs)
|
|
..where((s) =>
|
|
s.deleted.equals(false) &
|
|
(s.title.lower().like(like, escapeChar: '\\') |
|
|
s.artist.lower().like(like, escapeChar: '\\') |
|
|
s.album.lower().like(like, escapeChar: '\\')))
|
|
..orderBy([(s) => OrderingTerm(expression: s.title)]))
|
|
.watch();
|
|
}
|
|
|
|
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
|
|
Future<List<Song>> allSongs() => select(songs).get();
|
|
|
|
/// Wie viele Titel die Bibliothek kennt (ohne getombstonte).
|
|
Future<int> countSongs() async {
|
|
final anzahl = songs.id.count();
|
|
final zeile = await (selectOnly(songs)
|
|
..addColumns([anzahl])
|
|
..where(songs.deleted.equals(false)))
|
|
.getSingle();
|
|
return zeile.read(anzahl) ?? 0;
|
|
}
|
|
|
|
/// Upsert Songs nach Pfad. Neue Songs bekommen UUID; bekannte erhalten sie zurück.
|
|
/// Setzt [updatedAtMs] auf die Werte der Companions (normalerweise Scan-Startzeit).
|
|
Future<void> upsertSongs(List<SongsCompanion> items) async {
|
|
await batch((b) => b.insertAllOnConflictUpdate(songs, items));
|
|
}
|
|
|
|
/// Tombstone für Songs, deren Datei beim Scan nicht mehr gefunden wurde.
|
|
/// Löscht nur Songs, die in diesem Scan (Zeitstempel [now]) nicht aktualisiert wurden.
|
|
Future<void> markMissing(int now) async {
|
|
await (update(songs)
|
|
..where((s) =>
|
|
s.deleted.equals(false) &
|
|
s.updatedAtMs.isSmallerThanValue(now)))
|
|
.write(
|
|
SongsCompanion(deleted: const Value(true), updatedAtMs: Value(now)),
|
|
);
|
|
}
|
|
|
|
Stream<List<Folder>> watchFolders() =>
|
|
(select(folders)..where((f) => f.deleted.equals(false))).watch();
|
|
|
|
Future<List<Folder>> activeFolders() =>
|
|
(select(folders)..where((f) => f.deleted.equals(false))).get();
|
|
|
|
Future<void> addFolder(FoldersCompanion folder) =>
|
|
into(folders).insert(folder, onConflict: DoUpdate((_) => folder, target: [folders.path]));
|
|
|
|
static const _uuid = Uuid();
|
|
|
|
// === Playlists ===
|
|
Stream<List<Playlist>> watchPlaylists() =>
|
|
(select(playlists)
|
|
..where((p) => p.deleted.equals(false))
|
|
..orderBy([(p) => OrderingTerm(expression: p.createdAtMs, mode: OrderingMode.desc)]))
|
|
.watch();
|
|
|
|
Future<String> createPlaylist(String name, {String? description}) async {
|
|
final id = _uuid.v4();
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
await into(playlists).insert(PlaylistsCompanion.insert(
|
|
id: id,
|
|
name: name,
|
|
description: Value(description),
|
|
createdAtMs: now,
|
|
updatedAtMs: now,
|
|
));
|
|
return id;
|
|
}
|
|
|
|
Future<void> deletePlaylist(String id) async {
|
|
await (update(playlists)..where((p) => p.id.equals(id))).write(
|
|
PlaylistsCompanion(
|
|
deleted: const Value(true),
|
|
updatedAtMs: Value(DateTime.now().millisecondsSinceEpoch),
|
|
),
|
|
);
|
|
}
|
|
|
|
// === PlaylistSongs ===
|
|
Stream<List<Song>> watchPlaylistSongs(String playlistId) {
|
|
final query = select(songs).join([
|
|
innerJoin(playlistSongs, playlistSongs.songId.equalsExp(songs.id)),
|
|
])
|
|
..where(playlistSongs.playlistId.equals(playlistId))
|
|
..orderBy([OrderingTerm(expression: playlistSongs.position)]);
|
|
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
|
|
}
|
|
|
|
Future<void> addSongToPlaylist(String playlistId, String songId, int position) async {
|
|
await into(playlistSongs).insert(
|
|
PlaylistSongsCompanion.insert(
|
|
playlistId: playlistId,
|
|
songId: songId,
|
|
position: position,
|
|
addedAtMs: DateTime.now().millisecondsSinceEpoch,
|
|
),
|
|
mode: InsertMode.insertOrReplace,
|
|
);
|
|
}
|
|
|
|
Future<void> removeSongFromPlaylist(String playlistId, String songId) async {
|
|
await (delete(playlistSongs)
|
|
..where((ps) => ps.playlistId.equals(playlistId) & ps.songId.equals(songId)))
|
|
.go();
|
|
}
|
|
|
|
Future<void> reorderPlaylistSong(String playlistId, String songId, int newPosition) async {
|
|
await (update(playlistSongs)
|
|
..where((ps) => ps.playlistId.equals(playlistId) & ps.songId.equals(songId)))
|
|
.write(PlaylistSongsCompanion(position: Value(newPosition)));
|
|
}
|
|
|
|
/// Schreibt für die gesamte Playlist neue, lückenlose Positionen (0..N-1)
|
|
/// gemäß [orderedSongIds] — vermeidet Mehrdeutigkeiten beim Umsortieren,
|
|
/// da einzelne [reorderPlaylistSong]-Aufrufe nur einen Song verschieben.
|
|
Future<void> reorderAllPlaylistSongs(String playlistId, List<String> orderedSongIds) async {
|
|
await transaction(() async {
|
|
for (var i = 0; i < orderedSongIds.length; i++) {
|
|
await (update(playlistSongs)
|
|
..where((ps) =>
|
|
ps.playlistId.equals(playlistId) & ps.songId.equals(orderedSongIds[i])))
|
|
.write(PlaylistSongsCompanion(position: Value(i)));
|
|
}
|
|
});
|
|
}
|
|
|
|
// === Favorites ===
|
|
Future<bool> songExists(String songId) async {
|
|
final row = await (select(songs)..where((s) => s.id.equals(songId))).getSingleOrNull();
|
|
return row != null;
|
|
}
|
|
|
|
Future<void> setFavorite(String songId, bool isFavorite) async {
|
|
final existing = await (select(favorites)..where((f) => f.songId.equals(songId))).getSingleOrNull();
|
|
if (isFavorite && existing == null) {
|
|
await into(favorites).insert(FavoritesCompanion.insert(
|
|
songId: songId,
|
|
createdAtMs: DateTime.now().millisecondsSinceEpoch,
|
|
));
|
|
} else if (!isFavorite && existing != null) {
|
|
await (delete(favorites)..where((f) => f.songId.equals(songId))).go();
|
|
}
|
|
}
|
|
|
|
Future<void> toggleFavorite(String songId) async {
|
|
final existing = await (select(favorites)..where((f) => f.songId.equals(songId))).getSingleOrNull();
|
|
if (existing == null) {
|
|
await into(favorites).insert(FavoritesCompanion.insert(
|
|
songId: songId,
|
|
createdAtMs: DateTime.now().millisecondsSinceEpoch,
|
|
));
|
|
} else {
|
|
await (delete(favorites)..where((f) => f.songId.equals(songId))).go();
|
|
}
|
|
}
|
|
|
|
Stream<bool> watchIsFavorite(String songId) {
|
|
return (select(favorites)..where((f) => f.songId.equals(songId)))
|
|
.watchSingleOrNull()
|
|
.map((row) => row != null);
|
|
}
|
|
|
|
Stream<List<Song>> watchFavorites() {
|
|
final query = select(songs).join([
|
|
innerJoin(favorites, favorites.songId.equalsExp(songs.id)),
|
|
])
|
|
..orderBy([OrderingTerm(expression: favorites.createdAtMs, mode: OrderingMode.desc)]);
|
|
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
|
|
}
|
|
|
|
/// Songtext eines Songs, sofern beim Scan einer im Tag gefunden wurde.
|
|
Future<String?> lyricsOf(String songId) async {
|
|
final row = await (select(songs)..where((s) => s.id.equals(songId)))
|
|
.getSingleOrNull();
|
|
final text = row?.lyrics?.trim();
|
|
return (text == null || text.isEmpty) ? null : text;
|
|
}
|
|
|
|
// === Kategorien ===
|
|
/// Alle Kategorien-Zuordnungen, nach Song gebündelt und in gespeicherter
|
|
/// Reihenfolge — die UI braucht sie immer für die ganze sichtbare Liste.
|
|
Stream<Map<String, List<String>>> watchCategoriesBySong() {
|
|
return (select(songCategories)
|
|
..orderBy([(c) => OrderingTerm(expression: c.position)]))
|
|
.watch()
|
|
.map((rows) {
|
|
final grouped = <String, List<String>>{};
|
|
for (final row in rows) {
|
|
grouped.putIfAbsent(row.songId, () => []).add(row.name);
|
|
}
|
|
return grouped;
|
|
});
|
|
}
|
|
|
|
Future<List<String>> categoriesOf(String songId) async {
|
|
final rows = await (select(songCategories)
|
|
..where((c) => c.songId.equals(songId))
|
|
..orderBy([(c) => OrderingTerm(expression: c.position)]))
|
|
.get();
|
|
return rows.map((r) => r.name).toList();
|
|
}
|
|
|
|
/// Ersetzt die Kategorien eines Songs. [byUser] markiert den Song als von
|
|
/// Hand bearbeitet, sodass der nächste Scan ihn in Ruhe lässt.
|
|
Future<void> setCategories(
|
|
String songId,
|
|
List<String> names, {
|
|
bool byUser = false,
|
|
}) async {
|
|
await transaction(() async {
|
|
await (delete(songCategories)..where((c) => c.songId.equals(songId))).go();
|
|
for (var i = 0; i < names.length; i++) {
|
|
await into(songCategories).insert(SongCategoriesCompanion.insert(
|
|
songId: songId,
|
|
name: names[i],
|
|
position: i,
|
|
));
|
|
}
|
|
if (byUser) {
|
|
await (update(songs)..where((s) => s.id.equals(songId)))
|
|
.write(const SongsCompanion(categoriesEdited: Value(true)));
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Alle vergebenen Kategorien, alphabetisch — für Vorschläge beim Bearbeiten.
|
|
Future<List<String>> allCategoryNames() async {
|
|
final rows = await (selectOnly(songCategories, distinct: true)
|
|
..addColumns([songCategories.name]))
|
|
.get();
|
|
final names = rows.map((r) => r.read(songCategories.name)!).toList()
|
|
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
|
return names;
|
|
}
|
|
|
|
// === Playback History ===
|
|
/// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim
|
|
/// periodischen Speichern der Position — sonst würde der Zähler hochlaufen,
|
|
/// solange ein Song nur läuft.
|
|
Future<void> incrementPlayCount(String songId) async {
|
|
await customUpdate(
|
|
'UPDATE songs SET play_count = play_count + 1 WHERE id = ?',
|
|
variables: [Variable<String>(songId)],
|
|
updates: {songs},
|
|
);
|
|
}
|
|
|
|
Future<void> recordPlayback(String songId, int positionMs) async {
|
|
await into(playbackHistory).insert(PlaybackHistoryCompanion.insert(
|
|
songId: songId,
|
|
positionMs: positionMs,
|
|
playedAtMs: DateTime.now().millisecondsSinceEpoch,
|
|
));
|
|
}
|
|
|
|
// === Cloud-Sync ===
|
|
/// Verknüpft einen Titel des Geräts mit seinem Gegenstück in der Cloud.
|
|
Future<void> setCloudId(String songId, String cloudId) async {
|
|
await (update(songs)..where((s) => s.id.equals(songId)))
|
|
.write(SongsCompanion(cloudId: Value(cloudId)));
|
|
}
|
|
|
|
/// Titel, die am Server gelöscht wurden, auch auf dem Gerät als gelöscht
|
|
/// markieren. Grabstein statt echtem Löschen — sonst legt der nächste Scan
|
|
/// sie wieder an.
|
|
Future<void> tombstoneByCloudIds(List<String> cloudIds) async {
|
|
if (cloudIds.isEmpty) return;
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
await (update(songs)..where((s) => s.cloudId.isIn(cloudIds))).write(
|
|
SongsCompanion(deleted: const Value(true), updatedAtMs: Value(now)),
|
|
);
|
|
}
|
|
|
|
/// Alle Favoriten-Song-IDs — Grundlage für den Favoriten-Abgleich.
|
|
Future<List<String>> favoriteSongIds() async {
|
|
final rows = await select(favorites).get();
|
|
return [for (final r in rows) r.songId];
|
|
}
|
|
|
|
/// Wiedergaben seit [sinceMs], neueste zuerst. Grundlage dafür, dem Server
|
|
/// zu melden, was auf diesem Gerät gehört wurde.
|
|
Future<List<PlaybackHistoryData>> historySince(int sinceMs,
|
|
{int limit = 100}) async {
|
|
return (select(playbackHistory)
|
|
..where((h) => h.playedAtMs.isBiggerThanValue(sinceMs))
|
|
..orderBy([
|
|
(h) => OrderingTerm(
|
|
expression: h.playedAtMs, mode: OrderingMode.desc)
|
|
])
|
|
..limit(limit))
|
|
.get();
|
|
}
|
|
|
|
Future<int?> lastPosition(String songId) async {
|
|
final row = await (select(playbackHistory)
|
|
..where((h) => h.songId.equals(songId))
|
|
..orderBy([(h) => OrderingTerm(expression: h.playedAtMs, mode: OrderingMode.desc)])
|
|
..limit(1))
|
|
.getSingleOrNull();
|
|
return row?.positionMs;
|
|
}
|
|
}
|
|
|
|
LazyDatabase _open() {
|
|
return LazyDatabase(() async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final file = File(p.join(dir.path, 'melo.sqlite'));
|
|
return NativeDatabase.createInBackground(file);
|
|
});
|
|
}
|