import 'package:flutter/foundation.dart'; /// A saved Jellyfin selection in the genre picker. @immutable class JellyfinGenreSelection { const JellyfinGenreSelection({required this.id, required this.label}); final String id; final String label; factory JellyfinGenreSelection.fromJson(Map json) { return JellyfinGenreSelection( id: (json['id'] as String?) ?? '', label: (json['label'] as String?) ?? '', ); } Map toJson() => {'id': id, 'label': label}; @override bool operator ==(Object other) { return other is JellyfinGenreSelection && other.id == id && other.label == label; } @override int get hashCode => Object.hash(id, label); @override String toString() => label; } /// A canonical genre family such as Metal, Rock or Jazz. Raw genres keep their /// server spelling; [rawGenres] is what the random query expands to. @immutable class JellyfinGenreFamily { const JellyfinGenreFamily({ required this.id, required this.label, required this.rawGenres, required this.isStandalone, }); final String id; final String label; final List rawGenres; final bool isStandalone; int get rawCount => rawGenres.length; @override bool operator ==(Object other) { return other is JellyfinGenreFamily && other.id == id; } @override int get hashCode => id.hashCode; } /// One raw genre on the server and the family it belongs to. @immutable class JellyfinGenre { const JellyfinGenre({ required this.name, required this.familyId, required this.familyLabel, }); final String name; final String familyId; final String familyLabel; } /// An album row in the Jellyfin dashboard. @immutable class JellyfinAlbum { const JellyfinAlbum({ required this.id, required this.name, required this.artist, this.year, this.imageTag, this.childCount, this.dateCreated, this.lastPlayed, }); final String id; final String name; final String artist; final int? year; final String? imageTag; final int? childCount; final DateTime? dateCreated; final DateTime? lastPlayed; bool get hasArtwork => (imageTag?.isNotEmpty ?? false); factory JellyfinAlbum.fromJson(Map json) { return JellyfinAlbum( id: _string(json['Id']), name: _string(json['Name']), artist: _albumArtist(json), year: _int(json['ProductionYear']), imageTag: _imageTag(json['ImageTags']) ?? _stringOrNull(json['AlbumPrimaryImageTag']), childCount: _int(json['ChildCount']), dateCreated: _date(json['DateCreated']), lastPlayed: _lastPlayed(json['UserData']), ); } /// Builds an album card from a played Audio item. factory JellyfinAlbum.fromTrack(JellyfinTrack track) { return JellyfinAlbum( id: track.albumId, name: track.album.isEmpty ? track.name : track.album, artist: track.artist, year: track.year, imageTag: track.albumImageTag, lastPlayed: track.lastPlayed, ); } /// Builds an album card from a played Audio item. Jellyfin 12.2 updates the /// track's user data, not the album's, so the recently-played row is built /// from tracks and deduped by album id. factory JellyfinAlbum.fromTrackJson(Map json) { final albumId = _string(json['AlbumId']); return JellyfinAlbum( id: albumId.isNotEmpty ? albumId : _string(json['ParentId']), name: _stringOrNull(json['Album']) ?? _string(json['Name']), artist: _albumArtist(json), year: _int(json['ProductionYear']), imageTag: _stringOrNull(json['AlbumPrimaryImageTag']) ?? _imageTag(json['ImageTags']), childCount: _int(json['ChildCount']), dateCreated: _date(json['DateCreated']), lastPlayed: _lastPlayed(json['UserData']), ); } @override bool operator ==(Object other) { return other is JellyfinAlbum && other.id == id; } @override int get hashCode => id.hashCode; @override String toString() => name; } /// A playable audio track. [mediaSourceId] is what Jellyfin playback reporting /// and the stream endpoint expect; [duration] comes from `RunTimeTicks`. @immutable class JellyfinTrack { const JellyfinTrack({ required this.id, required this.name, required this.albumId, required this.album, required this.artist, required this.indexNumber, required this.parentIndexNumber, required this.duration, required this.mediaSourceId, this.container, this.codec, this.genres = const [], this.lastPlayed, this.albumImageTag, this.year, }); final String id; final String name; final String albumId; final String album; final String artist; final int? indexNumber; final int? parentIndexNumber; final Duration duration; final String mediaSourceId; final String? container; final String? codec; final List genres; final DateTime? lastPlayed; final String? albumImageTag; final int? year; String get displayIndex { final track = indexNumber; if (track == null) return ''; final disc = parentIndexNumber; if (disc != null && disc > 1) return '$disc.$track'; return '$track'; } factory JellyfinTrack.fromJson(Map json) { final sources = json['MediaSources']; final source = sources is List && sources.isNotEmpty ? sources.first : null; final sourceMap = source is Map ? source : null; final streams = sourceMap?['MediaStreams']; final stream = streams is List && streams.isNotEmpty ? streams.first : null; final streamMap = stream is Map ? stream : null; final ticks = _int(json['RunTimeTicks']) ?? 0; return JellyfinTrack( id: _string(json['Id']), name: _string(json['Name']), albumId: _stringOrNull(json['AlbumId']) ?? _stringOrNull(json['ParentId']) ?? '', album: _string(json['Album']), artist: _albumArtist(json), indexNumber: _int(json['IndexNumber']), parentIndexNumber: _int(json['ParentIndexNumber']), duration: Duration(microseconds: ticks ~/ 10), mediaSourceId: _string(sourceMap?['Id']), container: _stringOrNull(sourceMap?['Container']), codec: _stringOrNull(streamMap?['Codec']), genres: _stringList(json['Genres']), lastPlayed: _lastPlayed(json['UserData']), albumImageTag: _stringOrNull(json['AlbumPrimaryImageTag']), year: _int(json['ProductionYear']), ); } @override bool operator ==(Object other) { return other is JellyfinTrack && other.id == id; } @override int get hashCode => id.hashCode; @override String toString() => name; } String _string(Object? value) => value is String ? value : ''; String? _stringOrNull(Object? value) { if (value is! String) return null; final trimmed = value.trim(); return trimmed.isEmpty ? null : trimmed; } int? _int(Object? value) { if (value is num) return value.toInt(); if (value is String) return int.tryParse(value); return null; } DateTime? _date(Object? value) { if (value is! String) return null; return DateTime.tryParse(value); } DateTime? _lastPlayed(Object? userData) { if (userData is! Map) return null; return _date(userData['LastPlayedDate']); } String? _imageTag(Object? imageTags) { if (imageTags is! Map) return null; return _stringOrNull(imageTags['Primary']); } String _albumArtist(Map json) { final albumArtist = _stringOrNull(json['AlbumArtist']); if (albumArtist != null) return albumArtist; final artists = _stringList(json['Artists']); if (artists.isNotEmpty) return artists.join(', '); final albumArtists = json['AlbumArtists']; if (albumArtists is List) { final names = albumArtists .whereType() .map((entry) => _stringOrNull(entry['Name'])) .whereType() .toList(growable: false); if (names.isNotEmpty) return names.join(', '); } return ''; } List _stringList(Object? value) { if (value is! List) return const []; return value.whereType().toList(growable: false); }