jellyfin_library_service.dart 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import 'dart:math';
  2. import 'dart:typed_data';
  3. import '../models/jellyfin_library.dart';
  4. import '../models/jellyfin_models.dart';
  5. import '../models/jellyfin_playback.dart';
  6. import 'jellyfin_genre_catalog.dart';
  7. import 'jellyfin_service.dart';
  8. /// A random hex id for one album playback session. It is reported to Jellyfin
  9. /// with playback progress; it carries no user information.
  10. String generateJellyfinPlaySessionId() {
  11. final random = Random.secure();
  12. return List.generate(
  13. 16,
  14. (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
  15. ).join();
  16. }
  17. /// Typed access to the Jellyfin music library. The access token stays inside
  18. /// the [JellyfinConnection]; widgets receive image URLs/headers, never the
  19. /// connection itself.
  20. class JellyfinLibraryService {
  21. JellyfinLibraryService({
  22. required this.connectionProvider,
  23. required this.gateway,
  24. this.audioQualityProvider,
  25. this.catalog = const JellyfinGenreCatalog(),
  26. this.genreCacheTtl = const Duration(minutes: 5),
  27. });
  28. final JellyfinConnection? Function() connectionProvider;
  29. final JellyfinLibraryGateway gateway;
  30. final JellyfinAudioQuality Function()? audioQualityProvider;
  31. final JellyfinGenreCatalog catalog;
  32. final Duration genreCacheTtl;
  33. List<JellyfinGenreFamily>? _cachedFamilies;
  34. DateTime? _cachedFamiliesAt;
  35. JellyfinConnection? get connection => connectionProvider();
  36. bool get isConfigured => connectionProvider() != null;
  37. JellyfinConnection _require() {
  38. final connection = connectionProvider();
  39. if (connection == null) {
  40. throw JellyfinException('Pair Jellyfin first.');
  41. }
  42. return connection;
  43. }
  44. /// Latest 5 distinct albums from played Audio tracks. Jellyfin 12.2 updates
  45. /// track user data, not album user data, so the album query is not reliable.
  46. Future<List<JellyfinAlbum>> latestPlayed({int limit = 5}) async {
  47. final connection = _require();
  48. final tracks = await gateway.playedTracks(connection, limit: 50);
  49. final seen = <String>{};
  50. final albums = <JellyfinAlbum>[];
  51. for (final track in tracks) {
  52. if (track.albumId.isEmpty) continue;
  53. if (!seen.add(track.albumId)) continue;
  54. albums.add(JellyfinAlbum.fromTrack(track));
  55. if (albums.length >= limit) break;
  56. }
  57. return albums;
  58. }
  59. Future<List<JellyfinAlbum>> latestAdded({int limit = 5}) {
  60. return gateway.latestAddedAlbums(_require(), limit: limit);
  61. }
  62. /// Raw genre names across every music library the user can see. The server
  63. /// needs a `parentId` for /Genres, so each `music` view is queried.
  64. Future<List<String>> rawGenres() async {
  65. final connection = _require();
  66. final libraries = await gateway.musicLibraryIds(connection);
  67. final names = <String>[];
  68. final seen = <String>{};
  69. for (final libraryId in libraries) {
  70. final libraryGenres = await gateway.genreNames(connection, libraryId);
  71. for (final name in libraryGenres) {
  72. if (seen.add(name)) names.add(name);
  73. }
  74. }
  75. names.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
  76. return names;
  77. }
  78. /// Grouped families, cached briefly so the dashboard and picker do not refetch
  79. /// the genre list for every action.
  80. Future<List<JellyfinGenreFamily>> genreFamilies({bool force = false}) async {
  81. final cached = _cachedFamilies;
  82. final cachedAt = _cachedFamiliesAt;
  83. if (!force &&
  84. cached != null &&
  85. cachedAt != null &&
  86. DateTime.now().difference(cachedAt) < genreCacheTtl) {
  87. return cached;
  88. }
  89. final families = catalog.group(await rawGenres());
  90. _cachedFamilies = families;
  91. _cachedFamiliesAt = DateTime.now();
  92. return families;
  93. }
  94. void clearGenreCache() {
  95. _cachedFamilies = null;
  96. _cachedFamiliesAt = null;
  97. }
  98. Future<List<JellyfinAlbum>> randomAlbums({
  99. required Iterable<JellyfinGenreSelection> selections,
  100. int limit = 5,
  101. }) async {
  102. final families = await genreFamilies();
  103. final raw = catalog.rawGenresFor(
  104. families: families,
  105. selectedIds: selections.map((selection) => selection.id),
  106. );
  107. return randomAlbumsForGenres(raw, limit: limit);
  108. }
  109. Future<List<JellyfinAlbum>> randomAlbumsForGenres(
  110. List<String> rawGenres, {
  111. int limit = 5,
  112. }) {
  113. if (rawGenres.isEmpty) return Future.value(const []);
  114. return gateway.randomAlbums(_require(), genres: rawGenres, limit: limit);
  115. }
  116. Future<List<JellyfinAlbum>> favoriteAlbums() {
  117. return gateway.favoriteAlbums(_require());
  118. }
  119. Future<bool> isFavorite(String albumId) {
  120. return gateway.isAlbumFavorite(_require(), albumId);
  121. }
  122. Future<void> setFavorite(String albumId, bool favorite) {
  123. return gateway.setAlbumFavorite(_require(), albumId, favorite: favorite);
  124. }
  125. Future<List<JellyfinTrack>> albumTracks(String albumId) {
  126. return gateway.albumTracks(_require(), albumId);
  127. }
  128. /// Builds the direct/transcode URL. The token is never added here; the
  129. /// native player sends the Authorization header.
  130. ///
  131. /// Direct quality keeps the source file. A bitrate quality sends MP3 as the
  132. /// only container/codec and caps the stream, which asks Jellyfin to transcode
  133. /// high-resolution audio that a car head unit may play back incorrectly.
  134. Uri streamUrl(
  135. JellyfinTrack track, {
  136. JellyfinAudioQuality? quality,
  137. int? maxStreamingBitrate,
  138. }) {
  139. final connection = _require();
  140. final selected =
  141. quality ?? audioQualityProvider?.call() ?? JellyfinAudioQuality.direct;
  142. final bitrate = maxStreamingBitrate ?? selected.maxStreamingBitrate;
  143. return connection.baseUrl
  144. .resolve('Audio/${track.id}/universal')
  145. .replace(
  146. queryParameters: {
  147. 'UserId': connection.userId,
  148. 'DeviceId': connection.deviceId,
  149. 'MediaSourceId': track.mediaSourceId,
  150. 'TranscodingContainer': 'mp3',
  151. 'TranscodingProtocol': 'http',
  152. 'EnableRedirection': 'true',
  153. if (bitrate == null) ...{
  154. 'Container': 'mp3,m4a,aac,flac,ogg,opus,wav',
  155. 'AudioCodec': 'mp3,aac,flac,opus,vorbis',
  156. } else ...{
  157. 'MaxStreamingBitrate': '$bitrate',
  158. 'Container': 'mp3',
  159. 'AudioCodec': 'mp3',
  160. },
  161. },
  162. );
  163. }
  164. Uri? imageUrl(String albumId, String? tag, {int maxHeight = 400}) {
  165. if (albumId.isEmpty) return null;
  166. final connection = connectionProvider();
  167. if (connection == null) return null;
  168. return connection.baseUrl
  169. .resolve('Items/$albumId/Images/Primary')
  170. .replace(
  171. queryParameters: {
  172. 'maxHeight': '$maxHeight',
  173. if (tag != null && tag.isNotEmpty) 'tag': tag,
  174. },
  175. );
  176. }
  177. /// Headers for `Image.network`; the token stays in the controller/service.
  178. Map<String, String> get imageHeaders {
  179. final connection = connectionProvider();
  180. if (connection == null) return const {};
  181. return Map<String, String>.unmodifiable({
  182. 'Authorization': connection.authorizationHeader,
  183. });
  184. }
  185. Future<Uint8List?> albumArtwork(
  186. String albumId, {
  187. String? tag,
  188. int maxHeight = 400,
  189. }) {
  190. final connection = connectionProvider();
  191. if (connection == null) return Future.value();
  192. return gateway.albumArtwork(
  193. connection,
  194. albumId,
  195. tag: tag,
  196. maxHeight: maxHeight,
  197. );
  198. }
  199. /// Resolves the album's tracks and builds everything the native service
  200. /// needs. Returns null when the connection or playable tracks are missing.
  201. Future<JellyfinPlaybackQueue?> buildQueue(
  202. JellyfinAlbum album, {
  203. String? playSessionId,
  204. }) async {
  205. final connection = connectionProvider();
  206. if (connection == null) return null;
  207. final tracks = await albumTracks(album.id);
  208. final items = <JellyfinQueueItem>[];
  209. for (final track in tracks) {
  210. if (track.mediaSourceId.isEmpty) continue;
  211. items.add(
  212. JellyfinQueueItem(
  213. trackId: track.id,
  214. mediaSourceId: track.mediaSourceId,
  215. url: streamUrl(track).toString(),
  216. title: track.name,
  217. artist: track.artist,
  218. album: track.album.isEmpty ? album.name : track.album,
  219. durationMs: track.duration.inMilliseconds,
  220. index: items.length,
  221. ),
  222. );
  223. }
  224. if (items.isEmpty) return null;
  225. final artwork = await albumArtwork(album.id, tag: album.imageTag);
  226. return JellyfinPlaybackQueue(
  227. baseUrl: connection.baseUrl,
  228. authorization: connection.authorizationHeader,
  229. accessToken: connection.accessToken,
  230. userId: connection.userId,
  231. deviceId: connection.deviceId,
  232. playSessionId: playSessionId ?? generateJellyfinPlaySessionId(),
  233. albumId: album.id,
  234. albumName: album.name,
  235. artistName: album.artist,
  236. items: items,
  237. artwork: artwork,
  238. );
  239. }
  240. }