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.audioQualityProvider, this.catalog = const JellyfinGenreCatalog(), this.genreCacheTtl = const Duration(minutes: 5), }); final JellyfinConnection? Function() connectionProvider; final JellyfinLibraryGateway gateway; final JellyfinAudioQuality Function()? audioQualityProvider; final JellyfinGenreCatalog catalog; final Duration genreCacheTtl; List? _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> latestPlayed({int limit = 5}) async { final connection = _require(); final tracks = await gateway.playedTracks(connection, limit: 50); final seen = {}; final albums = []; 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> 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> rawGenres() async { final connection = _require(); final libraries = await gateway.musicLibraryIds(connection); final names = []; final seen = {}; 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> 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> randomAlbums({ required Iterable 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> randomAlbumsForGenres( List rawGenres, { int limit = 5, }) { if (rawGenres.isEmpty) return Future.value(const []); return gateway.randomAlbums(_require(), genres: rawGenres, limit: limit); } Future> favoriteAlbums() { return gateway.favoriteAlbums(_require()); } Future isFavorite(String albumId) { return gateway.isAlbumFavorite(_require(), albumId); } Future setFavorite(String albumId, bool favorite) { return gateway.setAlbumFavorite(_require(), albumId, favorite: favorite); } Future> 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. /// /// Direct quality keeps the source file. A bitrate quality sends MP3 as the /// only container/codec and caps the stream, which asks Jellyfin to transcode /// high-resolution audio that a car head unit may play back incorrectly. Uri streamUrl( JellyfinTrack track, { JellyfinAudioQuality? quality, int? maxStreamingBitrate, }) { final connection = _require(); final selected = quality ?? audioQualityProvider?.call() ?? JellyfinAudioQuality.direct; final bitrate = maxStreamingBitrate ?? selected.maxStreamingBitrate; return connection.baseUrl .resolve('Audio/${track.id}/universal') .replace( queryParameters: { 'UserId': connection.userId, 'DeviceId': connection.deviceId, 'MediaSourceId': track.mediaSourceId, 'TranscodingContainer': 'mp3', 'TranscodingProtocol': 'http', 'EnableRedirection': 'true', if (bitrate == null) ...{ 'Container': 'mp3,m4a,aac,flac,ogg,opus,wav', 'AudioCodec': 'mp3,aac,flac,opus,vorbis', } else ...{ 'MaxStreamingBitrate': '$bitrate', 'Container': 'mp3', 'AudioCodec': 'mp3', }, }, ); } 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 get imageHeaders { final connection = connectionProvider(); if (connection == null) return const {}; return Map.unmodifiable({ 'Authorization': connection.authorizationHeader, }); } Future 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 buildQueue( JellyfinAlbum album, { String? playSessionId, }) async { final connection = connectionProvider(); if (connection == null) return null; final tracks = await albumTracks(album.id); final items = []; 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, ); } }