| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- import 'dart:math';
- import 'dart:typed_data';
- import '../models/jellyfin_library.dart';
- import '../models/jellyfin_models.dart';
- import '../models/jellyfin_playback.dart';
- import 'jellyfin_genre_catalog.dart';
- import 'jellyfin_service.dart';
- /// A random hex id for one album playback session. It is reported to Jellyfin
- /// with playback progress; it carries no user information.
- String generateJellyfinPlaySessionId() {
- final random = Random.secure();
- return List.generate(
- 16,
- (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
- ).join();
- }
- /// Typed access to the Jellyfin music library. The access token stays inside
- /// the [JellyfinConnection]; widgets receive image URLs/headers, never the
- /// connection itself.
- class JellyfinLibraryService {
- JellyfinLibraryService({
- required this.connectionProvider,
- required this.gateway,
- this.catalog = const JellyfinGenreCatalog(),
- this.genreCacheTtl = const Duration(minutes: 5),
- });
- final JellyfinConnection? Function() connectionProvider;
- final JellyfinLibraryGateway gateway;
- final JellyfinGenreCatalog catalog;
- final Duration genreCacheTtl;
- List<JellyfinGenreFamily>? _cachedFamilies;
- DateTime? _cachedFamiliesAt;
- JellyfinConnection? get connection => connectionProvider();
- bool get isConfigured => connectionProvider() != null;
- JellyfinConnection _require() {
- final connection = connectionProvider();
- if (connection == null) {
- throw JellyfinException('Pair Jellyfin first.');
- }
- return connection;
- }
- /// Latest 5 distinct albums from played Audio tracks. Jellyfin 12.2 updates
- /// track user data, not album user data, so the album query is not reliable.
- Future<List<JellyfinAlbum>> latestPlayed({int limit = 5}) async {
- final connection = _require();
- final tracks = await gateway.playedTracks(connection, limit: 50);
- final seen = <String>{};
- final albums = <JellyfinAlbum>[];
- for (final track in tracks) {
- if (track.albumId.isEmpty) continue;
- if (!seen.add(track.albumId)) continue;
- albums.add(JellyfinAlbum.fromTrack(track));
- if (albums.length >= limit) break;
- }
- return albums;
- }
- Future<List<JellyfinAlbum>> latestAdded({int limit = 5}) {
- return gateway.latestAddedAlbums(_require(), limit: limit);
- }
- /// Raw genre names across every music library the user can see. The server
- /// needs a `parentId` for /Genres, so each `music` view is queried.
- Future<List<String>> rawGenres() async {
- final connection = _require();
- final libraries = await gateway.musicLibraryIds(connection);
- final names = <String>[];
- final seen = <String>{};
- for (final libraryId in libraries) {
- final libraryGenres = await gateway.genreNames(connection, libraryId);
- for (final name in libraryGenres) {
- if (seen.add(name)) names.add(name);
- }
- }
- names.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
- return names;
- }
- /// Grouped families, cached briefly so the dashboard and picker do not refetch
- /// the genre list for every action.
- Future<List<JellyfinGenreFamily>> genreFamilies({bool force = false}) async {
- final cached = _cachedFamilies;
- final cachedAt = _cachedFamiliesAt;
- if (!force &&
- cached != null &&
- cachedAt != null &&
- DateTime.now().difference(cachedAt) < genreCacheTtl) {
- return cached;
- }
- final families = catalog.group(await rawGenres());
- _cachedFamilies = families;
- _cachedFamiliesAt = DateTime.now();
- return families;
- }
- void clearGenreCache() {
- _cachedFamilies = null;
- _cachedFamiliesAt = null;
- }
- Future<List<JellyfinAlbum>> randomAlbums({
- required Iterable<JellyfinGenreSelection> selections,
- int limit = 5,
- }) async {
- final families = await genreFamilies();
- final raw = catalog.rawGenresFor(
- families: families,
- selectedIds: selections.map((selection) => selection.id),
- );
- return randomAlbumsForGenres(raw, limit: limit);
- }
- Future<List<JellyfinAlbum>> randomAlbumsForGenres(
- List<String> rawGenres, {
- int limit = 5,
- }) {
- if (rawGenres.isEmpty) return Future.value(const []);
- return gateway.randomAlbums(_require(), genres: rawGenres, limit: limit);
- }
- Future<List<JellyfinTrack>> albumTracks(String albumId) {
- return gateway.albumTracks(_require(), albumId);
- }
- /// Builds the direct/transcode URL. The token is never added here; the
- /// native player sends the Authorization header.
- Uri streamUrl(JellyfinTrack track, {int maxStreamingBitrate = 1500000}) {
- final connection = _require();
- return connection.baseUrl
- .resolve('Audio/${track.id}/universal')
- .replace(
- queryParameters: {
- 'UserId': connection.userId,
- 'DeviceId': connection.deviceId,
- 'MediaSourceId': track.mediaSourceId,
- 'MaxStreamingBitrate': '$maxStreamingBitrate',
- 'Container': 'mp3,m4a,aac,flac,ogg,opus,wav',
- 'TranscodingContainer': 'mp3',
- 'TranscodingProtocol': 'http',
- 'AudioCodec': 'mp3,aac,flac,opus,vorbis',
- 'EnableRedirection': 'true',
- },
- );
- }
- Uri? imageUrl(String albumId, String? tag, {int maxHeight = 400}) {
- if (albumId.isEmpty) return null;
- final connection = connectionProvider();
- if (connection == null) return null;
- return connection.baseUrl
- .resolve('Items/$albumId/Images/Primary')
- .replace(
- queryParameters: {
- 'maxHeight': '$maxHeight',
- if (tag != null && tag.isNotEmpty) 'tag': tag,
- },
- );
- }
- /// Headers for `Image.network`; the token stays in the controller/service.
- Map<String, String> get imageHeaders {
- final connection = connectionProvider();
- if (connection == null) return const {};
- return Map<String, String>.unmodifiable({
- 'Authorization': connection.authorizationHeader,
- });
- }
- Future<Uint8List?> albumArtwork(
- String albumId, {
- String? tag,
- int maxHeight = 400,
- }) {
- final connection = connectionProvider();
- if (connection == null) return Future.value();
- return gateway.albumArtwork(
- connection,
- albumId,
- tag: tag,
- maxHeight: maxHeight,
- );
- }
- /// Resolves the album's tracks and builds everything the native service
- /// needs. Returns null when the connection or playable tracks are missing.
- Future<JellyfinPlaybackQueue?> buildQueue(
- JellyfinAlbum album, {
- String? playSessionId,
- }) async {
- final connection = connectionProvider();
- if (connection == null) return null;
- final tracks = await albumTracks(album.id);
- final items = <JellyfinQueueItem>[];
- for (final track in tracks) {
- if (track.mediaSourceId.isEmpty) continue;
- items.add(
- JellyfinQueueItem(
- trackId: track.id,
- mediaSourceId: track.mediaSourceId,
- url: streamUrl(track).toString(),
- title: track.name,
- artist: track.artist,
- album: track.album.isEmpty ? album.name : track.album,
- durationMs: track.duration.inMilliseconds,
- index: items.length,
- ),
- );
- }
- if (items.isEmpty) return null;
- final artwork = await albumArtwork(album.id, tag: album.imageTag);
- return JellyfinPlaybackQueue(
- baseUrl: connection.baseUrl,
- authorization: connection.authorizationHeader,
- accessToken: connection.accessToken,
- userId: connection.userId,
- deviceId: connection.deviceId,
- playSessionId: playSessionId ?? generateJellyfinPlaySessionId(),
- albumId: album.id,
- albumName: album.name,
- artistName: album.artist,
- items: items,
- artwork: artwork,
- );
- }
- }
|