Core: pure-Dart Jellyfin client

Auth (Quick Connect incl. the Initiate 400 fix: Content-Length + full
client header; password), server identity and link parsing, browsing
(views, resume, next up, latest, paged items, search, seasons,
episodes, next episode), images, PlaybackInfo and stream URLs,
progress reporting and user data, and a track matcher for carrying
audio/subtitle choices across episodes. Fixture tests plus env-gated
live tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 01:42:03 +03:00
1 parent d8d487c189
commit 4814bfb561
29 files changed
+9973

No files matched your search

+6
View File
@@ -6,3 +6,9 @@ export 'src/config.dart';
export 'src/protocol.dart';
export 'src/media_source.dart';
export 'src/sync_engine.dart';
export 'src/jellyfin/client.dart';
export 'src/jellyfin/client_info.dart';
export 'src/jellyfin/http.dart';
export 'src/jellyfin/links.dart';
export 'src/jellyfin/models.dart';
export 'src/jellyfin/track_match.dart';
+596
View File
@@ -0,0 +1,596 @@
import 'dart:async';
import 'dart:convert';
import 'client_info.dart';
import 'http.dart';
import 'links.dart';
import 'models.dart';
/// Sort keys for [JellyfinItemQuery.sortBy] (Jellyfin `ItemSortBy`).
abstract final class JellyfinSortBy {
static const sortName = 'SortName';
static const name = 'Name';
static const dateCreated = 'DateCreated';
static const datePlayed = 'DatePlayed';
static const premiereDate = 'PremiereDate';
static const productionYear = 'ProductionYear';
static const communityRating = 'CommunityRating';
static const runtime = 'Runtime';
static const random = 'Random';
static const dateLastContentAdded = 'DateLastContentAdded';
static const seriesDatePlayed = 'SeriesDatePlayed';
}
/// Filters for [JellyfinItemQuery.filters] (Jellyfin `ItemFilter`).
abstract final class JellyfinFilter {
static const isFolder = 'IsFolder';
static const isNotFolder = 'IsNotFolder';
static const isPlayed = 'IsPlayed';
static const isUnplayed = 'IsUnplayed';
static const isFavorite = 'IsFavorite';
static const isResumable = 'IsResumable';
}
/// Item types for [JellyfinItemQuery.includeItemTypes].
abstract final class JellyfinItemType {
static const movie = 'Movie';
static const series = 'Series';
static const season = 'Season';
static const episode = 'Episode';
static const folder = 'Folder';
static const boxSet = 'BoxSet';
static const video = 'Video';
}
/// A library listing (`GET /Items`). Typical uses:
/// - all series of a TV library: `parentId: libId, includeItemTypes:
/// [JellyfinItemType.series], recursive: true`;
/// - all movies: same with `[JellyfinItemType.movie]`;
/// - browsing a folder library level by level: `parentId: folderId`,
/// `recursive: false` (sub-folders and videos mixed; tell them apart by
/// [JellyfinItem.isFolder]);
/// - paging: `query.page(startIndex, limit)` until [JellyfinItemPage.hasMore]
/// is false.
class JellyfinItemQuery {
final String? parentId;
final List<String> includeItemTypes;
final bool recursive;
final String? searchTerm;
final List<String> sortBy;
final bool descending;
final List<String> filters;
final int startIndex;
final int? limit;
const JellyfinItemQuery({
this.parentId,
this.includeItemTypes = const [],
this.recursive = false,
this.searchTerm,
this.sortBy = const [JellyfinSortBy.sortName],
this.descending = false,
this.filters = const [],
this.startIndex = 0,
this.limit,
});
JellyfinItemQuery page(int startIndex, int limit) => JellyfinItemQuery(
parentId: parentId,
includeItemTypes: includeItemTypes,
recursive: recursive,
searchTerm: searchTerm,
sortBy: sortBy,
descending: descending,
filters: filters,
startIndex: startIndex,
limit: limit,
);
}
/// One Playing / Progress / Stopped report. Build it from
/// [JellyfinPlaybackInfo] (playSessionId, source id) and the player state.
class JellyfinPlaybackReport {
final String itemId;
final String mediaSourceId;
final String playSessionId;
final int positionTicks;
final bool isPaused;
/// Jellyfin stream index ([JellyfinMediaStream.index]) of the audio track.
final int? audioStreamIndex;
/// Jellyfin stream index of the subtitle track; -1 = subtitles off.
final int? subtitleStreamIndex;
final bool canSeek;
/// Always "DirectPlay" for this app (no transcoding).
final String playMethod;
const JellyfinPlaybackReport({
required this.itemId,
required this.mediaSourceId,
required this.playSessionId,
this.positionTicks = 0,
this.isPaused = false,
this.audioStreamIndex,
this.subtitleStreamIndex,
this.canSeek = true,
this.playMethod = 'DirectPlay',
});
JellyfinPlaybackReport copyWith({
int? positionTicks,
bool? isPaused,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) =>
JellyfinPlaybackReport(
itemId: itemId,
mediaSourceId: mediaSourceId,
playSessionId: playSessionId,
positionTicks: positionTicks ?? this.positionTicks,
isPaused: isPaused ?? this.isPaused,
audioStreamIndex: audioStreamIndex ?? this.audioStreamIndex,
subtitleStreamIndex: subtitleStreamIndex ?? this.subtitleStreamIndex,
canSeek: canSeek,
playMethod: playMethod,
);
/// Body of /Sessions/Playing and /Sessions/Playing/Progress.
Map<String, dynamic> toProgressJson() => {
'ItemId': itemId,
'MediaSourceId': mediaSourceId,
'PlaySessionId': playSessionId,
'PositionTicks': positionTicks,
'IsPaused': isPaused,
'CanSeek': canSeek,
'PlayMethod': playMethod,
if (audioStreamIndex != null) 'AudioStreamIndex': audioStreamIndex,
if (subtitleStreamIndex != null) 'SubtitleStreamIndex': subtitleStreamIndex,
};
/// Body of /Sessions/Playing/Stopped.
Map<String, dynamic> toStoppedJson() => {
'ItemId': itemId,
'MediaSourceId': mediaSourceId,
'PlaySessionId': playSessionId,
'PositionTicks': positionTicks,
};
}
/// Jellyfin REST client for one server and (once signed in) one user.
///
/// Lifecycle:
/// 1. `JellyfinClient(baseUrl: normalizeServerUrl(input)!, clientInfo: ...)`;
/// [publicInfo] gives the ServerId to key the account by.
/// 2. Sign in with [quickConnectInitiate] + [waitForQuickConnect], or
/// [authenticateByName]. Both store the token and user id on the client
/// and return them in a [JellyfinAuthResult] to persist. A saved login is
/// restored with the constructor's [accessToken] / [userId] and checked
/// with [validateToken].
/// 3. Browse, get [playbackInfo], play [streamUrl] in mpv, report with
/// [reportPlaybackStart] / [reportPlaybackProgress] /
/// [reportPlaybackStopped] (progress every ~10 s and on pause/seek/track
/// change, like Jellyfin's own clients).
///
/// Every user-scoped call needs [userId]; calls that need a token throw
/// [JellyfinException] (401) from the server when it is missing or revoked.
/// [close] releases the HTTP client.
class JellyfinClient {
/// Normalised server base URL, no trailing slash.
final String baseUrl;
final JellyfinClientInfo clientInfo;
String? accessToken;
String? userId;
final JellyfinHttp _http;
/// Fields requested for list items: enough for cards and detail headers.
static const listFields = 'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount';
static const _imageParams = {
'enableImageTypes': 'Primary,Backdrop,Thumb,Logo',
'imageTypeLimit': '1',
};
JellyfinClient({
required String baseUrl,
required this.clientInfo,
this.accessToken,
this.userId,
JellyfinHttp? http,
}) : baseUrl = normalizeServerUrl(baseUrl) ?? baseUrl,
_http = http ?? IoJellyfinHttp();
bool get isAuthenticated => accessToken != null && userId != null;
void close() => _http.close();
// ---------------------------------------------------------------- server
/// `GET /System/Info/Public` (no auth): ServerId, name, version. Also the
/// cheap way to check that an address is a Jellyfin server.
Future<JellyfinServerInfo> publicInfo() async =>
JellyfinServerInfo.fromJson(_map(await _json('GET', '/System/Info/Public', auth: false)));
// ------------------------------------------------------------------ auth
/// Whether the server has Quick Connect turned on.
Future<bool> quickConnectEnabled() async =>
await _json('GET', '/QuickConnect/Enabled', auth: false) == true;
/// `POST /QuickConnect/Initiate`: starts a request and returns the code
/// to show. Needs the full client header (see [JellyfinClientInfo]) and a
/// Content-Length (see [JellyfinHttp.send]); 401 means Quick Connect is
/// disabled on the server.
Future<QuickConnectSession> quickConnectInitiate() async =>
QuickConnectSession.fromJson(_map(await _json('POST', '/QuickConnect/Initiate', auth: false)));
/// `GET /QuickConnect/Connect?secret=`: current state of the request.
/// Throws 404 once the request expired (Jellyfin drops them after ~10 min).
Future<QuickConnectSession> quickConnectCheck(String secret) async =>
QuickConnectSession.fromJson(_map(await _json('GET', '/QuickConnect/Connect',
query: {'secret': secret}, auth: false)));
/// `POST /Users/AuthenticateWithQuickConnect` once the request is
/// authorised. Stores the token on this client.
Future<JellyfinAuthResult> authenticateWithQuickConnect(String secret) async =>
_applyAuth(JellyfinAuthResult.fromJson(_map(await _json(
'POST', '/Users/AuthenticateWithQuickConnect',
body: {'Secret': secret}, auth: false))));
/// Polls [quickConnectCheck] every [interval] until the user approves the
/// code, then signs in. Returns null if [isCancelled] turns true (checked
/// before each poll); throws [TimeoutException] after [timeout].
Future<JellyfinAuthResult?> waitForQuickConnect(
QuickConnectSession session, {
Duration interval = const Duration(seconds: 3),
Duration timeout = const Duration(minutes: 10),
bool Function()? isCancelled,
}) async {
final deadline = DateTime.now().add(timeout);
while (true) {
if (isCancelled?.call() ?? false) return null;
final state = await quickConnectCheck(session.secret);
if (isCancelled?.call() ?? false) return null;
if (state.authenticated) return authenticateWithQuickConnect(session.secret);
if (DateTime.now().isAfter(deadline)) {
throw TimeoutException('Quick Connect code was not approved', timeout);
}
await Future<void>.delayed(interval);
}
}
/// `POST /Users/AuthenticateByName`. Stores the token on this client.
/// Wrong credentials give a [JellyfinException] with status 401.
Future<JellyfinAuthResult> authenticateByName(String username, String password) async =>
_applyAuth(JellyfinAuthResult.fromJson(_map(await _json(
'POST', '/Users/AuthenticateByName',
body: {'Username': username, 'Pw': password}, auth: false))));
/// `GET /Users/Me`: the token's user. Also sets [userId].
Future<JellyfinUser> currentUser() async {
final user = JellyfinUser.fromJson(_map(await _json('GET', '/Users/Me')));
userId = user.id;
return user;
}
/// Checks a saved token: the user, or null when the server says 401
/// (expired or revoked; sign in again). Other failures throw, so an
/// offline server does not look like a logout.
Future<JellyfinUser?> validateToken() async {
try {
return await currentUser();
} on JellyfinException catch (e) {
if (e.isUnauthorized) return null;
rethrow;
}
}
/// `POST /Sessions/Logout`: revokes the token server-side, then forgets it.
Future<void> logout() async {
try {
await _json('POST', '/Sessions/Logout');
} finally {
accessToken = null;
userId = null;
}
}
JellyfinAuthResult _applyAuth(JellyfinAuthResult r) {
accessToken = r.accessToken;
userId = r.user.id;
return r;
}
// -------------------------------------------------------------- browsing
/// The user's libraries (`GET /UserViews`). [JellyfinItem.collectionType]
/// tells movies / tvshows / homevideos / ... apart.
Future<List<JellyfinItem>> userViews() async =>
JellyfinItemPage.fromJson(_map(await _json('GET', '/UserViews', query: {'userId': _uid}))).items;
/// Continue Watching: videos with a resume position, most recent first.
Future<List<JellyfinItem>> resume({int limit = 24, String? parentId}) async =>
JellyfinItemPage.fromJson(_map(await _json('GET', '/UserItems/Resume', query: {
'userId': _uid,
'mediaTypes': 'Video',
'limit': '$limit',
if (parentId != null) 'parentId': parentId,
'fields': listFields,
..._imageParams,
}))).items;
/// Next Up: per series, the first unwatched episode after the last
/// watched one. Not "the episode after X": use [nextEpisode] for that.
/// [enableResumable] false leaves out episodes already in [resume].
Future<List<JellyfinItem>> nextUp({
int limit = 24,
String? seriesId,
String? parentId,
bool enableResumable = false,
}) async =>
JellyfinItemPage.fromJson(_map(await _json('GET', '/Shows/NextUp', query: {
'userId': _uid,
'limit': '$limit',
if (seriesId != null) 'seriesId': seriesId,
if (parentId != null) 'parentId': parentId,
'enableResumable': '$enableResumable',
'fields': listFields,
..._imageParams,
}))).items;
/// Recently added in one library. Episodes are grouped into their series
/// (as the web UI shows them) unless [groupItems] is false.
Future<List<JellyfinItem>> latest(String parentId, {int limit = 16, bool groupItems = true}) async {
final list = await _json('GET', '/Items/Latest', query: {
'userId': _uid,
'parentId': parentId,
'limit': '$limit',
'groupItems': '$groupItems',
'fields': listFields,
..._imageParams,
});
return (list as List? ?? const [])
.whereType<Map>()
.map((e) => JellyfinItem.fromJson(e.cast<String, dynamic>()))
.toList();
}
/// A page of library items; see [JellyfinItemQuery].
Future<JellyfinItemPage> items(JellyfinItemQuery q) async {
final page = JellyfinItemPage.fromJson(_map(await _json('GET', '/Items', query: {
'userId': _uid,
if (q.parentId != null) 'parentId': q.parentId!,
if (q.includeItemTypes.isNotEmpty) 'includeItemTypes': q.includeItemTypes.join(','),
'recursive': '${q.recursive}',
if (q.searchTerm != null && q.searchTerm!.isNotEmpty) 'searchTerm': q.searchTerm!,
if (q.sortBy.isNotEmpty) 'sortBy': q.sortBy.join(','),
'sortOrder': q.descending ? 'Descending' : 'Ascending',
if (q.filters.isNotEmpty) 'filters': q.filters.join(','),
'startIndex': '${q.startIndex}',
if (q.limit != null) 'limit': '${q.limit}',
'enableTotalRecordCount': 'true',
'fields': listFields,
..._imageParams,
})));
// Jellyfin echoes StartIndex; older servers omit it.
return JellyfinItemPage(page.items,
totalRecordCount: page.totalRecordCount, startIndex: q.startIndex);
}
/// Search movies, series and episodes by name across all libraries.
Future<List<JellyfinItem>> search(
String term, {
int limit = 30,
List<String> types = const [
JellyfinItemType.movie,
JellyfinItemType.series,
JellyfinItemType.episode,
],
}) async =>
(await items(JellyfinItemQuery(
searchTerm: term,
recursive: true,
includeItemTypes: types,
limit: limit,
)))
.items;
/// Full item (detail page): overview, user data, media sources/streams.
Future<JellyfinItem> item(String itemId) async =>
JellyfinItem.fromJson(_map(await _json('GET', '/Items/$itemId', query: {'userId': _uid})));
/// Seasons of a series, with user data (played, unplayed count).
Future<List<JellyfinItem>> seasons(String seriesId) async =>
JellyfinItemPage.fromJson(_map(await _json('GET', '/Shows/$seriesId/Seasons', query: {
'userId': _uid,
'enableUserData': 'true',
'fields': listFields,
..._imageParams,
}))).items;
/// Episodes of a series in airing order (all seasons, or one
/// [seasonId]), with user data. Missing (virtual) episodes are left out.
/// Pass [withMediaStreams] to get each episode's tracks in the same call.
Future<List<JellyfinItem>> episodes(String seriesId,
{String? seasonId, bool withMediaStreams = false}) async =>
JellyfinItemPage.fromJson(_map(await _json('GET', '/Shows/$seriesId/Episodes', query: {
'userId': _uid,
if (seasonId != null) 'seasonId': seasonId,
'isMissing': 'false',
'enableUserData': 'true',
'fields': withMediaStreams ? '$listFields,MediaStreams' : listFields,
..._imageParams,
}))).items;
/// The episode after [episode] in its series (crossing seasons), or null
/// for the last one. See [episodeAfter] for the rule.
Future<JellyfinItem?> nextEpisode(JellyfinItem episode, {bool withMediaStreams = false}) async {
final seriesId = episode.seriesId;
if (seriesId == null) return null;
return episodeAfter(
await episodes(seriesId, withMediaStreams: withMediaStreams), episode.id);
}
// -------------------------------------------------------------- images
/// Image URL for caching and display. Images need no token on Jellyfin,
/// so this URL is safe to share. [tag] makes the URL change when the image
/// does; [maxWidth] / [maxHeight] let the server scale down.
Uri imageUrl(JellyfinImageRef ref, {int? maxWidth, int? maxHeight, int quality = 90}) {
final idx = ref.type == JellyfinImageType.backdrop ? '/${ref.index}' : '';
return _uri('/Items/${ref.itemId}/Images/${ref.type.apiName}$idx', {
'tag': ref.tag,
if (maxWidth != null) 'maxWidth': '$maxWidth',
if (maxHeight != null) 'maxHeight': '$maxHeight',
'quality': '$quality',
});
}
/// User avatar URL, or null if the user has none.
Uri? userImageUrl(JellyfinUser user, {int maxWidth = 96}) => user.primaryImageTag == null
? null
: _uri('/Users/${user.id}/Images/Primary', {'tag': user.primaryImageTag!, 'maxWidth': '$maxWidth'});
// ------------------------------------------------------------ playback
/// `POST /Items/{id}/PlaybackInfo` asking for direct play only. The
/// [JellyfinPlaybackInfo.playSessionId] ties the progress reports
/// together; pick the source with [JellyfinPlaybackInfo.primarySource].
Future<JellyfinPlaybackInfo> playbackInfo(String itemId, {String? mediaSourceId}) async =>
JellyfinPlaybackInfo.fromJson(_map(await _json('POST', '/Items/$itemId/PlaybackInfo', query: {
'userId': _uid,
if (mediaSourceId != null) 'mediaSourceId': mediaSourceId,
}, body: {
'UserId': _uid,
if (mediaSourceId != null) 'MediaSourceId': mediaSourceId,
'EnableDirectPlay': true,
'EnableDirectStream': true,
'EnableTranscoding': false,
})));
/// The original file, byte-range capable (206), for mpv:
/// `/Videos/{id}/stream?static=true&mediaSourceId=...&api_key=TOKEN`.
/// The token is in the URL, so do not log or share it; mpv can instead
/// take [shareableStreamUrl] plus [authHeaders] as http-header-fields.
Uri streamUrl(String itemId, {String? mediaSourceId}) =>
authorizeUrl(shareableStreamUrl(itemId, mediaSourceId: mediaSourceId));
/// Same as [streamUrl] without credentials: safe to send to other viewers
/// or the sync server, each of which adds its own ([authorizeUrl]).
Uri shareableStreamUrl(String itemId, {String? mediaSourceId}) =>
_uri('/Videos/$itemId/stream', {
'static': 'true',
'mediaSourceId': mediaSourceId ?? itemId,
});
/// [url] plus this client's token as `api_key` (replacing any present).
Uri authorizeUrl(Uri url) {
final token = accessToken;
if (token == null) throw const JellyfinException('not signed in', statusCode: 401);
return url.replace(queryParameters: {...url.queryParameters, 'api_key': token});
}
/// Headers that authenticate a request without a token in the URL.
Map<String, String> get authHeaders =>
{'Authorization': clientInfo.authorizationHeader(accessToken)};
// ------------------------------------------------------------ progress
/// `POST /Sessions/Playing`: playback started (Jellyfin shows the session
/// as playing and starts tracking the item).
Future<void> reportPlaybackStart(JellyfinPlaybackReport r) =>
_json('POST', '/Sessions/Playing', body: r.toProgressJson());
/// `POST /Sessions/Playing/Progress`: position, pause state and tracks.
/// Jellyfin saves the resume position from these.
Future<void> reportPlaybackProgress(JellyfinPlaybackReport r) =>
_json('POST', '/Sessions/Playing/Progress', body: r.toProgressJson());
/// `POST /Sessions/Playing/Stopped`: ends the session. The server then
/// marks the item played if the position is past its "max resume" share
/// (90% by default), keeps it as a resume point between 5% and 90%, and
/// clears the position below 5%.
Future<void> reportPlaybackStopped(JellyfinPlaybackReport r) =>
_json('POST', '/Sessions/Playing/Stopped', body: r.toStoppedJson());
/// The user's state for one item.
Future<JellyfinUserData> userData(String itemId) async => JellyfinUserData.fromJson(
_map(await _json('GET', '/UserItems/$itemId/UserData', query: {'userId': _uid})));
/// Marks an item (or a whole season/series) watched.
Future<JellyfinUserData> markPlayed(String itemId) async => JellyfinUserData.fromJson(
_map(await _json('POST', '/UserPlayedItems/$itemId', query: {'userId': _uid})));
/// Marks an item unwatched. This also resets play count, last played date
/// and resume position.
Future<JellyfinUserData> markUnplayed(String itemId) async => JellyfinUserData.fromJson(
_map(await _json('DELETE', '/UserPlayedItems/$itemId', query: {'userId': _uid})));
/// Sets the resume position without a playback session.
Future<void> setResumePosition(String itemId, int positionTicks) =>
_json('POST', '/UserItems/$itemId/UserData',
query: {'userId': _uid}, body: {'PlaybackPositionTicks': positionTicks});
/// Removes the item from Continue Watching.
Future<void> clearResumePosition(String itemId) => setResumePosition(itemId, 0);
// ------------------------------------------------------------- plumbing
String get _uid {
final id = userId;
if (id == null) throw const JellyfinException('not signed in', statusCode: 401);
return id;
}
Uri _uri(String path, [Map<String, String>? query]) {
final uri = Uri.parse('$baseUrl$path');
return query == null || query.isEmpty ? uri : uri.replace(queryParameters: query);
}
Future<Object?> _json(
String method,
String path, {
Map<String, String>? query,
Object? body,
bool auth = true,
}) async {
final uri = _uri(path, query);
final res = await _http.send(
method,
uri,
headers: {
'Authorization': clientInfo.authorizationHeader(auth ? accessToken : null),
'Accept': 'application/json',
},
body: body == null ? null : jsonEncode(body),
);
if (!res.ok) {
final detail = res.body.length > 200 ? res.body.substring(0, 200) : res.body;
throw JellyfinException('$method $path -> ${res.statusCode} $detail'.trim(),
statusCode: res.statusCode);
}
if (res.body.trim().isEmpty) return null;
try {
return jsonDecode(res.body);
} on FormatException {
throw JellyfinException('$method $path: response is not JSON', statusCode: res.statusCode);
}
}
static Map<String, dynamic> _map(Object? v) =>
v is Map ? v.cast<String, dynamic>() : const {};
}
/// The successor of [episodeId] in [episodes] (a series' episode list in
/// airing order, as `/Shows/{id}/Episodes` returns it), crossing seasons.
/// Other copies of the same episode (same season and episode number, e.g.
/// two files of one episode) are skipped. Null when [episodeId] is last or
/// not in the list.
JellyfinItem? episodeAfter(List<JellyfinItem> episodes, String episodeId) {
final at = episodes.indexWhere((e) => e.id == episodeId);
if (at < 0) return null;
final cur = episodes[at];
for (var i = at + 1; i < episodes.length; i++) {
final e = episodes[i];
final sameEpisode = cur.indexNumber != null &&
e.indexNumber == cur.indexNumber &&
e.parentIndexNumber == cur.parentIndexNumber;
if (!sameEpisode) return e;
}
return null;
}
@@ -0,0 +1,38 @@
/// Identifies this app to Jellyfin. Always send all four fields: on 10.11
/// an unauthenticated request (Quick Connect, sign-in) whose
/// `Authorization: MediaBrowser ...` header lacks any of them (e.g. no
/// Version) is rejected with 400 and no body. Token-carrying requests are
/// more lenient, but progress reports are attributed by these fields.
///
/// [deviceId] must be stable per install (persist a random id once).
/// Jellyfin keys devices, sessions and tokens by it: signing the same user
/// in again with the same DeviceId reuses that device entry instead of
/// piling up devices on the user's dashboard.
class JellyfinClientInfo {
/// App name shown in Jellyfin's dashboard ("Watch Together").
final String client;
/// Device name shown in the dashboard (e.g. the hostname).
final String device;
final String deviceId;
final String version;
const JellyfinClientInfo({
required this.client,
required this.device,
required this.deviceId,
required this.version,
});
/// The `Authorization` header value. Values are percent-encoded (the
/// server decodes them), so names with quotes, commas or non-ASCII
/// characters are safe.
String authorizationHeader([String? token]) {
final parts = <String>[
'Client="${Uri.encodeComponent(client)}"',
'Device="${Uri.encodeComponent(device)}"',
'DeviceId="${Uri.encodeComponent(deviceId)}"',
'Version="${Uri.encodeComponent(version)}"',
if (token != null && token.isNotEmpty) 'Token="${Uri.encodeComponent(token)}"',
];
return 'MediaBrowser ${parts.join(', ')}';
}
}
+79
View File
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'dart:io';
import '../errors.dart';
/// A failed Jellyfin request. [statusCode] is null for network errors.
class JellyfinException extends SyncRelayException {
final int? statusCode;
const JellyfinException(super.message, {this.statusCode});
/// 401: the token is missing, expired or was revoked (sign in again).
bool get isUnauthorized => statusCode == 401;
@override
String toString() =>
'JellyfinException${statusCode == null ? '' : ' $statusCode'}: $message';
}
class JellyfinHttpResponse {
final int statusCode;
final String body;
const JellyfinHttpResponse(this.statusCode, this.body);
bool get ok => statusCode >= 200 && statusCode < 300;
}
/// The transport [JellyfinClient] talks through. Tests inject a fake; the
/// default is [IoJellyfinHttp].
abstract class JellyfinHttp {
/// Sends one request. [body] is sent as-is (JSON when non-null). A POST
/// always carries a Content-Length, even when empty: Jellyfin behind nginx
/// answers 400 to a body-less POST without one (seen on
/// /QuickConnect/Initiate).
Future<JellyfinHttpResponse> send(
String method,
Uri uri, {
Map<String, String> headers = const {},
String? body,
});
void close();
}
class IoJellyfinHttp implements JellyfinHttp {
final HttpClient _client;
final Duration timeout;
IoJellyfinHttp({HttpClient? client, this.timeout = const Duration(seconds: 20)})
: _client = client ?? HttpClient();
@override
Future<JellyfinHttpResponse> send(
String method,
Uri uri, {
Map<String, String> headers = const {},
String? body,
}) async {
try {
final req = await _client.openUrl(method, uri).timeout(timeout);
headers.forEach(req.headers.set);
final bytes = body == null ? const <int>[] : utf8.encode(body);
if (body != null) {
req.headers.contentType = ContentType.json;
}
if (method != 'GET' && method != 'HEAD') {
req.contentLength = bytes.length;
req.add(bytes);
}
final res = await req.close().timeout(timeout);
final text = await res.transform(utf8.decoder).join().timeout(timeout);
return JellyfinHttpResponse(res.statusCode, text);
} on Object catch (e) {
throw JellyfinException('$method ${uri.path}: $e');
}
}
@override
void close() => _client.close(force: true);
}
+132
View File
@@ -0,0 +1,132 @@
/// Server URL normalisation and Jellyfin link recognition.
library;
/// Normalises what a user typed as a server address into a base URL without
/// a trailing slash, e.g. `jelly.example.com/web/#/home` ->
/// `https://jelly.example.com`. A base path (reverse proxy sub-path such as
/// `https://example.com/jellyfin`) is kept. No scheme means https, except
/// for localhost and IP addresses, which get http. Returns null when the
/// input is not a usable http(s) URL.
String? normalizeServerUrl(String input) {
var text = input.trim();
if (text.isEmpty) return null;
if (!text.contains('://')) {
final host = text.split(RegExp(r'[/:#?]')).first;
final local = host == 'localhost' ||
RegExp(r'^\d{1,3}(\.\d{1,3}){3}$').hasMatch(host) ||
text.startsWith('[');
text = '${local ? 'http' : 'https'}://$text';
}
final link = parseJellyfinLink(text);
if (link != null) return link.baseUrl;
final Uri uri;
try {
uri = Uri.parse(text);
} on FormatException {
return null;
}
if ((uri.scheme != 'http' && uri.scheme != 'https') || uri.host.isEmpty) {
return null;
}
return _base(uri, _basePath(uri.pathSegments));
}
/// A Jellyfin item recognised in a pasted link. [serverId] is only known
/// when the link carried it (web UI links do); otherwise resolve it with
/// `JellyfinClient.publicInfo()` on [baseUrl].
class JellyfinLink {
final String baseUrl;
final String itemId;
final String? serverId;
const JellyfinLink({required this.baseUrl, required this.itemId, this.serverId});
@override
bool operator ==(Object other) =>
other is JellyfinLink &&
other.baseUrl == baseUrl &&
other.itemId == itemId &&
other.serverId == serverId;
@override
int get hashCode => Object.hash(baseUrl, itemId, serverId);
@override
String toString() => 'JellyfinLink($baseUrl, $itemId, serverId: $serverId)';
}
final _idPattern = RegExp(
r'^([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$');
/// Jellyfin ids are GUIDs; the API prints them as 32 lowercase hex digits.
/// Returns that form, or null when [id] is not a GUID.
String? normalizeItemId(String id) {
if (!_idPattern.hasMatch(id)) return null;
return id.replaceAll('-', '').toLowerCase();
}
/// Recognises a Jellyfin item link and reduces it to (baseUrl, itemId):
/// - web UI: `/web/#/details?id=X&serverId=S`, `/web/index.html#!/details?id=X`,
/// any `#/...?id=X` route;
/// - API: `/Items/X/...`, `/Users/U/Items/X`, `/Videos/X/stream...`,
/// `/Audio/X/...`, `/UserItems/X/...`.
/// Returns null for anything else (including non-Jellyfin URLs, where it
/// cannot be sure).
JellyfinLink? parseJellyfinLink(String input) {
final text = input.trim();
if (!text.startsWith('http://') && !text.startsWith('https://')) return null;
final Uri uri;
try {
uri = Uri.parse(text);
} on FormatException {
return null;
}
if (uri.host.isEmpty) return null;
final segs = uri.pathSegments.where((s) => s.isNotEmpty).toList();
// Web UI: the route lives in the fragment, e.g. "/details?id=...".
final webAt = segs.indexWhere((s) => s.toLowerCase() == 'web');
if (webAt >= 0 && uri.fragment.isNotEmpty) {
final frag = uri.fragment.replaceFirst(RegExp(r'^!'), '');
final q = frag.indexOf('?');
if (q >= 0) {
final params = Uri.splitQueryString(frag.substring(q + 1));
final id = normalizeItemId(params['id'] ?? '');
if (id != null) {
final sid = params['serverId'];
return JellyfinLink(
baseUrl: _base(uri, segs.sublist(0, webAt)),
itemId: id,
serverId: sid == null ? null : normalizeItemId(sid) ?? sid,
);
}
}
}
// API paths: <base>/(Users/<u>/)?(Items|Videos|Audio|UserItems)/<id>/...
for (var i = 0; i < segs.length - 1; i++) {
final s = segs[i].toLowerCase();
if (s != 'items' && s != 'videos' && s != 'audio' && s != 'useritems') continue;
final id = normalizeItemId(segs[i + 1]);
if (id == null) continue;
var baseEnd = i;
if (i >= 2 && segs[i - 2].toLowerCase() == 'users') baseEnd = i - 2;
return JellyfinLink(baseUrl: _base(uri, segs.sublist(0, baseEnd)), itemId: id);
}
return null;
}
/// Path segments of the server base: everything before a `web` segment
/// (the web UI lives at `<base>/web/`), otherwise the whole path.
List<String> _basePath(List<String> segments) {
final segs = segments.where((s) => s.isNotEmpty).toList();
final webAt = segs.indexWhere((s) => s.toLowerCase() == 'web');
return webAt >= 0 ? segs.sublist(0, webAt) : segs;
}
String _base(Uri uri, List<String> pathSegments) {
final port = uri.hasPort ? ':${uri.port}' : '';
final path = pathSegments.isEmpty
? ''
: '/${pathSegments.map(Uri.encodeComponent).join('/')}';
return '${uri.scheme}://${uri.host}$port$path';
}
+557
View File
@@ -0,0 +1,557 @@
/// Typed views of Jellyfin API JSON. Only the fields a client UI and the
/// player need; everything is parsed leniently (missing fields become null,
/// false or 0) because Jellyfin omits fields freely.
library;
/// Jellyfin positions and durations are in ticks: 10,000,000 per second.
const int jellyfinTicksPerSecond = 10000000;
int durationToTicks(Duration d) => d.inMicroseconds * 10;
Duration ticksToDuration(int ticks) => Duration(microseconds: ticks ~/ 10);
typedef JellyfinJson = Map<String, dynamic>;
String? _str(Object? v) => v is String && v.isNotEmpty ? v : null;
int? _int(Object? v) => v is num ? v.toInt() : null;
double? _double(Object? v) => v is num ? v.toDouble() : null;
bool _bool(Object? v) => v == true;
DateTime? _date(Object? v) => v is String ? DateTime.tryParse(v) : null;
List<JellyfinJson> _list(Object? v) =>
v is List ? v.whereType<Map>().map((e) => e.cast<String, dynamic>()).toList() : const [];
/// /System/Info/Public. [id] is the ServerId: the stable key for a linked
/// server (URLs may change, the id does not).
class JellyfinServerInfo {
final String id;
final String name;
final String version;
const JellyfinServerInfo({required this.id, required this.name, required this.version});
factory JellyfinServerInfo.fromJson(JellyfinJson j) => JellyfinServerInfo(
id: _str(j['Id']) ?? '',
name: _str(j['ServerName']) ?? '',
version: _str(j['Version']) ?? '',
);
}
class JellyfinUser {
final String id;
final String name;
final String? serverId;
final String? primaryImageTag;
final bool isAdministrator;
const JellyfinUser({
required this.id,
required this.name,
this.serverId,
this.primaryImageTag,
this.isAdministrator = false,
});
factory JellyfinUser.fromJson(JellyfinJson j) => JellyfinUser(
id: _str(j['Id']) ?? '',
name: _str(j['Name']) ?? '',
serverId: _str(j['ServerId']),
primaryImageTag: _str(j['PrimaryImageTag']),
isAdministrator: _bool((j['Policy'] as Map?)?['IsAdministrator']),
);
}
/// Result of a sign-in. Persist [accessToken], [user].id and [serverId]
/// (keyed by ServerId); the token stays valid until logout or revocation.
class JellyfinAuthResult {
final String accessToken;
final JellyfinUser user;
final String serverId;
const JellyfinAuthResult({required this.accessToken, required this.user, required this.serverId});
factory JellyfinAuthResult.fromJson(JellyfinJson j) {
final user = JellyfinUser.fromJson((j['User'] as Map? ?? const {}).cast<String, dynamic>());
return JellyfinAuthResult(
accessToken: _str(j['AccessToken']) ?? '',
user: user,
serverId: _str(j['ServerId']) ?? user.serverId ?? '',
);
}
}
/// A pending Quick Connect request. Show [code] to the user (they enter it
/// in Jellyfin: user menu > Quick Connect); keep [secret] private, it is
/// what the app polls and signs in with.
class QuickConnectSession {
final String code;
final String secret;
final bool authenticated;
const QuickConnectSession({required this.code, required this.secret, this.authenticated = false});
factory QuickConnectSession.fromJson(JellyfinJson j) => QuickConnectSession(
code: _str(j['Code']) ?? '',
secret: _str(j['Secret']) ?? '',
authenticated: _bool(j['Authenticated']),
);
}
/// Per-user state of an item.
class JellyfinUserData {
final bool played;
final int playbackPositionTicks;
final int playCount;
final bool isFavorite;
/// Folders (series, seasons) only: episodes left to watch.
final int? unplayedItemCount;
final double? playedPercentage;
final DateTime? lastPlayedDate;
const JellyfinUserData({
this.played = false,
this.playbackPositionTicks = 0,
this.playCount = 0,
this.isFavorite = false,
this.unplayedItemCount,
this.playedPercentage,
this.lastPlayedDate,
});
factory JellyfinUserData.fromJson(JellyfinJson j) => JellyfinUserData(
played: _bool(j['Played']),
playbackPositionTicks: _int(j['PlaybackPositionTicks']) ?? 0,
playCount: _int(j['PlayCount']) ?? 0,
isFavorite: _bool(j['IsFavorite']),
unplayedItemCount: _int(j['UnplayedItemCount']),
playedPercentage: _double(j['PlayedPercentage']),
lastPlayedDate: _date(j['LastPlayedDate']),
);
Duration get position => ticksToDuration(playbackPositionTicks);
/// Has a resume point (Continue Watching).
bool get isResumable => playbackPositionTicks > 0;
}
/// Image kinds a UI uses. Backdrop can have several (index), the rest one.
enum JellyfinImageType {
primary('Primary'),
backdrop('Backdrop'),
thumb('Thumb'),
logo('Logo');
final String apiName;
const JellyfinImageType(this.apiName);
}
/// A concrete image: which item owns it and its tag. The tag changes when
/// the image changes, so `(itemId, type, index, tag, maxWidth)` is a safe
/// cache key. Build the URL with `JellyfinClient.imageUrl`.
class JellyfinImageRef {
final String itemId;
final JellyfinImageType type;
final String tag;
final int index;
const JellyfinImageRef(this.itemId, this.type, this.tag, {this.index = 0});
@override
bool operator ==(Object other) =>
other is JellyfinImageRef &&
other.itemId == itemId &&
other.type == type &&
other.tag == tag &&
other.index == index;
@override
int get hashCode => Object.hash(itemId, type, tag, index);
}
/// Item types the app distinguishes; anything else is [other].
enum JellyfinItemKind {
movie,
series,
season,
episode,
folder,
collectionFolder,
boxSet,
video,
other;
static JellyfinItemKind parse(String? type) => switch (type) {
'Movie' => movie,
'Series' => series,
'Season' => season,
'Episode' => episode,
'Folder' => folder,
'CollectionFolder' || 'UserView' => collectionFolder,
'BoxSet' => boxSet,
'Video' || 'MusicVideo' => video,
_ => other,
};
}
/// One library item (view, series, season, episode, movie, folder...).
class JellyfinItem {
final String id;
final String name;
/// Raw Jellyfin type ("Episode", "Series", ...); see [kind].
final String type;
final String? serverId;
final bool isFolder;
/// Libraries only: "movies", "tvshows", "homevideos", ...
final String? collectionType;
final String? overview;
final int? productionYear;
final DateTime? premiereDate;
final double? communityRating;
final String? officialRating;
final int? runTimeTicks;
final String? container;
final String? mediaType;
// Episodes / seasons.
final String? seriesId;
final String? seriesName;
final String? seasonId;
final String? seasonName;
/// Episode number, or season number for a season.
final int? indexNumber;
/// Season number of an episode.
final int? parentIndexNumber;
final int? childCount;
final int? recursiveItemCount;
final JellyfinUserData userData;
final double? primaryImageAspectRatio;
final Map<String, String> imageTags;
final List<String> backdropImageTags;
final String? seriesPrimaryImageTag;
final String? parentBackdropItemId;
final List<String> parentBackdropImageTags;
final String? parentThumbItemId;
final String? parentThumbImageTag;
final String? parentLogoItemId;
final String? parentLogoImageTag;
/// Present on full item responses (`getItem`) for playable items.
final List<JellyfinMediaSource> mediaSources;
/// Streams of the default source; present on `getItem` and when
/// MediaStreams was requested in `fields`.
final List<JellyfinMediaStream> mediaStreams;
const JellyfinItem({
required this.id,
required this.name,
required this.type,
this.serverId,
this.isFolder = false,
this.collectionType,
this.overview,
this.productionYear,
this.premiereDate,
this.communityRating,
this.officialRating,
this.runTimeTicks,
this.container,
this.mediaType,
this.seriesId,
this.seriesName,
this.seasonId,
this.seasonName,
this.indexNumber,
this.parentIndexNumber,
this.childCount,
this.recursiveItemCount,
this.userData = const JellyfinUserData(),
this.primaryImageAspectRatio,
this.imageTags = const {},
this.backdropImageTags = const [],
this.seriesPrimaryImageTag,
this.parentBackdropItemId,
this.parentBackdropImageTags = const [],
this.parentThumbItemId,
this.parentThumbImageTag,
this.parentLogoItemId,
this.parentLogoImageTag,
this.mediaSources = const [],
this.mediaStreams = const [],
});
factory JellyfinItem.fromJson(JellyfinJson j) => JellyfinItem(
id: _str(j['Id']) ?? '',
name: _str(j['Name']) ?? '',
type: _str(j['Type']) ?? '',
serverId: _str(j['ServerId']),
isFolder: _bool(j['IsFolder']),
collectionType: _str(j['CollectionType']),
overview: _str(j['Overview']),
productionYear: _int(j['ProductionYear']),
premiereDate: _date(j['PremiereDate']),
communityRating: _double(j['CommunityRating']),
officialRating: _str(j['OfficialRating']),
runTimeTicks: _int(j['RunTimeTicks']),
container: _str(j['Container']),
mediaType: _str(j['MediaType']),
seriesId: _str(j['SeriesId']),
seriesName: _str(j['SeriesName']),
seasonId: _str(j['SeasonId']),
seasonName: _str(j['SeasonName']),
indexNumber: _int(j['IndexNumber']),
parentIndexNumber: _int(j['ParentIndexNumber']),
childCount: _int(j['ChildCount']),
recursiveItemCount: _int(j['RecursiveItemCount']),
userData: JellyfinUserData.fromJson(
(j['UserData'] as Map? ?? const {}).cast<String, dynamic>()),
primaryImageAspectRatio: _double(j['PrimaryImageAspectRatio']),
imageTags: (j['ImageTags'] as Map? ?? const {})
.map((k, v) => MapEntry(k.toString(), v.toString())),
backdropImageTags: _strings(j['BackdropImageTags']),
seriesPrimaryImageTag: _str(j['SeriesPrimaryImageTag']),
parentBackdropItemId: _str(j['ParentBackdropItemId']),
parentBackdropImageTags: _strings(j['ParentBackdropImageTags']),
parentThumbItemId: _str(j['ParentThumbItemId']),
parentThumbImageTag: _str(j['ParentThumbImageTag']),
parentLogoItemId: _str(j['ParentLogoItemId']),
parentLogoImageTag: _str(j['ParentLogoImageTag']),
mediaSources: _list(j['MediaSources']).map(JellyfinMediaSource.fromJson).toList(),
mediaStreams: _list(j['MediaStreams']).map(JellyfinMediaStream.fromJson).toList(),
);
JellyfinItemKind get kind => JellyfinItemKind.parse(type);
Duration? get runTime => runTimeTicks == null ? null : ticksToDuration(runTimeTicks!);
/// "S2E12" for episodes with both numbers, else null.
String? get episodeLabel => parentIndexNumber != null && indexNumber != null
? 'S${parentIndexNumber}E$indexNumber'
: null;
/// The item's own image of [type], if it has one.
JellyfinImageRef? ownImage(JellyfinImageType type, {int index = 0}) {
if (type == JellyfinImageType.backdrop) {
return index < backdropImageTags.length
? JellyfinImageRef(id, type, backdropImageTags[index], index: index)
: null;
}
final tag = imageTags[type.apiName];
return tag == null ? null : JellyfinImageRef(id, type, tag);
}
/// Best image of [type] for display: the item's own, else the parent's
/// (series/season art that Jellyfin inlines on episodes and seasons). For
/// [JellyfinImageType.primary] on an episode or season without its own
/// image this falls back to the series poster.
JellyfinImageRef? image(JellyfinImageType type) {
final own = ownImage(type);
if (own != null) return own;
switch (type) {
case JellyfinImageType.primary:
if (seriesId != null && seriesPrimaryImageTag != null) {
return JellyfinImageRef(seriesId!, type, seriesPrimaryImageTag!);
}
case JellyfinImageType.backdrop:
if (parentBackdropItemId != null && parentBackdropImageTags.isNotEmpty) {
return JellyfinImageRef(parentBackdropItemId!, type, parentBackdropImageTags.first);
}
case JellyfinImageType.thumb:
if (parentThumbItemId != null && parentThumbImageTag != null) {
return JellyfinImageRef(parentThumbItemId!, type, parentThumbImageTag!);
}
case JellyfinImageType.logo:
if (parentLogoItemId != null && parentLogoImageTag != null) {
return JellyfinImageRef(parentLogoItemId!, type, parentLogoImageTag!);
}
}
return null;
}
}
List<String> _strings(Object? v) =>
v is List ? v.map((e) => e.toString()).toList() : const [];
/// A page of items. [startIndex] + items.length < [totalRecordCount] means
/// there is more.
class JellyfinItemPage {
final List<JellyfinItem> items;
final int totalRecordCount;
final int startIndex;
const JellyfinItemPage(this.items, {required this.totalRecordCount, this.startIndex = 0});
factory JellyfinItemPage.fromJson(JellyfinJson j) {
final items = _list(j['Items']).map(JellyfinItem.fromJson).toList();
return JellyfinItemPage(
items,
totalRecordCount: _int(j['TotalRecordCount']) ?? items.length,
startIndex: _int(j['StartIndex']) ?? 0,
);
}
bool get hasMore => startIndex + items.length < totalRecordCount;
}
enum JellyfinStreamType {
video,
audio,
subtitle,
other;
static JellyfinStreamType parse(String? s) => switch (s) {
'Video' => video,
'Audio' => audio,
'Subtitle' => subtitle,
_ => other,
};
}
/// One track of a media source. [index] is Jellyfin's stream index: the
/// value for AudioStreamIndex / SubtitleStreamIndex in progress reports.
/// For embedded streams in an mkv it equals the ffprobe stream index, so
/// mpv's track id is the position among streams of the same type (mpv
/// counts audio and subtitle ids separately, from 1); see
/// [JellyfinMediaSource.mpvTrackId].
class JellyfinMediaStream {
final int index;
final JellyfinStreamType type;
final String? codec;
final String? language;
final String? title;
final String? displayTitle;
final int? channels;
final String? channelLayout;
final bool isDefault;
final bool isForced;
final bool isExternal;
final bool isHearingImpaired;
final bool isTextSubtitleStream;
/// External subtitles only: path on the server, not reachable by clients.
final String? deliveryUrl;
const JellyfinMediaStream({
required this.index,
required this.type,
this.codec,
this.language,
this.title,
this.displayTitle,
this.channels,
this.channelLayout,
this.isDefault = false,
this.isForced = false,
this.isExternal = false,
this.isHearingImpaired = false,
this.isTextSubtitleStream = false,
this.deliveryUrl,
});
factory JellyfinMediaStream.fromJson(JellyfinJson j) => JellyfinMediaStream(
index: _int(j['Index']) ?? -1,
type: JellyfinStreamType.parse(_str(j['Type'])),
codec: _str(j['Codec']),
language: _str(j['Language']),
title: _str(j['Title']),
displayTitle: _str(j['DisplayTitle']),
channels: _int(j['Channels']),
channelLayout: _str(j['ChannelLayout']),
isDefault: _bool(j['IsDefault']),
isForced: _bool(j['IsForced']),
isExternal: _bool(j['IsExternal']),
isHearingImpaired: _bool(j['IsHearingImpaired']),
isTextSubtitleStream: _bool(j['IsTextSubtitleStream']),
deliveryUrl: _str(j['DeliveryUrl']),
);
@override
String toString() =>
'JellyfinMediaStream($index ${type.name} $language "$title" $codec ${channels ?? ''})';
}
/// One playable version of an item (usually exactly one: the file).
class JellyfinMediaSource {
final String id;
final String? name;
final String? container;
final int? size;
final int? bitrate;
final int? runTimeTicks;
final bool supportsDirectPlay;
final bool supportsDirectStream;
final int? defaultAudioStreamIndex;
final int? defaultSubtitleStreamIndex;
final List<JellyfinMediaStream> mediaStreams;
const JellyfinMediaSource({
required this.id,
this.name,
this.container,
this.size,
this.bitrate,
this.runTimeTicks,
this.supportsDirectPlay = false,
this.supportsDirectStream = false,
this.defaultAudioStreamIndex,
this.defaultSubtitleStreamIndex,
this.mediaStreams = const [],
});
factory JellyfinMediaSource.fromJson(JellyfinJson j) => JellyfinMediaSource(
id: _str(j['Id']) ?? '',
name: _str(j['Name']),
container: _str(j['Container']),
size: _int(j['Size']),
bitrate: _int(j['Bitrate']),
runTimeTicks: _int(j['RunTimeTicks']),
supportsDirectPlay: _bool(j['SupportsDirectPlay']),
supportsDirectStream: _bool(j['SupportsDirectStream']),
defaultAudioStreamIndex: _int(j['DefaultAudioStreamIndex']),
defaultSubtitleStreamIndex: _int(j['DefaultSubtitleStreamIndex']),
mediaStreams: _list(j['MediaStreams']).map(JellyfinMediaStream.fromJson).toList(),
);
List<JellyfinMediaStream> streamsOf(JellyfinStreamType type) =>
mediaStreams.where((s) => s.type == type).toList();
JellyfinMediaStream? streamAt(int index) {
for (final s in mediaStreams) {
if (s.index == index) return s;
}
return null;
}
/// mpv track id (`aid` / `sid`, 1-based per type) of the embedded stream
/// with Jellyfin [index], or null if it is external or unknown. Assumes
/// mpv sees the same embedded streams in the same order as Jellyfin's
/// probe (true for direct-played mkv/mp4).
int? mpvTrackId(int index) {
final s = streamAt(index);
if (s == null || s.isExternal) return null;
final sameType = mediaStreams
.where((m) => m.type == s.type && !m.isExternal)
.toList()
..sort((a, b) => a.index.compareTo(b.index));
final pos = sameType.indexWhere((m) => m.index == index);
return pos < 0 ? null : pos + 1;
}
/// Inverse of [mpvTrackId].
int? jellyfinIndexForMpvTrack(JellyfinStreamType type, int mpvId) {
final sameType = mediaStreams
.where((m) => m.type == type && !m.isExternal)
.toList()
..sort((a, b) => a.index.compareTo(b.index));
return mpvId >= 1 && mpvId <= sameType.length ? sameType[mpvId - 1].index : null;
}
}
/// Result of PlaybackInfo. Report progress with [playSessionId] and the
/// chosen source's id.
class JellyfinPlaybackInfo {
final String playSessionId;
final List<JellyfinMediaSource> mediaSources;
const JellyfinPlaybackInfo({required this.playSessionId, required this.mediaSources});
factory JellyfinPlaybackInfo.fromJson(JellyfinJson j) => JellyfinPlaybackInfo(
playSessionId: _str(j['PlaySessionId']) ?? '',
mediaSources: _list(j['MediaSources']).map(JellyfinMediaSource.fromJson).toList(),
);
/// The source to direct-play: the first that supports it, else the first.
JellyfinMediaSource? get primarySource {
for (final s in mediaSources) {
if (s.supportsDirectPlay) return s;
}
return mediaSources.isEmpty ? null : mediaSources.first;
}
}
@@ -0,0 +1,172 @@
/// Carrying the viewer's audio/subtitle choice from one episode to the
/// next. Pure functions, no I/O.
library;
import 'models.dart';
/// The traits of a chosen track that are compared against the next
/// episode's tracks. Stream indices are not compared: they shift between
/// releases.
class TrackPreference {
final JellyfinStreamType type;
final String? language;
final String? title;
final String? codec;
final int? channels;
final bool isForced;
final bool isHearingImpaired;
const TrackPreference({
required this.type,
this.language,
this.title,
this.codec,
this.channels,
this.isForced = false,
this.isHearingImpaired = false,
});
factory TrackPreference.fromStream(JellyfinMediaStream s) => TrackPreference(
type: s.type,
language: s.language,
title: s.title,
codec: s.codec,
channels: s.channels,
isForced: s.isForced,
isHearingImpaired: s.isHearingImpaired,
);
}
/// Picks the track in [candidates] that best matches [preference]:
/// 1. same type and same language (required; ISO 639-1/639-2 codes are
/// equated, so "en" = "eng" and "ger" = "deu"; a missing or "und"
/// language only matches another missing/"und" one);
/// 2. most similar title (exact match first, then shared words, so "Full
/// Subtitles" keeps "Full" over "Signs & Songs");
/// 3. same forced / hearing-impaired flags;
/// 4. same codec, then the closest channel count;
/// 5. the stream marked default, then the lowest index.
/// Returns null when no candidate has the language: the caller then keeps
/// the server/player default.
JellyfinMediaStream? matchTrack(TrackPreference preference, List<JellyfinMediaStream> candidates) {
final lang = normalizeLanguage(preference.language);
final pool = candidates
.where((s) => s.type == preference.type && normalizeLanguage(s.language) == lang)
.toList();
if (pool.isEmpty) return null;
final prefTokens = _titleTokens(preference.title);
final prefTitle = _normTitle(preference.title);
List<num> score(JellyfinMediaStream s) => [
_normTitle(s.title) == prefTitle ? 2 : _jaccard(prefTokens, _titleTokens(s.title)),
s.isForced == preference.isForced ? 1 : 0,
s.isHearingImpaired == preference.isHearingImpaired ? 1 : 0,
_eq(s.codec, preference.codec) ? 1 : 0,
(preference.channels == null || s.channels == null)
? 0
: -(s.channels! - preference.channels!).abs(),
s.isDefault ? 1 : 0,
-s.index,
];
JellyfinMediaStream? best;
List<num>? bestScore;
for (final s in pool) {
final sc = score(s);
if (bestScore == null || _compare(sc, bestScore) > 0) {
best = s;
bestScore = sc;
}
}
return best;
}
/// Result of [matchTracks]. Indices are Jellyfin stream indices (also what
/// progress reports carry). [subtitleStreamIndex] is -1 for "subtitles
/// off"; null in either field means no match (use the default).
class TrackSelection {
final int? audioStreamIndex;
final int? subtitleStreamIndex;
const TrackSelection({this.audioStreamIndex, this.subtitleStreamIndex});
@override
String toString() => 'TrackSelection(audio: $audioStreamIndex, subtitle: $subtitleStreamIndex)';
}
/// Carries a whole choice over: [audio] and [subtitle] are the tracks
/// picked on the previous episode (null = nothing picked, keep defaults);
/// [subtitlesOff] means the viewer turned subtitles off, which carries over
/// as -1. [streams] are the next episode's streams.
TrackSelection matchTracks({
JellyfinMediaStream? audio,
JellyfinMediaStream? subtitle,
bool subtitlesOff = false,
required List<JellyfinMediaStream> streams,
}) =>
TrackSelection(
audioStreamIndex:
audio == null ? null : matchTrack(TrackPreference.fromStream(audio), streams)?.index,
subtitleStreamIndex: subtitlesOff
? -1
: subtitle == null
? null
: matchTrack(TrackPreference.fromStream(subtitle), streams)?.index,
);
const _iso1To2 = {
'en': 'eng', 'ja': 'jpn', 'de': 'deu', 'fr': 'fra', 'es': 'spa', 'it': 'ita',
'pt': 'por', 'ru': 'rus', 'zh': 'zho', 'ko': 'kor', 'ar': 'ara', 'nl': 'nld',
'pl': 'pol', 'sv': 'swe', 'no': 'nor', 'nb': 'nob', 'da': 'dan', 'fi': 'fin',
'cs': 'ces', 'sk': 'slk', 'hu': 'hun', 'ro': 'ron', 'el': 'ell', 'tr': 'tur',
'he': 'heb', 'hi': 'hin', 'th': 'tha', 'vi': 'vie', 'id': 'ind', 'ms': 'msa',
'uk': 'ukr', 'bg': 'bul', 'hr': 'hrv', 'sr': 'srp', 'fa': 'fas', 'ca': 'cat',
};
// ISO 639-2/B (bibliographic) -> 639-2/T, which Jellyfin mostly reports.
const _bToT = {
'ger': 'deu', 'fre': 'fra', 'chi': 'zho', 'dut': 'nld', 'cze': 'ces', 'slo': 'slk',
'gre': 'ell', 'rum': 'ron', 'per': 'fas', 'may': 'msa', 'baq': 'eus', 'arm': 'hye',
'geo': 'kat', 'ice': 'isl', 'mac': 'mkd', 'alb': 'sqi', 'bur': 'mya', 'wel': 'cym',
'tib': 'bod',
};
/// Lower-case ISO 639-2/T code, or null for none / "und".
String? normalizeLanguage(String? language) {
if (language == null) return null;
var l = language.trim().toLowerCase();
final dash = l.indexOf(RegExp(r'[-_]'));
if (dash > 0) l = l.substring(0, dash); // "en-US" -> "en"
if (l.isEmpty || l == 'und' || l == 'unk' || l == 'mis' || l == 'zxx') return null;
l = _iso1To2[l] ?? l;
return _bToT[l] ?? l;
}
const _stopWords = {'and', 'the', 'of', 'a'};
String _normTitle(String? t) => _titleTokens(t).join(' ');
List<String> _titleTokens(String? title) {
if (title == null) return const [];
return title
.toLowerCase()
.split(RegExp(r'[^a-z0-9À-￿]+'))
.where((w) => w.isNotEmpty && !_stopWords.contains(w))
.map((w) => w.length > 3 && w.endsWith('s') ? w.substring(0, w.length - 1) : w)
.toList();
}
double _jaccard(List<String> a, List<String> b) {
if (a.isEmpty && b.isEmpty) return 1;
final sa = a.toSet(), sb = b.toSet();
final inter = sa.intersection(sb).length;
return inter / sa.union(sb).length;
}
bool _eq(String? a, String? b) => a != null && b != null && a.toLowerCase() == b.toLowerCase();
int _compare(List<num> a, List<num> b) {
for (var i = 0; i < a.length; i++) {
final c = a[i].compareTo(b[i]);
if (c != 0) return c;
}
return 0;
}