jellyfin_library_service.dart 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  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<JellyfinTrack>> albumTracks(String albumId) {
  117. return gateway.albumTracks(_require(), albumId);
  118. }
  119. /// Builds the direct/transcode URL. The token is never added here; the
  120. /// native player sends the Authorization header.
  121. ///
  122. /// Direct quality keeps the source file. A bitrate quality sends MP3 as the
  123. /// only container/codec and caps the stream, which asks Jellyfin to transcode
  124. /// high-resolution audio that a car head unit may play back incorrectly.
  125. Uri streamUrl(
  126. JellyfinTrack track, {
  127. JellyfinAudioQuality? quality,
  128. int? maxStreamingBitrate,
  129. }) {
  130. final connection = _require();
  131. final selected =
  132. quality ?? audioQualityProvider?.call() ?? JellyfinAudioQuality.direct;
  133. final bitrate = maxStreamingBitrate ?? selected.maxStreamingBitrate;
  134. return connection.baseUrl
  135. .resolve('Audio/${track.id}/universal')
  136. .replace(
  137. queryParameters: {
  138. 'UserId': connection.userId,
  139. 'DeviceId': connection.deviceId,
  140. 'MediaSourceId': track.mediaSourceId,
  141. 'TranscodingContainer': 'mp3',
  142. 'TranscodingProtocol': 'http',
  143. 'EnableRedirection': 'true',
  144. if (bitrate == null) ...{
  145. 'Container': 'mp3,m4a,aac,flac,ogg,opus,wav',
  146. 'AudioCodec': 'mp3,aac,flac,opus,vorbis',
  147. } else ...{
  148. 'MaxStreamingBitrate': '$bitrate',
  149. 'Container': 'mp3',
  150. 'AudioCodec': 'mp3',
  151. },
  152. },
  153. );
  154. }
  155. Uri? imageUrl(String albumId, String? tag, {int maxHeight = 400}) {
  156. if (albumId.isEmpty) return null;
  157. final connection = connectionProvider();
  158. if (connection == null) return null;
  159. return connection.baseUrl
  160. .resolve('Items/$albumId/Images/Primary')
  161. .replace(
  162. queryParameters: {
  163. 'maxHeight': '$maxHeight',
  164. if (tag != null && tag.isNotEmpty) 'tag': tag,
  165. },
  166. );
  167. }
  168. /// Headers for `Image.network`; the token stays in the controller/service.
  169. Map<String, String> get imageHeaders {
  170. final connection = connectionProvider();
  171. if (connection == null) return const {};
  172. return Map<String, String>.unmodifiable({
  173. 'Authorization': connection.authorizationHeader,
  174. });
  175. }
  176. Future<Uint8List?> albumArtwork(
  177. String albumId, {
  178. String? tag,
  179. int maxHeight = 400,
  180. }) {
  181. final connection = connectionProvider();
  182. if (connection == null) return Future.value();
  183. return gateway.albumArtwork(
  184. connection,
  185. albumId,
  186. tag: tag,
  187. maxHeight: maxHeight,
  188. );
  189. }
  190. /// Resolves the album's tracks and builds everything the native service
  191. /// needs. Returns null when the connection or playable tracks are missing.
  192. Future<JellyfinPlaybackQueue?> buildQueue(
  193. JellyfinAlbum album, {
  194. String? playSessionId,
  195. }) async {
  196. final connection = connectionProvider();
  197. if (connection == null) return null;
  198. final tracks = await albumTracks(album.id);
  199. final items = <JellyfinQueueItem>[];
  200. for (final track in tracks) {
  201. if (track.mediaSourceId.isEmpty) continue;
  202. items.add(
  203. JellyfinQueueItem(
  204. trackId: track.id,
  205. mediaSourceId: track.mediaSourceId,
  206. url: streamUrl(track).toString(),
  207. title: track.name,
  208. artist: track.artist,
  209. album: track.album.isEmpty ? album.name : track.album,
  210. durationMs: track.duration.inMilliseconds,
  211. index: items.length,
  212. ),
  213. );
  214. }
  215. if (items.isEmpty) return null;
  216. final artwork = await albumArtwork(album.id, tag: album.imageTag);
  217. return JellyfinPlaybackQueue(
  218. baseUrl: connection.baseUrl,
  219. authorization: connection.authorizationHeader,
  220. accessToken: connection.accessToken,
  221. userId: connection.userId,
  222. deviceId: connection.deviceId,
  223. playSessionId: playSessionId ?? generateJellyfinPlaySessionId(),
  224. albumId: album.id,
  225. albumName: album.name,
  226. artistName: album.artist,
  227. items: items,
  228. artwork: artwork,
  229. );
  230. }
  231. }