jellyfin_genre_catalog.dart 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import '../models/jellyfin_library.dart';
  2. /// A raw genre plus the canonical family it belongs to.
  3. class JellyfinGenreMatch {
  4. const JellyfinGenreMatch({required this.id, required this.label});
  5. final String id;
  6. final String label;
  7. }
  8. /// Built-in grouping for Jellyfin genre tags. The rules intentionally prefer
  9. /// the head noun ("Folk Metal" -> Metal, "Blues Rock" -> Rock) and then a
  10. /// small alias table for hybrids and Cyrillic spellings. Anything unmatched
  11. /// stays its own family so no library tag is lost.
  12. class JellyfinGenreCatalog {
  13. const JellyfinGenreCatalog();
  14. static const _labels = <String, String>{
  15. 'metal': 'Metal',
  16. 'rock': 'Rock',
  17. 'jazz': 'Jazz',
  18. 'pop': 'Pop',
  19. 'folk': 'Folk',
  20. 'blues': 'Blues',
  21. 'soul': 'Soul',
  22. 'funk': 'Funk',
  23. 'rnb': 'R&B',
  24. 'country': 'Country',
  25. 'classical': 'Classical',
  26. 'soundtrack': 'Soundtrack',
  27. 'electronic': 'Electronic',
  28. 'ambient': 'Ambient',
  29. 'punk': 'Punk',
  30. 'latin': 'Latin',
  31. 'acoustic': 'Acoustic',
  32. 'avant-garde': 'Avant-Garde',
  33. 'gothic': 'Gothic',
  34. 'pagan': 'Pagan',
  35. 'singer-songwriter': 'Singer-Songwriter',
  36. 'comedy': 'Comedy',
  37. };
  38. static const _order = <String>[
  39. 'metal',
  40. 'rock',
  41. 'jazz',
  42. 'pop',
  43. 'folk',
  44. 'blues',
  45. 'soul',
  46. 'funk',
  47. 'rnb',
  48. 'country',
  49. 'classical',
  50. 'soundtrack',
  51. 'electronic',
  52. 'ambient',
  53. 'punk',
  54. 'latin',
  55. 'acoustic',
  56. 'avant-garde',
  57. 'gothic',
  58. 'pagan',
  59. 'singer-songwriter',
  60. 'comedy',
  61. ];
  62. /// Whole-name aliases that cannot be inferred from the last token.
  63. static const _aliases = <String, String>{
  64. 'black': 'metal',
  65. 'death': 'metal',
  66. 'viking': 'metal',
  67. 'doom': 'metal',
  68. 'alternative': 'rock',
  69. 'grunge': 'rock',
  70. 'progressive': 'rock',
  71. 'rockabilly': 'rock',
  72. 'rock & roll': 'rock',
  73. 'rock singer-songwriter': 'rock',
  74. 'neofolk': 'folk',
  75. 'medieval': 'folk',
  76. 'composer': 'classical',
  77. 'conductor': 'classical',
  78. 'film scores': 'soundtrack',
  79. 'films/games': 'soundtrack',
  80. 'dungeon synth': 'ambient',
  81. 'new age': 'ambient',
  82. 'dance': 'electronic',
  83. 'electro': 'electronic',
  84. 'fusion': 'jazz',
  85. 'swing': 'jazz',
  86. 'hardcore': 'punk',
  87. 'metalcore': 'metal',
  88. 'deathcore': 'metal',
  89. 'black metal': 'metal',
  90. 'death metal': 'metal',
  91. 'heavy metal': 'metal',
  92. 'soul & funk': 'soul',
  93. 'r&b': 'rnb',
  94. 'latin music': 'latin',
  95. 'rock pop': 'pop',
  96. 'acoustic': 'acoustic',
  97. 'avant-garde': 'avant-garde',
  98. 'gothic': 'gothic',
  99. 'pagan': 'pagan',
  100. 'singer & songwriter': 'singer-songwriter',
  101. 'comedy': 'comedy',
  102. 'neoclassical': 'classical',
  103. 'метъл': 'metal',
  104. 'рок': 'rock',
  105. 'хард рок': 'rock',
  106. 'джаз': 'jazz',
  107. 'поп': 'pop',
  108. 'фолк': 'folk',
  109. 'блус': 'blues',
  110. 'кънтри': 'country',
  111. 'класическа': 'classical',
  112. 'електронна': 'electronic',
  113. 'ембиънт': 'ambient',
  114. 'пънк': 'punk',
  115. 'соул': 'soul',
  116. 'фънк': 'funk',
  117. 'латино': 'latin',
  118. };
  119. /// Head nouns. The last token wins, which handles every "X Metal",
  120. /// "X Rock", "X Jazz" and "X Folk" variant without enumerating them.
  121. static const _headNouns = <String, String>{
  122. 'metal': 'metal',
  123. 'метъл': 'metal',
  124. 'rock': 'rock',
  125. 'рок': 'rock',
  126. 'jazz': 'jazz',
  127. 'джаз': 'jazz',
  128. 'pop': 'pop',
  129. 'поп': 'pop',
  130. 'folk': 'folk',
  131. 'фолк': 'folk',
  132. 'blues': 'blues',
  133. 'блус': 'blues',
  134. 'soul': 'soul',
  135. 'соул': 'soul',
  136. 'funk': 'funk',
  137. 'фънк': 'funk',
  138. 'country': 'country',
  139. 'кънтри': 'country',
  140. 'classical': 'classical',
  141. 'electronic': 'electronic',
  142. 'ambient': 'ambient',
  143. 'ембиънт': 'ambient',
  144. 'punk': 'punk',
  145. 'пънк': 'punk',
  146. 'latin': 'latin',
  147. 'латино': 'latin',
  148. 'soundtrack': 'soundtrack',
  149. };
  150. List<JellyfinGenreFamily> group(Iterable<String> rawGenres) {
  151. final byFamily = <String, List<String>>{};
  152. final labels = <String, String>{};
  153. for (final raw in rawGenres) {
  154. final name = raw.trim();
  155. if (name.isEmpty) continue;
  156. final genreMatch = match(name);
  157. byFamily.putIfAbsent(genreMatch.id, () => []).add(name);
  158. labels[genreMatch.id] = genreMatch.label;
  159. }
  160. final families = <JellyfinGenreFamily>[];
  161. for (final id in _order) {
  162. final raws = byFamily[id];
  163. if (raws == null || raws.isEmpty) continue;
  164. families.add(
  165. JellyfinGenreFamily(
  166. id: id,
  167. label: labels[id] ?? _labels[id]!,
  168. rawGenres: List.unmodifiable(_sorted(raws)),
  169. isStandalone: false,
  170. ),
  171. );
  172. }
  173. final standalone =
  174. byFamily.keys
  175. .where((id) => !_order.contains(id))
  176. .toList(growable: false)
  177. ..sort(
  178. (a, b) => (labels[a] ?? a).toLowerCase().compareTo(
  179. (labels[b] ?? b).toLowerCase(),
  180. ),
  181. );
  182. for (final id in standalone) {
  183. families.add(
  184. JellyfinGenreFamily(
  185. id: id,
  186. label: labels[id] ?? id,
  187. rawGenres: List.unmodifiable(_sorted(byFamily[id]!)),
  188. isStandalone: true,
  189. ),
  190. );
  191. }
  192. return families;
  193. }
  194. JellyfinGenreMatch match(String raw) {
  195. final name = raw.trim();
  196. final normalized = _normalize(name);
  197. final alias = _aliases[normalized];
  198. if (alias != null) {
  199. return JellyfinGenreMatch(id: alias, label: _labels[alias]!);
  200. }
  201. final tokens = normalized.split(RegExp(r'[\s\-/&]+'));
  202. for (var i = tokens.length - 1; i >= 0; i--) {
  203. final token = tokens[i];
  204. if (token.isEmpty) continue;
  205. final head = _headNouns[token];
  206. if (head != null) {
  207. return JellyfinGenreMatch(id: head, label: _labels[head]!);
  208. }
  209. // Only the last meaningful token decides the head noun; stop after it.
  210. break;
  211. }
  212. for (final entry in _headNouns.entries) {
  213. if (normalized.endsWith(entry.key)) {
  214. return JellyfinGenreMatch(
  215. id: entry.value,
  216. label: _labels[entry.value]!,
  217. );
  218. }
  219. }
  220. return JellyfinGenreMatch(
  221. id: 'raw:$normalized',
  222. label: name.isEmpty ? raw : name,
  223. );
  224. }
  225. /// Expands saved family selections to the raw server tags the query needs.
  226. List<String> rawGenresFor({
  227. required List<JellyfinGenreFamily> families,
  228. required Iterable<String> selectedIds,
  229. }) {
  230. final selected = selectedIds.toSet();
  231. final result = <String>[];
  232. for (final family in families) {
  233. if (!selected.contains(family.id)) continue;
  234. for (final raw in family.rawGenres) {
  235. if (!result.contains(raw)) result.add(raw);
  236. }
  237. }
  238. return result;
  239. }
  240. /// Drops selections whose family disappeared from the server and refreshes
  241. /// their labels from the current catalog.
  242. List<JellyfinGenreSelection> resolveSelections({
  243. required List<JellyfinGenreFamily> families,
  244. required Iterable<JellyfinGenreSelection> selections,
  245. }) {
  246. final byId = {for (final family in families) family.id: family};
  247. final resolved = <JellyfinGenreSelection>[];
  248. for (final selection in selections) {
  249. final family = byId[selection.id];
  250. if (family == null) continue;
  251. resolved.add(JellyfinGenreSelection(id: family.id, label: family.label));
  252. }
  253. return resolved;
  254. }
  255. JellyfinGenreFamily? familyById(
  256. List<JellyfinGenreFamily> families,
  257. String id,
  258. ) {
  259. for (final family in families) {
  260. if (family.id == id) return family;
  261. }
  262. return null;
  263. }
  264. static List<String> _sorted(List<String> values) {
  265. final copy = values.toList(growable: true)
  266. ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
  267. return copy;
  268. }
  269. static String _normalize(String value) {
  270. var text = value.trim().toLowerCase();
  271. text = text.replaceAll('ё', 'е');
  272. text = text.replaceAll(RegExp(r'\s+'), ' ');
  273. text = text.replaceAll(RegExp(r'[\[\]\(\)\{\}"“”„]'), '');
  274. text = text.replaceAll(RegExp(r'\s*/\s*'), '/');
  275. text = text.replaceAll(RegExp(r'\s*&\s*'), ' & ');
  276. text = text.replaceAll(RegExp(r'\s*-\s*'), '-');
  277. text = text.replaceAll(RegExp(r'[.!?]+$'), '');
  278. return text.trim();
  279. }
  280. }