jellyfin_library_service.dart 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  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.catalog = const JellyfinGenreCatalog(),
  25. this.genreCacheTtl = const Duration(minutes: 5),
  26. });
  27. final JellyfinConnection? Function() connectionProvider;
  28. final JellyfinLibraryGateway gateway;
  29. final JellyfinGenreCatalog catalog;
  30. final Duration genreCacheTtl;
  31. List<JellyfinGenreFamily>? _cachedFamilies;
  32. DateTime? _cachedFamiliesAt;
  33. JellyfinConnection? get connection => connectionProvider();
  34. bool get isConfigured => connectionProvider() != null;
  35. JellyfinConnection _require() {
  36. final connection = connectionProvider();
  37. if (connection == null) {
  38. throw JellyfinException('Pair Jellyfin first.');
  39. }
  40. return connection;
  41. }
  42. /// Latest 5 distinct albums from played Audio tracks. Jellyfin 12.2 updates
  43. /// track user data, not album user data, so the album query is not reliable.
  44. Future<List<JellyfinAlbum>> latestPlayed({int limit = 5}) async {
  45. final connection = _require();
  46. final tracks = await gateway.playedTracks(connection, limit: 50);
  47. final seen = <String>{};
  48. final albums = <JellyfinAlbum>[];
  49. for (final track in tracks) {
  50. if (track.albumId.isEmpty) continue;
  51. if (!seen.add(track.albumId)) continue;
  52. albums.add(JellyfinAlbum.fromTrack(track));
  53. if (albums.length >= limit) break;
  54. }
  55. return albums;
  56. }
  57. Future<List<JellyfinAlbum>> latestAdded({int limit = 5}) {
  58. return gateway.latestAddedAlbums(_require(), limit: limit);
  59. }
  60. /// Raw genre names across every music library the user can see. The server
  61. /// needs a `parentId` for /Genres, so each `music` view is queried.
  62. Future<List<String>> rawGenres() async {
  63. final connection = _require();
  64. final libraries = await gateway.musicLibraryIds(connection);
  65. final names = <String>[];
  66. final seen = <String>{};
  67. for (final libraryId in libraries) {
  68. final libraryGenres = await gateway.genreNames(connection, libraryId);
  69. for (final name in libraryGenres) {
  70. if (seen.add(name)) names.add(name);
  71. }
  72. }
  73. names.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
  74. return names;
  75. }
  76. /// Grouped families, cached briefly so the dashboard and picker do not refetch
  77. /// the genre list for every action.
  78. Future<List<JellyfinGenreFamily>> genreFamilies({bool force = false}) async {
  79. final cached = _cachedFamilies;
  80. final cachedAt = _cachedFamiliesAt;
  81. if (!force &&
  82. cached != null &&
  83. cachedAt != null &&
  84. DateTime.now().difference(cachedAt) < genreCacheTtl) {
  85. return cached;
  86. }
  87. final families = catalog.group(await rawGenres());
  88. _cachedFamilies = families;
  89. _cachedFamiliesAt = DateTime.now();
  90. return families;
  91. }
  92. void clearGenreCache() {
  93. _cachedFamilies = null;
  94. _cachedFamiliesAt = null;
  95. }
  96. Future<List<JellyfinAlbum>> randomAlbums({
  97. required Iterable<JellyfinGenreSelection> selections,
  98. int limit = 5,
  99. }) async {
  100. final families = await genreFamilies();
  101. final raw = catalog.rawGenresFor(
  102. families: families,
  103. selectedIds: selections.map((selection) => selection.id),
  104. );
  105. return randomAlbumsForGenres(raw, limit: limit);
  106. }
  107. Future<List<JellyfinAlbum>> randomAlbumsForGenres(
  108. List<String> rawGenres, {
  109. int limit = 5,
  110. }) {
  111. if (rawGenres.isEmpty) return Future.value(const []);
  112. return gateway.randomAlbums(_require(), genres: rawGenres, limit: limit);
  113. }
  114. Future<List<JellyfinTrack>> albumTracks(String albumId) {
  115. return gateway.albumTracks(_require(), albumId);
  116. }
  117. /// Builds the direct/transcode URL. The token is never added here; the
  118. /// native player sends the Authorization header.
  119. Uri streamUrl(JellyfinTrack track, {int maxStreamingBitrate = 1500000}) {
  120. final connection = _require();
  121. return connection.baseUrl
  122. .resolve('Audio/${track.id}/universal')
  123. .replace(
  124. queryParameters: {
  125. 'UserId': connection.userId,
  126. 'DeviceId': connection.deviceId,
  127. 'MediaSourceId': track.mediaSourceId,
  128. 'MaxStreamingBitrate': '$maxStreamingBitrate',
  129. 'Container': 'mp3,m4a,aac,flac,ogg,opus,wav',
  130. 'TranscodingContainer': 'mp3',
  131. 'TranscodingProtocol': 'http',
  132. 'AudioCodec': 'mp3,aac,flac,opus,vorbis',
  133. 'EnableRedirection': 'true',
  134. },
  135. );
  136. }
  137. Uri? imageUrl(String albumId, String? tag, {int maxHeight = 400}) {
  138. if (albumId.isEmpty) return null;
  139. final connection = connectionProvider();
  140. if (connection == null) return null;
  141. return connection.baseUrl
  142. .resolve('Items/$albumId/Images/Primary')
  143. .replace(
  144. queryParameters: {
  145. 'maxHeight': '$maxHeight',
  146. if (tag != null && tag.isNotEmpty) 'tag': tag,
  147. },
  148. );
  149. }
  150. /// Headers for `Image.network`; the token stays in the controller/service.
  151. Map<String, String> get imageHeaders {
  152. final connection = connectionProvider();
  153. if (connection == null) return const {};
  154. return Map<String, String>.unmodifiable({
  155. 'Authorization': connection.authorizationHeader,
  156. });
  157. }
  158. Future<Uint8List?> albumArtwork(
  159. String albumId, {
  160. String? tag,
  161. int maxHeight = 400,
  162. }) {
  163. final connection = connectionProvider();
  164. if (connection == null) return Future.value();
  165. return gateway.albumArtwork(
  166. connection,
  167. albumId,
  168. tag: tag,
  169. maxHeight: maxHeight,
  170. );
  171. }
  172. /// Resolves the album's tracks and builds everything the native service
  173. /// needs. Returns null when the connection or playable tracks are missing.
  174. Future<JellyfinPlaybackQueue?> buildQueue(
  175. JellyfinAlbum album, {
  176. String? playSessionId,
  177. }) async {
  178. final connection = connectionProvider();
  179. if (connection == null) return null;
  180. final tracks = await albumTracks(album.id);
  181. final items = <JellyfinQueueItem>[];
  182. for (final track in tracks) {
  183. if (track.mediaSourceId.isEmpty) continue;
  184. items.add(
  185. JellyfinQueueItem(
  186. trackId: track.id,
  187. mediaSourceId: track.mediaSourceId,
  188. url: streamUrl(track).toString(),
  189. title: track.name,
  190. artist: track.artist,
  191. album: track.album.isEmpty ? album.name : track.album,
  192. durationMs: track.duration.inMilliseconds,
  193. index: items.length,
  194. ),
  195. );
  196. }
  197. if (items.isEmpty) return null;
  198. final artwork = await albumArtwork(album.id, tag: album.imageTag);
  199. return JellyfinPlaybackQueue(
  200. baseUrl: connection.baseUrl,
  201. authorization: connection.authorizationHeader,
  202. accessToken: connection.accessToken,
  203. userId: connection.userId,
  204. deviceId: connection.deviceId,
  205. playSessionId: playSessionId ?? generateJellyfinPlaySessionId(),
  206. albumId: album.id,
  207. albumName: album.name,
  208. artistName: album.artist,
  209. items: items,
  210. artwork: artwork,
  211. );
  212. }
  213. }