jellyfin_library.dart 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import 'package:flutter/foundation.dart';
  2. /// Streaming quality for Jellyfin audio.
  3. ///
  4. /// [direct] sends the original file. Bitrate choices ask Jellyfin to transcode
  5. /// to MP3 at that cap, which is useful when a car head unit mishandles a
  6. /// high-resolution source.
  7. enum JellyfinAudioQuality {
  8. direct('Direct', null),
  9. kbps320('320 kbps', 320000),
  10. kbps256('256 kbps', 256000),
  11. kbps192('192 kbps', 192000),
  12. kbps128('128 kbps', 128000);
  13. const JellyfinAudioQuality(this.label, this.maxStreamingBitrate);
  14. final String label;
  15. final int? maxStreamingBitrate;
  16. bool get isDirect => maxStreamingBitrate == null;
  17. static JellyfinAudioQuality fromStorage(String? value) {
  18. return values.firstWhere(
  19. (quality) => quality.name == value,
  20. orElse: () => direct,
  21. );
  22. }
  23. }
  24. /// A saved Jellyfin selection in the genre picker.
  25. @immutable
  26. class JellyfinGenreSelection {
  27. const JellyfinGenreSelection({required this.id, required this.label});
  28. final String id;
  29. final String label;
  30. factory JellyfinGenreSelection.fromJson(Map<String, dynamic> json) {
  31. return JellyfinGenreSelection(
  32. id: (json['id'] as String?) ?? '',
  33. label: (json['label'] as String?) ?? '',
  34. );
  35. }
  36. Map<String, Object?> toJson() => {'id': id, 'label': label};
  37. @override
  38. bool operator ==(Object other) {
  39. return other is JellyfinGenreSelection &&
  40. other.id == id &&
  41. other.label == label;
  42. }
  43. @override
  44. int get hashCode => Object.hash(id, label);
  45. @override
  46. String toString() => label;
  47. }
  48. /// A canonical genre family such as Metal, Rock or Jazz. Raw genres keep their
  49. /// server spelling; [rawGenres] is what the random query expands to.
  50. @immutable
  51. class JellyfinGenreFamily {
  52. const JellyfinGenreFamily({
  53. required this.id,
  54. required this.label,
  55. required this.rawGenres,
  56. required this.isStandalone,
  57. });
  58. final String id;
  59. final String label;
  60. final List<String> rawGenres;
  61. final bool isStandalone;
  62. int get rawCount => rawGenres.length;
  63. @override
  64. bool operator ==(Object other) {
  65. return other is JellyfinGenreFamily && other.id == id;
  66. }
  67. @override
  68. int get hashCode => id.hashCode;
  69. }
  70. /// One raw genre on the server and the family it belongs to.
  71. @immutable
  72. class JellyfinGenre {
  73. const JellyfinGenre({
  74. required this.name,
  75. required this.familyId,
  76. required this.familyLabel,
  77. });
  78. final String name;
  79. final String familyId;
  80. final String familyLabel;
  81. }
  82. /// An album row in the Jellyfin dashboard.
  83. @immutable
  84. class JellyfinAlbum {
  85. const JellyfinAlbum({
  86. required this.id,
  87. required this.name,
  88. required this.artist,
  89. this.year,
  90. this.imageTag,
  91. this.childCount,
  92. this.dateCreated,
  93. this.lastPlayed,
  94. });
  95. final String id;
  96. final String name;
  97. final String artist;
  98. final int? year;
  99. final String? imageTag;
  100. final int? childCount;
  101. final DateTime? dateCreated;
  102. final DateTime? lastPlayed;
  103. bool get hasArtwork => (imageTag?.isNotEmpty ?? false);
  104. factory JellyfinAlbum.fromJson(Map<String, dynamic> json) {
  105. return JellyfinAlbum(
  106. id: _string(json['Id']),
  107. name: _string(json['Name']),
  108. artist: _albumArtist(json),
  109. year: _int(json['ProductionYear']),
  110. imageTag:
  111. _imageTag(json['ImageTags']) ??
  112. _stringOrNull(json['AlbumPrimaryImageTag']),
  113. childCount: _int(json['ChildCount']),
  114. dateCreated: _date(json['DateCreated']),
  115. lastPlayed: _lastPlayed(json['UserData']),
  116. );
  117. }
  118. /// Builds an album card from a played Audio item.
  119. factory JellyfinAlbum.fromTrack(JellyfinTrack track) {
  120. return JellyfinAlbum(
  121. id: track.albumId,
  122. name: track.album.isEmpty ? track.name : track.album,
  123. artist: track.artist,
  124. year: track.year,
  125. imageTag: track.albumImageTag,
  126. lastPlayed: track.lastPlayed,
  127. );
  128. }
  129. /// Builds an album card from a played Audio item. Jellyfin 12.2 updates the
  130. /// track's user data, not the album's, so the recently-played row is built
  131. /// from tracks and deduped by album id.
  132. factory JellyfinAlbum.fromTrackJson(Map<String, dynamic> json) {
  133. final albumId = _string(json['AlbumId']);
  134. return JellyfinAlbum(
  135. id: albumId.isNotEmpty ? albumId : _string(json['ParentId']),
  136. name: _stringOrNull(json['Album']) ?? _string(json['Name']),
  137. artist: _albumArtist(json),
  138. year: _int(json['ProductionYear']),
  139. imageTag:
  140. _stringOrNull(json['AlbumPrimaryImageTag']) ??
  141. _imageTag(json['ImageTags']),
  142. childCount: _int(json['ChildCount']),
  143. dateCreated: _date(json['DateCreated']),
  144. lastPlayed: _lastPlayed(json['UserData']),
  145. );
  146. }
  147. @override
  148. bool operator ==(Object other) {
  149. return other is JellyfinAlbum && other.id == id;
  150. }
  151. @override
  152. int get hashCode => id.hashCode;
  153. @override
  154. String toString() => name;
  155. }
  156. /// A playable audio track. [mediaSourceId] is what Jellyfin playback reporting
  157. /// and the stream endpoint expect; [duration] comes from `RunTimeTicks`.
  158. @immutable
  159. class JellyfinTrack {
  160. const JellyfinTrack({
  161. required this.id,
  162. required this.name,
  163. required this.albumId,
  164. required this.album,
  165. required this.artist,
  166. required this.indexNumber,
  167. required this.parentIndexNumber,
  168. required this.duration,
  169. required this.mediaSourceId,
  170. this.container,
  171. this.codec,
  172. this.genres = const [],
  173. this.lastPlayed,
  174. this.albumImageTag,
  175. this.year,
  176. });
  177. final String id;
  178. final String name;
  179. final String albumId;
  180. final String album;
  181. final String artist;
  182. final int? indexNumber;
  183. final int? parentIndexNumber;
  184. final Duration duration;
  185. final String mediaSourceId;
  186. final String? container;
  187. final String? codec;
  188. final List<String> genres;
  189. final DateTime? lastPlayed;
  190. final String? albumImageTag;
  191. final int? year;
  192. String get displayIndex {
  193. final track = indexNumber;
  194. if (track == null) return '';
  195. final disc = parentIndexNumber;
  196. if (disc != null && disc > 1) return '$disc.$track';
  197. return '$track';
  198. }
  199. factory JellyfinTrack.fromJson(Map<String, dynamic> json) {
  200. final sources = json['MediaSources'];
  201. final source = sources is List && sources.isNotEmpty ? sources.first : null;
  202. final sourceMap = source is Map ? source : null;
  203. final streams = sourceMap?['MediaStreams'];
  204. final stream = streams is List && streams.isNotEmpty ? streams.first : null;
  205. final streamMap = stream is Map ? stream : null;
  206. final ticks = _int(json['RunTimeTicks']) ?? 0;
  207. return JellyfinTrack(
  208. id: _string(json['Id']),
  209. name: _string(json['Name']),
  210. albumId:
  211. _stringOrNull(json['AlbumId']) ??
  212. _stringOrNull(json['ParentId']) ??
  213. '',
  214. album: _string(json['Album']),
  215. artist: _albumArtist(json),
  216. indexNumber: _int(json['IndexNumber']),
  217. parentIndexNumber: _int(json['ParentIndexNumber']),
  218. duration: Duration(microseconds: ticks ~/ 10),
  219. mediaSourceId: _string(sourceMap?['Id']),
  220. container: _stringOrNull(sourceMap?['Container']),
  221. codec: _stringOrNull(streamMap?['Codec']),
  222. genres: _stringList(json['Genres']),
  223. lastPlayed: _lastPlayed(json['UserData']),
  224. albumImageTag: _stringOrNull(json['AlbumPrimaryImageTag']),
  225. year: _int(json['ProductionYear']),
  226. );
  227. }
  228. @override
  229. bool operator ==(Object other) {
  230. return other is JellyfinTrack && other.id == id;
  231. }
  232. @override
  233. int get hashCode => id.hashCode;
  234. @override
  235. String toString() => name;
  236. }
  237. String _string(Object? value) => value is String ? value : '';
  238. String? _stringOrNull(Object? value) {
  239. if (value is! String) return null;
  240. final trimmed = value.trim();
  241. return trimmed.isEmpty ? null : trimmed;
  242. }
  243. int? _int(Object? value) {
  244. if (value is num) return value.toInt();
  245. if (value is String) return int.tryParse(value);
  246. return null;
  247. }
  248. DateTime? _date(Object? value) {
  249. if (value is! String) return null;
  250. return DateTime.tryParse(value);
  251. }
  252. DateTime? _lastPlayed(Object? userData) {
  253. if (userData is! Map) return null;
  254. return _date(userData['LastPlayedDate']);
  255. }
  256. String? _imageTag(Object? imageTags) {
  257. if (imageTags is! Map) return null;
  258. return _stringOrNull(imageTags['Primary']);
  259. }
  260. String _albumArtist(Map<String, dynamic> json) {
  261. final albumArtist = _stringOrNull(json['AlbumArtist']);
  262. if (albumArtist != null) return albumArtist;
  263. final artists = _stringList(json['Artists']);
  264. if (artists.isNotEmpty) return artists.join(', ');
  265. final albumArtists = json['AlbumArtists'];
  266. if (albumArtists is List) {
  267. final names = albumArtists
  268. .whereType<Map>()
  269. .map((entry) => _stringOrNull(entry['Name']))
  270. .whereType<String>()
  271. .toList(growable: false);
  272. if (names.isNotEmpty) return names.join(', ');
  273. }
  274. return '';
  275. }
  276. List<String> _stringList(Object? value) {
  277. if (value is! List) return const [];
  278. return value.whereType<String>().toList(growable: false);
  279. }