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 = { '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 = [ '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 = { '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 = { '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 group(Iterable rawGenres) { final byFamily = >{}; final labels = {}; 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 = []; 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 rawGenresFor({ required List families, required Iterable selectedIds, }) { final selected = selectedIds.toSet(); final result = []; 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 resolveSelections({ required List families, required Iterable selections, }) { final byId = {for (final family in families) family.id: family}; final resolved = []; 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 families, String id, ) { for (final family in families) { if (family.id == id) return family; } return null; } static List _sorted(List 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(); } }