| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- import '../models/jellyfin_library.dart';
- /// A raw genre plus the canonical family it belongs to.
- class JellyfinGenreMatch {
- const JellyfinGenreMatch({required this.id, required this.label});
- final String id;
- final String label;
- }
- /// Built-in grouping for Jellyfin genre tags. The rules intentionally prefer
- /// the head noun ("Folk Metal" -> Metal, "Blues Rock" -> Rock) and then a
- /// small alias table for hybrids and Cyrillic spellings. Anything unmatched
- /// stays its own family so no library tag is lost.
- class JellyfinGenreCatalog {
- const JellyfinGenreCatalog();
- static const _labels = <String, String>{
- 'metal': 'Metal',
- 'rock': 'Rock',
- 'jazz': 'Jazz',
- 'pop': 'Pop',
- 'folk': 'Folk',
- 'blues': 'Blues',
- 'soul': 'Soul',
- 'funk': 'Funk',
- 'rnb': 'R&B',
- 'country': 'Country',
- 'classical': 'Classical',
- 'soundtrack': 'Soundtrack',
- 'electronic': 'Electronic',
- 'ambient': 'Ambient',
- 'punk': 'Punk',
- 'latin': 'Latin',
- 'acoustic': 'Acoustic',
- 'avant-garde': 'Avant-Garde',
- 'gothic': 'Gothic',
- 'pagan': 'Pagan',
- 'singer-songwriter': 'Singer-Songwriter',
- 'comedy': 'Comedy',
- };
- static const _order = <String>[
- 'metal',
- 'rock',
- 'jazz',
- 'pop',
- 'folk',
- 'blues',
- 'soul',
- 'funk',
- 'rnb',
- 'country',
- 'classical',
- 'soundtrack',
- 'electronic',
- 'ambient',
- 'punk',
- 'latin',
- 'acoustic',
- 'avant-garde',
- 'gothic',
- 'pagan',
- 'singer-songwriter',
- 'comedy',
- ];
- /// Whole-name aliases that cannot be inferred from the last token.
- static const _aliases = <String, String>{
- 'black': 'metal',
- 'death': 'metal',
- 'viking': 'metal',
- 'doom': 'metal',
- 'alternative': 'rock',
- 'grunge': 'rock',
- 'progressive': 'rock',
- 'rockabilly': 'rock',
- 'rock & roll': 'rock',
- 'rock singer-songwriter': 'rock',
- 'neofolk': 'folk',
- 'medieval': 'folk',
- 'composer': 'classical',
- 'conductor': 'classical',
- 'film scores': 'soundtrack',
- 'films/games': 'soundtrack',
- 'dungeon synth': 'ambient',
- 'new age': 'ambient',
- 'dance': 'electronic',
- 'electro': 'electronic',
- 'fusion': 'jazz',
- 'swing': 'jazz',
- 'hardcore': 'punk',
- 'metalcore': 'metal',
- 'deathcore': 'metal',
- 'black metal': 'metal',
- 'death metal': 'metal',
- 'heavy metal': 'metal',
- 'soul & funk': 'soul',
- 'r&b': 'rnb',
- 'latin music': 'latin',
- 'rock pop': 'pop',
- 'acoustic': 'acoustic',
- 'avant-garde': 'avant-garde',
- 'gothic': 'gothic',
- 'pagan': 'pagan',
- 'singer & songwriter': 'singer-songwriter',
- 'comedy': 'comedy',
- 'neoclassical': 'classical',
- 'метъл': 'metal',
- 'рок': 'rock',
- 'хард рок': 'rock',
- 'джаз': 'jazz',
- 'поп': 'pop',
- 'фолк': 'folk',
- 'блус': 'blues',
- 'кънтри': 'country',
- 'класическа': 'classical',
- 'електронна': 'electronic',
- 'ембиънт': 'ambient',
- 'пънк': 'punk',
- 'соул': 'soul',
- 'фънк': 'funk',
- 'латино': 'latin',
- };
- /// Head nouns. The last token wins, which handles every "X Metal",
- /// "X Rock", "X Jazz" and "X Folk" variant without enumerating them.
- static const _headNouns = <String, String>{
- 'metal': 'metal',
- 'метъл': 'metal',
- 'rock': 'rock',
- 'рок': 'rock',
- 'jazz': 'jazz',
- 'джаз': 'jazz',
- 'pop': 'pop',
- 'поп': 'pop',
- 'folk': 'folk',
- 'фолк': 'folk',
- 'blues': 'blues',
- 'блус': 'blues',
- 'soul': 'soul',
- 'соул': 'soul',
- 'funk': 'funk',
- 'фънк': 'funk',
- 'country': 'country',
- 'кънтри': 'country',
- 'classical': 'classical',
- 'electronic': 'electronic',
- 'ambient': 'ambient',
- 'ембиънт': 'ambient',
- 'punk': 'punk',
- 'пънк': 'punk',
- 'latin': 'latin',
- 'латино': 'latin',
- 'soundtrack': 'soundtrack',
- };
- List<JellyfinGenreFamily> group(Iterable<String> rawGenres) {
- final byFamily = <String, List<String>>{};
- final labels = <String, String>{};
- for (final raw in rawGenres) {
- final name = raw.trim();
- if (name.isEmpty) continue;
- final genreMatch = match(name);
- byFamily.putIfAbsent(genreMatch.id, () => []).add(name);
- labels[genreMatch.id] = genreMatch.label;
- }
- final families = <JellyfinGenreFamily>[];
- for (final id in _order) {
- final raws = byFamily[id];
- if (raws == null || raws.isEmpty) continue;
- families.add(
- JellyfinGenreFamily(
- id: id,
- label: labels[id] ?? _labels[id]!,
- rawGenres: List.unmodifiable(_sorted(raws)),
- isStandalone: false,
- ),
- );
- }
- final standalone =
- byFamily.keys
- .where((id) => !_order.contains(id))
- .toList(growable: false)
- ..sort(
- (a, b) => (labels[a] ?? a).toLowerCase().compareTo(
- (labels[b] ?? b).toLowerCase(),
- ),
- );
- for (final id in standalone) {
- families.add(
- JellyfinGenreFamily(
- id: id,
- label: labels[id] ?? id,
- rawGenres: List.unmodifiable(_sorted(byFamily[id]!)),
- isStandalone: true,
- ),
- );
- }
- return families;
- }
- JellyfinGenreMatch match(String raw) {
- final name = raw.trim();
- final normalized = _normalize(name);
- final alias = _aliases[normalized];
- if (alias != null) {
- return JellyfinGenreMatch(id: alias, label: _labels[alias]!);
- }
- final tokens = normalized.split(RegExp(r'[\s\-/&]+'));
- for (var i = tokens.length - 1; i >= 0; i--) {
- final token = tokens[i];
- if (token.isEmpty) continue;
- final head = _headNouns[token];
- if (head != null) {
- return JellyfinGenreMatch(id: head, label: _labels[head]!);
- }
- // Only the last meaningful token decides the head noun; stop after it.
- break;
- }
- for (final entry in _headNouns.entries) {
- if (normalized.endsWith(entry.key)) {
- return JellyfinGenreMatch(
- id: entry.value,
- label: _labels[entry.value]!,
- );
- }
- }
- return JellyfinGenreMatch(
- id: 'raw:$normalized',
- label: name.isEmpty ? raw : name,
- );
- }
- /// Expands saved family selections to the raw server tags the query needs.
- List<String> rawGenresFor({
- required List<JellyfinGenreFamily> families,
- required Iterable<String> selectedIds,
- }) {
- final selected = selectedIds.toSet();
- final result = <String>[];
- for (final family in families) {
- if (!selected.contains(family.id)) continue;
- for (final raw in family.rawGenres) {
- if (!result.contains(raw)) result.add(raw);
- }
- }
- return result;
- }
- /// Drops selections whose family disappeared from the server and refreshes
- /// their labels from the current catalog.
- List<JellyfinGenreSelection> resolveSelections({
- required List<JellyfinGenreFamily> families,
- required Iterable<JellyfinGenreSelection> selections,
- }) {
- final byId = {for (final family in families) family.id: family};
- final resolved = <JellyfinGenreSelection>[];
- for (final selection in selections) {
- final family = byId[selection.id];
- if (family == null) continue;
- resolved.add(JellyfinGenreSelection(id: family.id, label: family.label));
- }
- return resolved;
- }
- JellyfinGenreFamily? familyById(
- List<JellyfinGenreFamily> families,
- String id,
- ) {
- for (final family in families) {
- if (family.id == id) return family;
- }
- return null;
- }
- static List<String> _sorted(List<String> values) {
- final copy = values.toList(growable: true)
- ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
- return copy;
- }
- static String _normalize(String value) {
- var text = value.trim().toLowerCase();
- text = text.replaceAll('ё', 'е');
- text = text.replaceAll(RegExp(r'\s+'), ' ');
- text = text.replaceAll(RegExp(r'[\[\]\(\)\{\}"“”„]'), '');
- text = text.replaceAll(RegExp(r'\s*/\s*'), '/');
- text = text.replaceAll(RegExp(r'\s*&\s*'), ' & ');
- text = text.replaceAll(RegExp(r'\s*-\s*'), '-');
- text = text.replaceAll(RegExp(r'[.!?]+$'), '');
- return text.trim();
- }
- }
|