From c7a7a7c4584e9a9b2c989b416a7ebf19f1dca913 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Fri, 21 Aug 2026 09:36:46 +0200 Subject: [PATCH] =?UTF-8?q?UI:=20Alben=20sind=20Kategorien=20=E2=80=94=20R?= =?UTF-8?q?eiter=20Lieder/Kategorie/Kuenstler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auf Wunsch von Dustin: seine beste Freundin sortiert ihre Sammlung ueber das Album-Feld. In Melo ist der Album-Titel deshalb ab jetzt die Kategorie — sie muss nichts neu machen. 1) Album = Kategorie - kategorienAusTags(): Album-Titel steht VORN in der Kategorienliste (die erste Kategorie bestimmt das Coverbild), Genres dahinter. - Beide Scans (android_scan + scan_service) tragen ihn ein; von Hand gepflegte Kategorien (categoriesEdited) bleiben unberuehrt. - DB-Schema 9: einmalige Nachruestung bestehender Bibliotheken, damit das nicht erst beim naechsten vollstaendigen Scan sichtbar wird (der auf Android nur laeuft, wenn sich die Dateianzahl aendert). 2) Reiter: 'Songs/Kuenstler/Alben' -> 'Lieder/Kategorie/Kuenstler' - Neu: library/category_list.dart mit groupByCategory(); Lieder ohne Kategorie sammeln sich am Ende unter "Ohne Kategorie". - Entfernt: library/album_list.dart, groupByAlbum(), albumArtistLabel() — mit dem Alben-Reiter tot geworden. Das Album-FELD bleibt erhalten. 3) YouTube-Downloads ohne Original-Album - Feld "Kategorie (optional)" im YouTube-Bereich, mit Vorschlaegen aus der Bibliothek und freier Eingabe. - ordneDownloadEin() verwirft nach dem Scan das Album-Tag (yt-dlp leitet es aus Kanal/Playlist ab — als Kategorie waere das Unsinn) und setzt stattdessen die gewaehlte Kategorie. Beides als "von Hand gesetzt" markiert, damit der naechste Scan es nicht zurueckholt. - MeloDb.songByPath() und MeloDb.verwirfAlbum() neu. - CategoryService.alleNamen: Kategorienamen ohne zusaetzliche Abfrage — ohne das flackerte die Vorschlagsliste und im Widget-Test blieb ein Aufraeum-Timer von drift haengen. 295 Tests gruen (19 neue, 1 uebersprungen), flutter analyze ohne Befund. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81 --- CHANGELOG.md | 44 ++++ lib/downloads/download_einordnung.dart | 36 ++++ lib/downloads/downloads_screen.dart | 55 ++++- lib/library/album_list.dart | 69 ------ lib/library/android_scan.dart | 5 +- lib/library/categories.dart | 47 ++++ lib/library/category_list.dart | 83 ++++++++ lib/library/category_service.dart | 14 ++ lib/library/database.dart | 57 ++++- lib/library/my_music_screen.dart | 12 +- lib/library/scan_service.dart | 5 +- lib/library/song_grouping.dart | 14 -- test/downloads/download_einordnung_test.dart | 98 +++++++++ test/downloads/online_screen_test.dart | 42 +++- test/library/album_als_kategorie_test.dart | 213 +++++++++++++++++++ test/library/my_music_tabs_test.dart | 42 +++- test/library/song_grouping_test.dart | 47 +--- 17 files changed, 726 insertions(+), 157 deletions(-) create mode 100644 lib/downloads/download_einordnung.dart delete mode 100644 lib/library/album_list.dart create mode 100644 lib/library/category_list.dart create mode 100644 test/downloads/download_einordnung_test.dart create mode 100644 test/library/album_als_kategorie_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb57ef..63d2461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🗂️ Alben sind jetzt Kategorien — neue Bibliotheks-Reiter (2026-08-21) + +- 🏷️ **Album = Kategorie.** Wer seine Sammlung über das **Album-Feld** + sortiert hat, findet sie ab sofort genau so unter *Kategorie* wieder — der + Album-Titel eines Liedes **ist** in Melo seine Kategorie. Nichts muss neu + gemacht werden. + 🔧 Der Album-Titel steht dabei **vorn** in der Kategorienliste, weil die + erste Kategorie das Coverbild bestimmt (Einstellung „Gleiche Kategorie = + gleiches Coverbild"). Genres aus den Datei-Tags kommen weiterhin dazu, + aber dahinter. + 🔁 **Bestehende Bibliotheken werden beim ersten Start automatisch + nachgerüstet** — du musst nicht erst neu scannen. Von Hand gepflegte + Kategorien bleiben dabei unangetastet. +- 📑 **Neue Reiter in „Meine Musik": Lieder · Kategorie · Künstler.** Der + Alben-Reiter ist verschwunden — er ist im Kategorie-Reiter aufgegangen. + „Songs" heißt jetzt „Lieder". Der Kategorie-Reiter zeigt jede Kategorie mit + Coverbild und Anzahl; Lieder ganz ohne Kategorie sammeln sich am Ende unter + *Ohne Kategorie*. +- ⬇️ **YouTube-Downloads: Kategorie beim Herunterladen wählen.** Im + YouTube-Bereich gibt es ein Feld **Kategorie (optional)** — es schlägt die + Kategorien deiner Bibliothek vor, nimmt aber auch eine neue entgegen. Leer + lassen = der Titel kommt ohne Kategorie in die Bibliothek. + 🐛 **Warum das nötig ist:** YouTube-Dateien bringen ein Album-Tag mit, das + yt-dlp aus Kanal, Playlist oder Videotitel ableitet — Dinge wie + „Topic - Various Artists". Da das Album jetzt die Kategorie ist, hätte + **jeder Download eine neue Unsinns-Kategorie** erzeugt. Melo **verwirft** + dieses Album-Tag deshalb bewusst und nimmt stattdessen deine Angabe. + Beides wird als „von Hand gesetzt" markiert, damit der nächste Scan es + nicht wieder hereinholt. +- 🔧 **Details für Technikinteressierte:** + - Neu: `kategorienAusTags()` und `groupByCategory()` in + `library/categories.dart`, `library/category_list.dart`, + `downloads/download_einordnung.dart` (`ordneDownloadEin`, + `kategorieVorschlaege`). + - Beide Scans (Gerät + Ordner) tragen den Album-Titel als erste Kategorie + ein; `MeloDb.songByPath()` und `MeloDb.verwirfAlbum()` neu. + - Datenbank auf Version 9: einmalige Nachrüstung der Album-Kategorien. + - `CategoryService.alleNamen` liefert die Kategorienamen jetzt ohne + zusätzliche Abfrage — die Vorschlagsliste flackert dadurch nicht. + - Entfernt: `library/album_list.dart` sowie `groupByAlbum()` / + `albumArtistLabel()` — mit dem Alben-Reiter tot geworden. Das + **Album-Feld selbst bleibt** in den Metadaten erhalten. +- ✅ **Getestet:** 295 Tests grün (19 neue), `flutter analyze` ohne Befund. + ### 📚 Hochgeladene Titel landen jetzt auch in Navidrome (2026-08-21) - 🎵 **Was sich ändert:** Was du vom Handy hochlädst, taucht ab sofort **auch diff --git a/lib/downloads/download_einordnung.dart b/lib/downloads/download_einordnung.dart new file mode 100644 index 0000000..364d1c7 --- /dev/null +++ b/lib/downloads/download_einordnung.dart @@ -0,0 +1,36 @@ +import '../library/database.dart'; + +/// Ordnet einen frisch heruntergeladenen Titel in die Bibliothek ein. +/// +/// YouTube-Dateien bringen ein Album-Tag mit, das yt-dlp aus Kanal, Playlist +/// oder Videotitel ableitet. Da der Album-Titel in Melo die Kategorie ist, +/// wäre das Unsinn — es würde für jeden Download eine Kategorie wie +/// „Topic - Various Artists" erzeugen. Das Album wird deshalb verworfen; +/// stattdessen zählt allein die beim Download gewählte [kategorie]. +/// +/// Ein leerer [kategorie]-Wert lässt den Titel bewusst ohne Kategorie. Beides +/// wird als „von Hand gesetzt" markiert, damit der nächste Bibliotheks-Scan +/// die Tags der Datei nicht wieder hereinholt. +Future ordneDownloadEin( + MeloDb db, + String pfad, + String kategorie, +) async { + final song = await db.songByPath(pfad); + if (song == null) return; + await db.verwirfAlbum(song.id); + final name = kategorie.trim(); + await db.setCategories(song.id, name.isEmpty ? const [] : [name], + byUser: true); +} + +/// Kategorie-Vorschläge zur Eingabe [eingabe]: alles aus [vorhanden], das die +/// Eingabe enthält. Leere Eingabe zeigt alles. +List kategorieVorschlaege(String eingabe, List vorhanden) { + final suche = eingabe.trim().toLowerCase(); + if (suche.isEmpty) return vorhanden; + return [ + for (final name in vorhanden) + if (name.toLowerCase().contains(suche)) name, + ]; +} diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index 332313e..8eddd5d 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../library/category_service.dart'; +import '../library/database.dart'; import '../library/library_service.dart'; import '../player/audio_handler.dart'; import '../services/baka_auth.dart'; @@ -13,6 +15,7 @@ import '../services/navidrome_service.dart'; import '../services/server_neuheiten.dart'; import '../services/yt_download_service.dart'; import '../shared/sub_tabs.dart'; +import 'download_einordnung.dart'; import '../shared/theme.dart'; /// Online-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte @@ -398,6 +401,9 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { }); } + /// Kategorie, in die der heruntergeladene Titel wandert. Leer = keine. + final _kategorie = TextEditingController(); + Future _setzeCookies(bool an) async { setState(() => _cookies = an); final prefs = await SharedPreferences.getInstance(); @@ -407,6 +413,7 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { @override void dispose() { _url.dispose(); + _kategorie.dispose(); super.dispose(); } @@ -454,12 +461,19 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { return; } + final db = context.read(); + final kategorie = _kategorie.text; _url.clear(); setState(() => _meldung = '✅ ${ergebnis.titel} — wird eingelesen …'); // Der MediaStore kennt die Datei jetzt; ein Scan holt sie in die Bibliothek. await lib.rescan(); + // Erst nach dem Scan: vorher gibt es die Songzeile noch nicht. Hier wird + // auch das Album-Tag von YouTube verworfen (siehe ordneDownloadEin). + await ordneDownloadEin(db, pfad, kategorie); if (mounted) { - setState(() => _meldung = '✅ ${ergebnis.titel} ist in deiner Musik'); + setState(() => _meldung = kategorie.trim().isEmpty + ? '✅ ${ergebnis.titel} ist in deiner Musik' + : '✅ ${ergebnis.titel} ist in „${kategorie.trim()}“'); } } @@ -508,6 +522,8 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { border: OutlineInputBorder(), ), ), + const SizedBox(height: 12), + _KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft), SwitchListTile( dense: true, contentPadding: EdgeInsets.zero, @@ -618,3 +634,40 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> { ); } } + +/// Eingabefeld für die Kategorie eines Downloads: schlägt die Kategorien der +/// Bibliothek vor, nimmt aber auch eine neue entgegen. +/// +/// Nötig, weil das Album-Tag von YouTube verworfen wird — ohne diese Angabe +/// landet der Titel ohne Kategorie in der Bibliothek. +class _KategorieFeld extends StatelessWidget { + const _KategorieFeld({required this.controller, required this.aktiv}); + + final TextEditingController controller; + final bool aktiv; + + @override + Widget build(BuildContext context) { + final vorhanden = context.watch().alleNamen; + return Autocomplete( + optionsBuilder: (wert) => kategorieVorschlaege(wert.text, vorhanden), + onSelected: (wert) => controller.text = wert, + fieldViewBuilder: (context, feld, focus, onSubmit) { + // Der Autocomplete führt einen eigenen Controller; seinen Text an + // den unseren durchreichen, damit der Download ihn sieht. + feld.addListener(() => controller.text = feld.text); + return TextField( + controller: feld, + focusNode: focus, + enabled: aktiv, + decoration: const InputDecoration( + labelText: 'Kategorie (optional)', + hintText: 'z. B. Nightcore', + helperText: 'Leer lassen = ohne Kategorie', + border: OutlineInputBorder(), + ), + ); + }, + ); + } +} diff --git a/lib/library/album_list.dart b/lib/library/album_list.dart deleted file mode 100644 index de82dca..0000000 --- a/lib/library/album_list.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../shared/cover.dart'; -import 'database.dart'; -import 'song_grouping.dart'; -import 'song_list.dart'; - -/// Bibliotheks-Tab: Album-Übersicht, gruppiert aus [MeloDb.watchSongs]. -class AlbumListScreen extends StatelessWidget { - const AlbumListScreen({super.key}); - - @override - Widget build(BuildContext context) { - final db = context.read(); - return StreamBuilder>( - stream: db.watchSongs(), - builder: (context, snapshot) { - final songs = snapshot.data ?? const []; - if (songs.isEmpty) { - return const Center( - child: Text('Keine Alben', style: TextStyle(color: Colors.white54)), - ); - } - final grouped = groupByAlbum(songs); - final albums = grouped.keys.toList(); - return ListView.builder( - itemCount: albums.length, - itemBuilder: (context, i) { - final album = albums[i]; - final albumSongs = grouped[album]!; - final coverPath = albumSongs.first.coverPath; - return ListTile( - leading: CoverImage( - artUri: coverPath != null ? Uri.file(coverPath) : null, - size: 48, - radius: 6, - ), - title: Text(album, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text('${albumArtistLabel(albumSongs)} · ${albumSongs.length} Songs', - maxLines: 1, overflow: TextOverflow.ellipsis), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => _AlbumSongsScreen(album: album, songs: albumSongs), - ), - ), - ); - }, - ); - }, - ); - } -} - -/// Zeigt alle Songs eines Albums. -class _AlbumSongsScreen extends StatelessWidget { - const _AlbumSongsScreen({required this.album, required this.songs}); - final String album; - final List songs; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(album)), - body: SongList(songs), - ); - } -} diff --git a/lib/library/android_scan.dart b/lib/library/android_scan.dart index 77b5d87..5ca821a 100644 --- a/lib/library/android_scan.dart +++ b/lib/library/android_scan.dart @@ -87,7 +87,10 @@ Future scanAndroidMediaStore( deleted: const Value(false), )); if (prev?.categoriesEdited != true) { - categories[id] = parseCategories(s.genre); + categories[id] = kategorienAusTags( + album: behalten ? prev!.album : s.album, + genres: parseCategories(s.genre), + ); } ++done; if (done % 50 == 0 || done == songs.length) { diff --git a/lib/library/categories.dart b/lib/library/categories.dart index 958e431..a9d0126 100644 --- a/lib/library/categories.dart +++ b/lib/library/categories.dart @@ -17,6 +17,22 @@ List parseCategoryList(Iterable raw) { return _dedupe(raw.expand((e) => e.split(_separators)).map((e) => e.trim())); } +/// Kategorien eines Titels aus seinen Tags. +/// +/// Der **Album-Titel zählt als Kategorie** und steht vorn: viele Sammlungen +/// sind über das Album-Feld sortiert, nicht über das Genre — Alben und +/// Kategorien sind in Melo dieselbe Sache. Die vordere Position ist wichtig, +/// weil die erste Kategorie das Coverbild bestimmt. +List kategorienAusTags({ + String? album, + Iterable genres = const [], +}) { + final ausGenre = parseCategoryList(genres); + final albumName = album?.trim() ?? ''; + if (albumName.isEmpty) return ausGenre; + return _dedupe([albumName, ...ausGenre]); +} + List _dedupe(Iterable candidates) { final seen = {}; final result = []; @@ -27,6 +43,37 @@ List _dedupe(Iterable candidates) { return result; } +/// Name für Titel, denen keine Kategorie zugeordnet ist. +const ohneKategorie = 'Ohne Kategorie'; + +/// Bündelt [songs] nach Kategorie, alphabetisch nach Kategoriename. Ein Titel +/// erscheint unter **jeder** seiner Kategorien; Titel ganz ohne Kategorie +/// sammeln sich unter [ohneKategorie] (immer am Ende, damit die benannten +/// Kategorien oben stehen). +Map> groupByCategory( + List songs, + Map> categoriesBySong, +) { + final grouped = >{}; + final ohne = []; + for (final song in songs) { + final names = categoriesBySong[song.id] ?? const []; + if (names.isEmpty) { + ohne.add(song); + continue; + } + for (final name in names) { + grouped.putIfAbsent(name, () => []).add(song); + } + } + final keys = grouped.keys.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return { + for (final key in keys) key: grouped[key]!, + if (ohne.isNotEmpty) ohneKategorie: ohne, + }; +} + /// Ermittelt je Kategorie ein Coverbild: das erste vorhandene Cover unter den /// Songs der Kategorie, nach Titel geordnet — damit dieselbe Bibliothek immer /// dasselbe Bild ergibt. Kategorien ohne jedes Cover fehlen im Ergebnis. diff --git a/lib/library/category_list.dart b/lib/library/category_list.dart new file mode 100644 index 0000000..77ff703 --- /dev/null +++ b/lib/library/category_list.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../settings/app_settings.dart'; +import '../shared/cover.dart'; +import 'categories.dart'; +import 'category_service.dart'; +import 'database.dart'; +import 'song_list.dart'; + +/// Bibliotheks-Reiter „Kategorie": alle Kategorien der Bibliothek. +/// +/// Ersetzt den früheren Alben-Reiter — der Album-Titel eines Liedes **ist** +/// in Melo seine Kategorie (siehe [kategorienAusTags]). Wer seine Sammlung +/// über das Album-Feld sortiert hat, findet sie hier unverändert wieder. +class CategoryListScreen extends StatelessWidget { + const CategoryListScreen({super.key}); + + @override + Widget build(BuildContext context) { + final db = context.read(); + final categories = context.watch(); + final settings = context.watch(); + + return StreamBuilder>( + stream: db.watchSongs(), + builder: (context, snapshot) { + final songs = snapshot.data ?? const []; + if (songs.isEmpty) { + return const Center( + child: Text('Keine Kategorien', + style: TextStyle(color: Colors.white54)), + ); + } + final grouped = groupByCategory( + songs, + {for (final s in songs) s.id: categories.of(s.id)}, + ); + final namen = grouped.keys.toList(); + return ListView.builder( + itemCount: namen.length, + itemBuilder: (context, i) { + final name = namen[i]; + final lieder = grouped[name]!; + final cover = categories.coverFor(lieder.first, + groupByCategory: settings.groupCoversByCategory); + return ListTile( + leading: CoverImage( + artUri: cover != null ? Uri.file(cover) : null, + size: 48, + radius: 6, + ), + title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('${lieder.length} Lieder'), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + _KategorieLiederScreen(name: name, songs: lieder), + ), + ), + ); + }, + ); + }, + ); + } +} + +/// Zeigt alle Lieder einer Kategorie. +class _KategorieLiederScreen extends StatelessWidget { + const _KategorieLiederScreen({required this.name, required this.songs}); + final String name; + final List songs; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(name)), + body: SongList(songs), + ); + } +} diff --git a/lib/library/category_service.dart b/lib/library/category_service.dart index 49eefe4..81f81ff 100644 --- a/lib/library/category_service.dart +++ b/lib/library/category_service.dart @@ -43,6 +43,20 @@ class CategoryService extends ChangeNotifier { Future> allNames() => _db.allCategoryNames(); + /// Alle vergebenen Kategorien, alphabetisch — aus dem ohnehin beobachteten + /// Bestand, ohne zusätzliche Abfrage. Für Oberflächen, die die Liste beim + /// Bauen brauchen und nicht auf eine Antwort warten sollen. + List get alleNamen { + final namen = {}; + for (final liste in _byId.values) { + for (final name in liste) { + namen.putIfAbsent(name.toLowerCase(), () => name); + } + } + final sortiert = namen.keys.toList()..sort(); + return [for (final k in sortiert) namen[k]!]; + } + void _recompute() { _covers = categoryCovers(_allSongs, _byId); notifyListeners(); diff --git a/lib/library/database.dart b/lib/library/database.dart index 30c22cd..0d07aa8 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:drift/native.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -129,7 +130,7 @@ class MeloDb extends _$MeloDb { MeloDb([QueryExecutor? executor]) : super(executor ?? _open()); @override - int get schemaVersion => 8; + int get schemaVersion => 9; @override MigrationStrategy get migration => MigrationStrategy( @@ -160,6 +161,14 @@ class MeloDb extends _$MeloDb { if (from < 8) { await m.addColumn(songs, songs.cloudId); } + if (from < 9) { + // Album-Titel gelten ab jetzt als Kategorie (siehe + // kategorienAusTags). Ohne diese Nachrüstung bekämen bestehende + // Bibliotheken das erst beim nächsten vollständigen Scan zu + // sehen — der auf Android nur läuft, wenn sich die Anzahl der + // Dateien ändert. + await _ergaenzeAlbumKategorien(); + } }, ); @@ -448,6 +457,52 @@ class MeloDb extends _$MeloDb { )); } + /// Der Song mit genau diesem Dateipfad, oder `null`. + Future songByPath(String path) => + (select(songs)..where((s) => s.path.equals(path))).getSingleOrNull(); + + /// Verwirft das Album-Tag eines Titels und markiert ihn als von Hand + /// bearbeitet, damit der nächste Scan es nicht wieder hereinholt. + /// Für YouTube-Downloads: deren Album-Tag ist keine sinnvolle Kategorie. + Future verwirfAlbum(String songId) async { + await (update(songs)..where((s) => s.id.equals(songId))).write( + const SongsCompanion( + album: Value(null), + metadataEdited: Value(true), + ), + ); + } + + /// Nur für Tests: führt die Nachrüstung aus Schema 9 auf einer bereits + /// angelegten Datenbank aus. + @visibleForTesting + Future ergaenzeAlbumKategorienFuerTest() => _ergaenzeAlbumKategorien(); + + /// Trägt für jeden Titel seinen Album-Namen als **erste** Kategorie nach. + /// Von Hand gepflegte Kategorien (`categoriesEdited`) bleiben unberührt. + Future _ergaenzeAlbumKategorien() async { + const betroffen = "SELECT id, TRIM(album) AS album_name FROM songs " + "WHERE album IS NOT NULL AND TRIM(album) <> '' " + "AND categories_edited = 0"; + // 1) War der Album-Name schon Kategorie, kommt er gleich wieder vorn + // dazu — den alten Eintrag deshalb entfernen. + await customStatement( + 'DELETE FROM song_categories WHERE EXISTS (' + 'SELECT 1 FROM ($betroffen) b ' + 'WHERE b.id = song_categories.song_id AND b.album_name = song_categories.name)', + ); + // 2) Platz an Position 0 schaffen. + await customStatement( + 'UPDATE song_categories SET position = position + 1 ' + 'WHERE song_id IN (SELECT id FROM ($betroffen))', + ); + // 3) Album-Name als erste Kategorie setzen. + await customStatement( + 'INSERT INTO song_categories (song_id, name, position) ' + 'SELECT id, album_name, 0 FROM ($betroffen)', + ); + } + // === Cloud-Sync === /// Verknüpft einen Titel des Geräts mit seinem Gegenstück in der Cloud. Future setCloudId(String songId, String cloudId) async { diff --git a/lib/library/my_music_screen.dart b/lib/library/my_music_screen.dart index d26b450..b1af67c 100644 --- a/lib/library/my_music_screen.dart +++ b/lib/library/my_music_screen.dart @@ -8,7 +8,7 @@ import '../shared/sort_store.dart'; import '../shared/sub_tabs.dart'; import '../shared/sortable_song_list.dart'; import '../shared/theme.dart'; -import 'album_list.dart'; +import 'category_list.dart'; import 'artist_list.dart'; import 'database.dart'; import 'library_service.dart'; @@ -17,7 +17,7 @@ import 'permissions.dart'; import 'song_list.dart'; /// Startbildschirm: Kopfzeile mit Einstellungen/Suche/Musikerkennung, -/// Schnellzugriffe, Unterreiter (Songs/Künstler/Alben) und darunter der +/// Schnellzugriffe, Unterreiter (Lieder/Kategorie/Künstler) und darunter der /// jeweilige Inhalt. Bewusst ohne AppBar — die Kopfzeile ist Teil des Inhalts. class MyMusicScreen extends StatefulWidget { const MyMusicScreen({super.key, this.onSearchTap, this.onFavoritesTap}); @@ -33,7 +33,7 @@ class MyMusicScreen extends StatefulWidget { } class _MyMusicScreenState extends State { - /// 0 = Songs, 1 = Künstler, 2 = Alben. + /// 0 = Lieder, 1 = Kategorie, 2 = Künstler. int _subTab = 0; @override @@ -85,14 +85,14 @@ class _MyMusicScreenState extends State { ), _QuickAccessRow(onFavoritesTap: widget.onFavoritesTap), SubTabs( - labels: const ['Songs', 'Künstler', 'Alben'], + labels: const ['Lieder', 'Kategorie', 'Künstler'], index: _subTab, onChanged: (i) => setState(() => _subTab = i), ), Expanded( child: switch (_subTab) { - 1 => const ArtistListScreen(), - 2 => const AlbumListScreen(), + 1 => const CategoryListScreen(), + 2 => const ArtistListScreen(), _ => StreamBuilder>( stream: db.watchSongs(), builder: (context, snapshot) { diff --git a/lib/library/scan_service.dart b/lib/library/scan_service.dart index 8edfa74..185f452 100644 --- a/lib/library/scan_service.dart +++ b/lib/library/scan_service.dart @@ -107,7 +107,10 @@ Future scanFolders( gainDb: Value(gainDb), )); if (prev?.categoriesEdited != true) { - categories[id] = parseCategoryList(meta?.genres ?? const []); + categories[id] = kategorienAusTags( + album: behalten ? prev!.album : meta?.album, + genres: meta?.genres ?? const [], + ); } ++done; if (done % 50 == 0 || done == files.length) { diff --git a/lib/library/song_grouping.dart b/lib/library/song_grouping.dart index 276e2e1..6c61811 100644 --- a/lib/library/song_grouping.dart +++ b/lib/library/song_grouping.dart @@ -1,7 +1,6 @@ import 'database.dart'; const unbekannterKuenstler = 'Unbekannt'; -const unbekanntesAlbum = 'Unbekanntes Album'; /// Gruppiert [songs] nach Künstler (fehlender Künstler → [unbekannterKuenstler]), /// alphabetisch nach Künstlername sortiert. @@ -9,19 +8,6 @@ Map> groupByArtist(List songs) { return _groupBy(songs, (s) => s.artist, unbekannterKuenstler); } -/// Gruppiert [songs] nach Album (fehlendes Album → [unbekanntesAlbum]), -/// alphabetisch nach Albumname sortiert. -Map> groupByAlbum(List songs) { - return _groupBy(songs, (s) => s.album, unbekanntesAlbum); -} - -/// Künstler-Beschriftung für ein Album: der gemeinsame Künstler, falls alle -/// Songs im Album vom selben Künstler stammen, sonst "Verschiedene Interpreten". -String albumArtistLabel(List songs) { - final artists = songs.map((s) => s.artist ?? unbekannterKuenstler).toSet(); - return artists.length == 1 ? artists.first : 'Verschiedene Interpreten'; -} - Map> _groupBy( List songs, String? Function(Song) keyOf, diff --git a/test/downloads/download_einordnung_test.dart b/test/downloads/download_einordnung_test.dart new file mode 100644 index 0000000..a8ef19b --- /dev/null +++ b/test/downloads/download_einordnung_test.dart @@ -0,0 +1,98 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/downloads/download_einordnung.dart'; +import 'package:melo/library/database.dart'; + +const _pfad = '/storage/emulated/0/Music/Melo/Neues Lied.mp3'; + +Future _dbMitDownload({String? album}) async { + final db = MeloDb(NativeDatabase.memory()); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'yt-1', + path: _pfad, + title: 'Neues Lied', + album: Value(album), + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + // So sieht es nach dem Scan aus: das Album-Tag von YouTube ist bereits + // als Kategorie gelandet. + if (album != null) await db.setCategories('yt-1', [album]); + return db; +} + +void main() { + group('ordneDownloadEin', () { + test('das Album-Tag von YouTube wird verworfen', () async { + final db = await _dbMitDownload(album: 'Topic - Various Artists'); + addTearDown(db.close); + + await ordneDownloadEin(db, _pfad, 'Nightcore'); + + final song = (await db.allSongs()).single; + expect(song.album, isNull); + expect(await db.categoriesOf('yt-1'), ['Nightcore']); + }); + + test('die gewählte Kategorie überlebt den nächsten Scan', () async { + final db = await _dbMitDownload(album: 'Topic - Various Artists'); + addTearDown(db.close); + + await ordneDownloadEin(db, _pfad, 'Nightcore'); + + final song = (await db.allSongs()).single; + expect(song.metadataEdited, isTrue, reason: 'Album bleibt verworfen'); + expect(song.categoriesEdited, isTrue, reason: 'Kategorie bleibt stehen'); + }); + + test('ohne Angabe bleibt der Titel bewusst ohne Kategorie', () async { + final db = await _dbMitDownload(album: 'Topic - Various Artists'); + addTearDown(db.close); + + await ordneDownloadEin(db, _pfad, ' '); + + expect(await db.categoriesOf('yt-1'), isEmpty); + expect((await db.allSongs()).single.album, isNull); + }); + + test('umgebende Leerzeichen zählen nicht zum Namen', () async { + final db = await _dbMitDownload(); + addTearDown(db.close); + + await ordneDownloadEin(db, _pfad, ' Nightcore '); + + expect(await db.categoriesOf('yt-1'), ['Nightcore']); + }); + + test('ein unbekannter Pfad ändert nichts', () async { + final db = await _dbMitDownload(album: 'Bleibt'); + addTearDown(db.close); + + await ordneDownloadEin(db, '/gibt/es/nicht.mp3', 'Nightcore'); + + expect((await db.allSongs()).single.album, 'Bleibt'); + expect(await db.categoriesOf('yt-1'), ['Bleibt']); + }); + }); + + group('kategorieVorschlaege', () { + const vorhanden = ['Nightcore', 'Schwarz', 'Rock Classics']; + + test('leere Eingabe zeigt alles', () { + expect(kategorieVorschlaege('', vorhanden), vorhanden); + }); + + test('filtert nach Teiltreffer, unabhängig von Groß-/Kleinschreibung', () { + expect(kategorieVorschlaege('night', vorhanden), ['Nightcore']); + expect(kategorieVorschlaege('ROCK', vorhanden), ['Rock Classics']); + }); + + test('ohne Treffer bleibt die Liste leer — die Eingabe wird neue ' + 'Kategorie', () { + expect(kategorieVorschlaege('Gibtsnicht', vorhanden), isEmpty); + }); + }); +} diff --git a/test/downloads/online_screen_test.dart b/test/downloads/online_screen_test.dart index 4f986d3..552813e 100644 --- a/test/downloads/online_screen_test.dart +++ b/test/downloads/online_screen_test.dart @@ -5,7 +5,10 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import 'package:drift/native.dart'; import 'package:melo/downloads/downloads_screen.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/yt_download_service.dart'; import 'package:provider/provider.dart'; @@ -21,9 +24,15 @@ class _MemorySpeicher implements TokenSpeicher { Future loeschen(String key) async => werte.remove(key); } -Widget _wrap(BakaAuth auth) { +Widget _wrap(BakaAuth auth, + {required MeloDb db, required CategoryService categories}) { return MultiProvider( providers: [ + Provider.value(value: db), + // Bewusst .value: der Test besitzt den Dienst und schließt ihn im + // tearDown. Würde ihn der Baum beim Abbauen schließen, bliebe ein + // Aufräum-Timer von drift im Test-Rahmen hängen. + ChangeNotifierProvider.value(value: categories), ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider( create: (_) => YtDownloadService( @@ -63,11 +72,20 @@ void main() { }); } + late MeloDb db; + late CategoryService categories; + setUp(() { SharedPreferences.setMockInitialValues({}); nurInternerSpeicher(); + db = MeloDb(NativeDatabase.memory()); + categories = CategoryService(db); + }); + tearDown(() async { + messenger.setMockMethodCallHandler(medienKanal, null); + categories.dispose(); + await db.close(); }); - tearDown(() => messenger.setMockMethodCallHandler(medienKanal, null)); testWidgets('Online-Tab hat die Unterreiter YouTube und Server', (tester) async { @@ -75,7 +93,7 @@ void main() { BakaAuth(client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()); - await tester.pumpWidget(_wrap(auth)); + await tester.pumpWidget(_wrap(auth, db: db, categories: categories)); await tester.pump(); expect(find.text('YouTube'), findsOneWidget); @@ -88,7 +106,7 @@ void main() { BakaAuth(client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()); - await tester.pumpWidget(_wrap(auth)); + await tester.pumpWidget(_wrap(auth, db: db, categories: categories)); await tester.pump(); expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget); @@ -104,16 +122,18 @@ void main() { ); await auth.anmelden('Baka', 'geheim'); - await tester.pumpWidget(_wrap(auth)); + await tester.pumpWidget(_wrap(auth, db: db, categories: categories)); await tester.pump(); expect(find.text('Angemeldet als Baka'), findsOneWidget); - expect(find.byType(TextField), findsOneWidget); + expect(find.text('YouTube-Adresse'), findsOneWidget); + // Zweites Feld: die Kategorie, in die der Download wandert. + expect(find.text('Kategorie (optional)'), findsOneWidget); expect(find.text('Herunterladen'), findsOneWidget); }); testWidgets('Der Cookie-Schalter steht anfangs an', (tester) async { - await tester.pumpWidget(_wrap(await _angemeldet())); + await tester.pumpWidget(_wrap(await _angemeldet(), db: db, categories: categories)); await tester.pumpAndSettle(); expect(find.text('YouTube-Cookies des Servers verwenden'), findsOneWidget); @@ -122,7 +142,7 @@ void main() { }); testWidgets('Abschalten des Cookie-Schalters wird gemerkt', (tester) async { - await tester.pumpWidget(_wrap(await _angemeldet())); + await tester.pumpWidget(_wrap(await _angemeldet(), db: db, categories: categories)); await tester.pumpAndSettle(); await tester.tap(find.byType(SwitchListTile)); @@ -138,7 +158,7 @@ void main() { (tester) async { SharedPreferences.setMockInitialValues({'yt_cookies': false}); - await tester.pumpWidget(_wrap(await _angemeldet())); + await tester.pumpWidget(_wrap(await _angemeldet(), db: db, categories: categories)); await tester.pumpAndSettle(); expect(tester.widget(find.byType(SwitchListTile)).value, @@ -155,7 +175,7 @@ void main() { ]; }); - await tester.pumpWidget(_wrap(await _angemeldet())); + await tester.pumpWidget(_wrap(await _angemeldet(), db: db, categories: categories)); await tester.pumpAndSettle(); expect(find.byType(DropdownButtonFormField), findsOneWidget); @@ -164,7 +184,7 @@ void main() { testWidgets('Bei nur einem Speicherort gibt es keine Auswahl', (tester) async { - await tester.pumpWidget(_wrap(await _angemeldet())); + await tester.pumpWidget(_wrap(await _angemeldet(), db: db, categories: categories)); await tester.pumpAndSettle(); expect(find.byType(DropdownButtonFormField), findsNothing); diff --git a/test/library/album_als_kategorie_test.dart b/test/library/album_als_kategorie_test.dart new file mode 100644 index 0000000..b481832 --- /dev/null +++ b/test/library/album_als_kategorie_test.dart @@ -0,0 +1,213 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/categories.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/scan_service.dart'; +import 'package:path/path.dart' as p; + +Song _song(String id, {String titel = 'Lied'}) => Song( + id: id, + path: '/musik/$id.mp3', + title: titel, + dateAddedMs: 0, + updatedAtMs: 0, + deleted: false, + playCount: 0, + categoriesEdited: false, + metadataEdited: false, + ); + +void main() { + group('kategorienAusTags', () { + test('das Album steht vorn — es bestimmt das Coverbild', () { + expect( + kategorienAusTags(album: 'Schwarz', genres: const ['Nightcore']), + ['Schwarz', 'Nightcore'], + ); + }); + + test('ohne Album bleiben nur die Genres', () { + expect(kategorienAusTags(genres: const ['Rock']), ['Rock']); + expect(kategorienAusTags(album: ' ', genres: const ['Rock']), ['Rock']); + }); + + test('ohne alles gibt es keine Kategorie', () { + expect(kategorienAusTags(), isEmpty); + }); + + test('Album und Genre mit gleichem Namen erscheinen nur einmal', () { + expect( + kategorienAusTags(album: 'Nightcore', genres: const ['nightcore']), + ['Nightcore'], + ); + }); + + test('mehrere Genres in einem Feld werden aufgetrennt', () { + expect( + kategorienAusTags(album: 'Mix', genres: const ['Pop; Rock']), + ['Mix', 'Pop', 'Rock'], + ); + }); + }); + + group('groupByCategory', () { + test('ein Titel erscheint unter jeder seiner Kategorien', () { + final songs = [_song('1'), _song('2')]; + final grouped = groupByCategory(songs, { + '1': ['Schwarz', 'Nightcore'], + '2': ['Nightcore'], + }); + + expect(grouped.keys, ['Nightcore', 'Schwarz']); + expect(grouped['Nightcore']!.map((s) => s.id), ['1', '2']); + expect(grouped['Schwarz']!.map((s) => s.id), ['1']); + }); + + test('Titel ohne Kategorie sammeln sich am Ende', () { + final grouped = groupByCategory( + [_song('1'), _song('2')], + { + '1': ['Schwarz'] + }, + ); + + expect(grouped.keys.last, ohneKategorie); + expect(grouped[ohneKategorie]!.map((s) => s.id), ['2']); + }); + + test('ohne Titel ohne Kategorie fehlt der Sammel-Eintrag', () { + final grouped = groupByCategory([ + _song('1') + ], { + '1': ['Schwarz'] + }); + + expect(grouped.containsKey(ohneKategorie), isFalse); + }); + + test('leere Bibliothek ergibt nichts', () { + expect(groupByCategory(const [], const {}), isEmpty); + }); + }); + + group('Scan trägt das Album als Kategorie ein', () { + final fixtures = p.join(Directory.current.path, 'test', 'fixtures', 'audio'); + late MeloDb db; + late Directory coverDir; + + setUp(() async { + db = MeloDb(NativeDatabase.memory()); + coverDir = await Directory.systemTemp.createTemp('melo_covers'); + }); + tearDown(() async { + await db.close(); + await coverDir.delete(recursive: true); + }); + + test('nach dem Scan ist der Album-Titel eine Kategorie', () async { + await scanFolders(db, [fixtures], coverDir: coverDir); + final song = + (await db.watchSongs().first).firstWhere((s) => s.title == 'Nachtpuls'); + + expect(song.album, 'Schwarz'); + expect(await db.categoriesOf(song.id), contains('Schwarz')); + expect((await db.categoriesOf(song.id)).first, 'Schwarz'); + }); + + test('von Hand gesetzte Kategorien überschreibt der Scan nicht', () async { + await scanFolders(db, [fixtures], coverDir: coverDir); + final song = + (await db.watchSongs().first).firstWhere((s) => s.title == 'Nachtpuls'); + await db.setCategories(song.id, ['Eigene Kiste'], byUser: true); + + await scanFolders(db, [fixtures], coverDir: coverDir); + + expect(await db.categoriesOf(song.id), ['Eigene Kiste']); + }); + }); + + group('Nachrüstung bestehender Bibliotheken (Schema 9)', () { + test('das Album wird als erste Kategorie ergänzt', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'a', + path: '/a.mp3', + title: 'A', + album: const Value('Schwarz'), + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCategories('a', ['Nightcore']); + + await db.ergaenzeAlbumKategorienFuerTest(); + + expect(await db.categoriesOf('a'), ['Schwarz', 'Nightcore']); + }); + + test('ein bereits als Kategorie vorhandenes Album rutscht nach vorn', + () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'a', + path: '/a.mp3', + title: 'A', + album: const Value('Schwarz'), + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCategories('a', ['Nightcore', 'Schwarz']); + + await db.ergaenzeAlbumKategorienFuerTest(); + + expect(await db.categoriesOf('a'), ['Schwarz', 'Nightcore']); + }); + + test('von Hand gepflegte Kategorien bleiben unangetastet', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'a', + path: '/a.mp3', + title: 'A', + album: const Value('Schwarz'), + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCategories('a', ['Eigene Kiste'], byUser: true); + + await db.ergaenzeAlbumKategorienFuerTest(); + + expect(await db.categoriesOf('a'), ['Eigene Kiste']); + }); + + test('ohne Album passiert nichts', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'a', + path: '/a.mp3', + title: 'A', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCategories('a', ['Nightcore']); + + await db.ergaenzeAlbumKategorienFuerTest(); + + expect(await db.categoriesOf('a'), ['Nightcore']); + }); + }); +} diff --git a/test/library/my_music_tabs_test.dart b/test/library/my_music_tabs_test.dart index 60c7030..e6fd7b3 100644 --- a/test/library/my_music_tabs_test.dart +++ b/test/library/my_music_tabs_test.dart @@ -48,7 +48,7 @@ Future _insert( void main() { setUp(() => SharedPreferences.setMockInitialValues({})); - testWidgets('Meine Musik hat die Unterreiter Songs, Künstler und Alben', + testWidgets('Meine Musik hat die Unterreiter Lieder, Kategorie und Künstler', (tester) async { final db = MeloDb(NativeDatabase.memory()); final lib = LibraryService(db); @@ -60,9 +60,11 @@ void main() { _wrap(db, lib, playlists, handler, const MyMusicScreen())); await tester.pumpAndSettle(); - expect(find.text('Songs'), findsOneWidget); + expect(find.text('Lieder'), findsOneWidget); + expect(find.text('Kategorie'), findsOneWidget); expect(find.text('Künstler'), findsOneWidget); - expect(find.text('Alben'), findsOneWidget); + // Der Alben-Reiter ist im Kategorie-Reiter aufgegangen. + expect(find.text('Alben'), findsNothing); handler.dispose(); await db.close(); @@ -87,14 +89,15 @@ void main() { // Unterreiter wechseln den Inhalt, sie öffnen keinen neuen Bildschirm — // Suchfeld und Reiter bleiben sichtbar. expect(find.text('Titel, Künstler und Alben suchen'), findsOneWidget); - expect(find.text('Songs'), findsOneWidget); + expect(find.text('Lieder'), findsOneWidget); expect(find.text('Shuffle-Wiedergabe'), findsNothing); handler.dispose(); await db.close(); }); - testWidgets('Unterreiter Alben zeigt die Albenliste', (tester) async { + testWidgets('Unterreiter Kategorie zeigt den Album-Titel als Kategorie', + (tester) async { final db = MeloDb(NativeDatabase.memory()); final lib = LibraryService(db); final playlists = PlaylistService(db); @@ -105,12 +108,37 @@ void main() { _wrap(db, lib, playlists, handler, const MyMusicScreen())); await tester.pumpAndSettle(); - await tester.tap(find.text('Alben')); + // Das Album ist die Kategorie (siehe kategorienAusTags) — der Titel + // taucht deshalb unter "Roses" auf. + await db.setCategories('1', ['Roses']); + await tester.tap(find.text('Kategorie')); await tester.pumpAndSettle(); expect(find.text('Roses'), findsOneWidget); + expect(find.text('1 Lieder'), findsOneWidget); expect(find.text('Titel, Künstler und Alben suchen'), findsOneWidget); - expect(find.text('Songs'), findsOneWidget); + expect(find.text('Lieder'), findsOneWidget); + + handler.dispose(); + await db.close(); + }); + + testWidgets('Titel ohne Kategorie sammeln sich unter "Ohne Kategorie"', + (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(db: db); + await _insert(db, id: '1', title: 'Song A'); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const MyMusicScreen())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Kategorie')); + await tester.pumpAndSettle(); + + expect(find.text('Ohne Kategorie'), findsOneWidget); handler.dispose(); await db.close(); diff --git a/test/library/song_grouping_test.dart b/test/library/song_grouping_test.dart index f8a1ff1..d8ee700 100644 --- a/test/library/song_grouping_test.dart +++ b/test/library/song_grouping_test.dart @@ -67,49 +67,4 @@ void main() { expect(groupByArtist(const []), isEmpty); }); }); - - group('groupByAlbum', () { - test('gruppiert mehrere Alben getrennt', () { - final songs = [ - _song(id: '1', title: 'A', album: 'Best Of'), - _song(id: '2', title: 'B', album: 'Anthology'), - ]; - - final grouped = groupByAlbum(songs); - - expect(grouped.keys.toList(), ['Anthology', 'Best Of']); - }); - - test('gruppiert Songs ohne Album unter "Unbekanntes Album"', () { - final songs = [_song(id: '1', title: 'A', album: null)]; - - final grouped = groupByAlbum(songs); - - expect(grouped.keys, [unbekanntesAlbum]); - }); - - test('leere Liste ergibt leere Map', () { - expect(groupByAlbum(const []), isEmpty); - }); - }); - - group('albumArtistLabel', () { - test('zeigt gemeinsamen Künstler, wenn alle Songs von ihm stammen', () { - final songs = [ - _song(id: '1', title: 'A', artist: 'Alice', album: 'X'), - _song(id: '2', title: 'B', artist: 'Alice', album: 'X'), - ]; - - expect(albumArtistLabel(songs), 'Alice'); - }); - - test('zeigt "Verschiedene Interpreten" bei unterschiedlichen Künstlern', () { - final songs = [ - _song(id: '1', title: 'A', artist: 'Alice', album: 'X'), - _song(id: '2', title: 'B', artist: 'Bob', album: 'X'), - ]; - - expect(albumArtistLabel(songs), 'Verschiedene Interpreten'); - }); - }); -} +} \ No newline at end of file