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;
}
+12
View File
@@ -0,0 +1,12 @@
{
"User": {
"Name": "testuser",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "f00703a1d1ac44fe8a8a0ddf2ca05757",
"HasPassword": true,
"Policy": {"IsAdministrator": false}
},
"SessionInfo": {"Id": "0b9e1c2d3e4f5a6b7c8d9e0f1a2b3c4d", "UserId": "f00703a1d1ac44fe8a8a0ddf2ca05757", "Client": "WatchTogether", "DeviceId": "wt-test-device"},
"AccessToken": "TEST_ACCESS_TOKEN",
"ServerId": "122fce17c5b9413088af4be266bad6f4"
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+147
View File
@@ -0,0 +1,147 @@
[
{
"Name": "Monogatari",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "05eb67447682ff150ec4952fc50bbead",
"PremiereDate": "2009-07-02T23:00:00.0000000Z",
"OfficialRating": "GB-18",
"ChannelId": null,
"CommunityRating": 8.094,
"RunTimeTicks": 14400000000,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 86,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "05eb6744-7682-ff15-0ec4-952fc50bbead",
"ItemId": "05eb67447682ff150ec4952fc50bbead"
},
"ChildCount": 30,
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"DisplayOrder": "",
"ImageTags": {
"Primary": "bee58daaf7a98f8da69135796548201c",
"Thumb": "07435d43fff966521a761ea691ef4444",
"Logo": "02b7f5f02fa177a425225c7d1021ab57"
},
"BackdropImageTags": [
"1ee218592588355cd5c10daed8faa720"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2024-10-18T23:00:00.0000000Z"
},
{
"Name": "Urusei Yatsura",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "2f1ac9b2fb1d60bdcb3948d213683661",
"PremiereDate": "2022-10-14T00:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"CommunityRating": 6.9,
"RunTimeTicks": 13800000000,
"ProductionYear": 2022,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 46,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "2f1ac9b2-fb1d-60bd-cb39-48d213683661",
"ItemId": "2f1ac9b2fb1d60bdcb3948d213683661"
},
"ChildCount": 23,
"Status": "Ended",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Thumb": "c41e45b912a3685ee32499997fecc72b",
"Primary": "9c117448be3ca1f61fe026395fa437d4",
"Logo": "5470a8b93b5d43af3e2046bb4dab4ced"
},
"BackdropImageTags": [
"7c71bb4b01b4efff8dedbecbf3061cb2"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2024-06-21T00:00:00.0000000Z"
},
{
"Name": "Chained Soldier",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "6665dafb65dd99ace40bf84cefdab825",
"PremiereDate": "2024-01-04T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"CommunityRating": 7.643,
"RunTimeTicks": 14400000000,
"ProductionYear": 2024,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 12,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "6665dafb-65dd-99ac-e40b-f84cefdab825",
"ItemId": "6665dafb65dd99ace40bf84cefdab825"
},
"ChildCount": 12,
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Primary": "f8180f9631f8358a1aaf49747ae7cbd7",
"Thumb": "e83392a654c5a6fc5d42519b7c3621ac",
"Logo": "f90535d1adef8b99c7132b13aa3d07e4"
},
"BackdropImageTags": [
"05115da7b6eecf209426de7b137ffdd2"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2024-03-21T00:00:00.0000000Z"
},
{
"Name": "So What's Wrong with Getting Reborn as a Goblin?",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "525d4771d42b2f4426d16102e1bca69e",
"PremiereDate": "2026-10-05T00:00:00.0000000Z",
"ChannelId": null,
"RunTimeTicks": 0,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 2,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "525d4771-d42b-2f44-26d1-6102e1bca69e",
"ItemId": "525d4771d42b2f4426d16102e1bca69e"
},
"ChildCount": 2,
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Primary": "280f93626b92c12d03115d7121f2be3e",
"Logo": "44cc78e2021ca513586f8d48489198e8",
"Thumb": "cd23731a47d1c2e8d3973da1b99e564d"
},
"BackdropImageTags": [
"6dbbcc9ecb1f43bbf1e621d44196b51f"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2026-10-05T00:00:00.0000000Z"
}
]
+11
View File
@@ -0,0 +1,11 @@
{
"Name": "testuser",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "f00703a1d1ac44fe8a8a0ddf2ca05757",
"HasPassword": true,
"HasConfiguredPassword": true,
"HasConfiguredEasyPassword": false,
"EnableAutoLogin": false,
"LastLoginDate": "2026-05-02T17:06:16.9449873Z",
"LastActivityDate": "2026-10-06T17:16:17.677702Z"
}
File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
{
"Items": [
{
"Name": "Kingdom.of.Heaven",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "0f438cd4d983a9a36c3db3070464449e",
"HasSubtitles": true,
"Container": "mkv",
"ChannelId": null,
"RunTimeTicks": 86766720000,
"ProductionYear": 2005,
"IsFolder": false,
"Type": "Movie",
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "0f438cd4-d983-a9a3-6c3d-b3070464449e",
"ItemId": "0f438cd4d983a9a36c3db3070464449e"
},
"PrimaryImageAspectRatio": 1.7777777777777777,
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "6b5a5abf06bda07dd30dca56c95d3bbb"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "Dragon Ball: The Path to Power",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "38be917d9f88517f7e300f45d1408ccf",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "1996-03-04T00:00:00.0000000Z",
"OfficialRating": "Unrated",
"ChannelId": null,
"Overview": "A retelling of Dragon Ball's origin with a different take on the meeting of Goku, Bulma, and Kame-Sen'nin. It also retells the Red Ribbon Army story; but this time they find Goku rather than Goku finding them.",
"CommunityRating": 7.371,
"RunTimeTicks": 47779399999,
"ProductionYear": 1996,
"IsFolder": false,
"Type": "Movie",
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "38be917d-9f88-517f-7e30-0f45d1408ccf",
"ItemId": "38be917d9f88517f7e300f45d1408ccf"
},
"PrimaryImageAspectRatio": 0.7103825136612022,
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "50e9b29ee301f3822b263ae8d5f05cdd",
"Thumb": "b2f758f6bbe3a0f2c1b625434ef76746",
"Logo": "d8f0c21f570422cc5827af4cbb77443f"
},
"BackdropImageTags": [
"2a104d1fb000cb9a05869f669d72740d"
],
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "A Minecraft Movie",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "dcc7f3e1edc1a6ddd5f6115840373dd1",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2025-03-31T00:00:00.0000000Z",
"OfficialRating": "PG",
"ChannelId": null,
"Overview": "Four misfits find themselves struggling with ordinary problems when they are suddenly pulled through a mysterious portal into the Overworld: a bizarre, cubic wonderland that thrives on imagination. To get back home, they'll have to master this world while embarking on a magical quest with an unexpected, expert crafter, Steve.",
"CommunityRating": 6.531,
"RunTimeTicks": 60538810000,
"ProductionYear": 2025,
"IsFolder": false,
"Type": "Movie",
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "dcc7f3e1-edc1-a6dd-d5f6-115840373dd1",
"ItemId": "dcc7f3e1edc1a6ddd5f6115840373dd1"
},
"PrimaryImageAspectRatio": 0.6666666666666666,
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "85010a7ad576af2af569bfd16cef7e84",
"Logo": "7569b768391168d3b7cacae652973101",
"Thumb": "6da12b5a347e998da21409be24b6ee7e"
},
"BackdropImageTags": [
"3e59bb10ac7eb85d83e15af8eaa0dfa2"
],
"LocationType": "FileSystem",
"MediaType": "Video"
}
],
"TotalRecordCount": 3,
"StartIndex": 0
}
+141
View File
@@ -0,0 +1,141 @@
{
"Items": [
{
"Name": "Rice and Thieves and Heroes",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "b18826be994070a79f9b52bc0f66f052",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2022-08-12T23:00:00.0000000Z",
"ChannelId": null,
"Overview": "Kelvin sets off on a new journey in search of more powerful foes... and rice?!",
"CommunityRating": 7.143,
"RunTimeTicks": 14201120000,
"ProductionYear": 2022,
"IndexNumber": 6,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentBackdropItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentBackdropImageTags": [
"0cac7765a1364090b549bde6aa29cd58"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "416588001006",
"ItemId": "b18826be994070a79f9b52bc0f66f052"
},
"SeriesName": "Black Summoner",
"SeriesId": "e9aec9c14a1fcab775111844112b4cf7",
"SeasonId": "fb0b77cfda15445f9319ed9d753dba23",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "e62d963bb15310945fadffaef0334c35",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "3caf8126c9a3e5dd0dc6f50317707808"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "fe59b8bb26fe8d676956311969a816a4",
"ParentThumbItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentThumbImageTag": "8e40321ea0aca027e3407eb6b76c21ba",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "The Desert-Born Outlaws",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "28465756997333b4a6aa4b0039013da8",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2026-10-01T23:00:00.0000000Z",
"ChannelId": null,
"CommunityRating": 1,
"RunTimeTicks": 15120940000,
"ProductionYear": 2026,
"IndexNumber": 3,
"ParentIndexNumber": 6,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "5055eea092a576ea09eee3b6527ba857",
"ParentBackdropItemId": "5055eea092a576ea09eee3b6527ba857",
"ParentBackdropImageTags": [
"a79a786b6c84dc70b1214a1955d8274a"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "262954006003",
"ItemId": "28465756997333b4a6aa4b0039013da8"
},
"SeriesName": "JoJo's Bizarre Adventure",
"SeriesId": "5055eea092a576ea09eee3b6527ba857",
"SeasonId": "026b88dad1068c1478fc998c397fed12",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "86496a70700d7e488e87443bb89132bd",
"SeasonName": "Season 6",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "54546a638b2ae556f29bfe309da867bf"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "fcaf370687c63e4291adfa3d1525e7b0",
"ParentThumbItemId": "5055eea092a576ea09eee3b6527ba857",
"ParentThumbImageTag": "3e85d0ca4c63b5ad7399f9e8660cde37",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "The 100 Girlfriends Who Really, Really, Really, Really, REALLY Love You - S03E08 - Kusuri-Senpai's Grandmother WEBDL",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "c03340f1656bcc4299b6f7512edb685d",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "1970-01-01T00:00:00.0000000Z",
"ChannelId": null,
"RunTimeTicks": 14301630000,
"ProductionYear": 1970,
"IndexNumber": 8,
"ParentIndexNumber": 3,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "ef10dde161235f90e8e74a08d9e7a5d2",
"ParentBackdropItemId": "ef10dde161235f90e8e74a08d9e7a5d2",
"ParentBackdropImageTags": [
"f803fa33c65061f1db7a9a828d471cee"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "432050003008",
"ItemId": "c03340f1656bcc4299b6f7512edb685d"
},
"SeriesName": "The 100 Girlfriends Who Really, Really, Really, Really, REALLY Love You",
"SeriesId": "ef10dde161235f90e8e74a08d9e7a5d2",
"SeasonId": "9ef59421412abf02c4533b389ed40495",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "68864a1bebb104897cc86a99895e8605",
"SeasonName": "Season 3",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "741aaebd6279f7f08c58ccfe11008ea2"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "7054282a1c940755ae402362a9c02640",
"ParentThumbItemId": "ef10dde161235f90e8e74a08d9e7a5d2",
"ParentThumbImageTag": "ef9d6c7d5ff08671067df0d3d596528e",
"LocationType": "FileSystem",
"MediaType": "Video"
}
],
"TotalRecordCount": 44,
"StartIndex": 0
}
+622
View File
@@ -0,0 +1,622 @@
{
"MediaSources": [
{
"Protocol": "File",
"Id": "b18826be994070a79f9b52bc0f66f052",
"Path": "/media/Black Summoner - S01E06 - Rice and Thieves and Heroes WEBDL-1080p.mkv",
"Type": "Default",
"Container": "mkv",
"Size": 1525319390,
"Name": "Black Summoner - S01E06 - Rice and Thieves and Heroes WEBDL-1080p",
"IsRemote": false,
"ETag": "d5e0caa01d1fda0b9da06368887e6afb",
"RunTimeTicks": 14201120000,
"ReadAtNativeFramerate": false,
"IgnoreDts": false,
"IgnoreIndex": false,
"GenPtsInput": false,
"SupportsTranscoding": true,
"SupportsDirectStream": true,
"SupportsDirectPlay": true,
"IsInfiniteStream": false,
"UseMostCompatibleTranscodingProfile": false,
"RequiresOpening": false,
"RequiresClosing": false,
"RequiresLooping": false,
"SupportsProbing": true,
"VideoType": "VideoFile",
"MediaStreams": [
{
"Codec": "h264",
"ColorSpace": "bt709",
"ColorTransfer": "bt709",
"ColorPrimaries": "bt709",
"TimeBase": "1/1000",
"Title": "CR WEB-DL",
"VideoRange": "SDR",
"VideoRangeType": "SDR",
"AudioSpatialFormat": "None",
"DisplayTitle": "CR WEB-DL - 1080p - H264 - SDR",
"NalLengthSize": "4",
"IsInterlaced": false,
"IsAVC": true,
"BitRate": 8592670,
"BitDepth": 8,
"RefFrames": 1,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 1080,
"Width": 1920,
"AverageFrameRate": 23.976025,
"RealFrameRate": 23.976025,
"ReferenceFrameRate": 23.976025,
"Profile": "High",
"Type": "Video",
"AspectRatio": "16:9",
"Index": 0,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"PixelFormat": "yuv420p",
"Level": 40,
"IsAnamorphic": false
},
{
"Codec": "eac3",
"Language": "jpn",
"TimeBase": "1/1000",
"Title": "Japanese",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "Japanese - Dolby Digital+ - Stereo - Default",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "stereo",
"BitRate": 224000,
"Channels": 2,
"SampleRate": 48000,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Type": "Audio",
"Index": 1,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "eac3",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "English",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "English - Dolby Digital+ - Stereo - Default",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "stereo",
"BitRate": 224000,
"Channels": 2,
"SampleRate": 48000,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Type": "Audio",
"Index": 2,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "ass",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "English (Full Subtitle)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "English (Full Subtitle) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 3,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "English (Signs) [Generated]",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "English (Signs) [Generated] - Forced - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": true,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 4,
"Score": 121221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "subrip",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "English (CC)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "English (CC) - Hearing Impaired - SUBRIP",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": true,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 5,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "ara",
"TimeBase": "1/1000",
"Title": "Arabic (Saudi Arabia)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Arabic (Saudi Arabia) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 6,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "deu",
"TimeBase": "1/1000",
"Title": "German",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "German - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 7,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "spa",
"TimeBase": "1/1000",
"Title": "Spanish (Latin America)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Spanish (Latin America) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 8,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "spa",
"TimeBase": "1/1000",
"Title": "Spanish (Spain)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Spanish (Spain) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 9,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "fra",
"TimeBase": "1/1000",
"Title": "French",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "French - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 10,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "ind",
"TimeBase": "1/1000",
"Title": "Indonesian",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Indonesian - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 11,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "ita",
"TimeBase": "1/1000",
"Title": "Italian",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Italian - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 12,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "msa",
"TimeBase": "1/1000",
"Title": "Malay",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Malay - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 13,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "por",
"TimeBase": "1/1000",
"Title": "Portuguese (Brazil)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Portuguese (Brazil) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 14,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "rus",
"TimeBase": "1/1000",
"Title": "Russian",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Russian - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 15,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "tha",
"TimeBase": "1/1000",
"Title": "Thai",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Thai - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 16,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "vie",
"TimeBase": "1/1000",
"Title": "Vietnamese",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Vietnamese - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 17,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "zho",
"TimeBase": "1/1000",
"Title": "Chinese (Simplified)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Chinese (Simplified) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 18,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "zho",
"TimeBase": "1/1000",
"Title": "Chinese (Traditional)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Chinese (Traditional) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 19,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
}
],
"Formats": [],
"Bitrate": 9040670,
"RequiredHttpHeaders": {},
"TranscodingSubProtocol": "http",
"DefaultAudioStreamIndex": 1,
"DefaultSubtitleStreamIndex": 3,
"HasSegments": false
}
],
"PlaySessionId": "fc428dbbeb9c46a7885fa053bd46f7e7"
}
+9
View File
@@ -0,0 +1,9 @@
{
"LocalAddress": "https://example.invalid",
"ServerName": "Example Server",
"Version": "10.11.11",
"ProductName": "Jellyfin Server",
"OperatingSystem": "",
"Id": "122fce17c5b9413088af4be266bad6f4",
"StartupWizardCompleted": true
}
@@ -0,0 +1 @@
{"Authenticated":false,"Secret":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","Code":"437392","DeviceId":"wt-test-device","DeviceName":"test","AppName":"WatchTogether","AppVersion":"0.1.0","DateAdded":"2026-10-06T22:29:05.6874903Z"}
+147
View File
@@ -0,0 +1,147 @@
{
"Items": [
{
"Name": "A Secret Shared with the Guild, and a Slave Girl",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "b79037c4a4c2707287dd4a29965220f3",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2022-07-22T23:00:00.0000000Z",
"ChannelId": null,
"Overview": "After defeating the Blackghost Knight, Kelvin now has powerful servants at his command. But he needs another party member, and he has his eyes set on a certain half-elf slave...",
"CommunityRating": 7.375,
"RunTimeTicks": 14200480000,
"ProductionYear": 2022,
"IndexNumber": 3,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentBackdropItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentBackdropImageTags": [
"0cac7765a1364090b549bde6aa29cd58"
],
"UserData": {
"PlayedPercentage": 85.06994129775894,
"PlaybackPositionTicks": 12080340000,
"PlayCount": 2,
"IsFavorite": false,
"LastPlayedDate": "2026-10-06T12:10:32.978Z",
"Played": true,
"Key": "b79037c4-a4c2-7072-87dd-4a29965220f3",
"ItemId": "b79037c4a4c2707287dd4a29965220f3"
},
"SeriesName": "Black Summoner",
"SeriesId": "e9aec9c14a1fcab775111844112b4cf7",
"SeasonId": "fb0b77cfda15445f9319ed9d753dba23",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "e62d963bb15310945fadffaef0334c35",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "35d3062104a44d07f4d444847d2d847f"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "fe59b8bb26fe8d676956311969a816a4",
"ParentThumbItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentThumbImageTag": "8e40321ea0aca027e3407eb6b76c21ba",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "From Old Country Bumpkin to Master Swordsman - S01E10 - The Old Country Bumpkin Guards a Princess",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "43c0f58d4f0c13e4df01d737b695fae7",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2025-06-07T00:00:00.0000000Z",
"ChannelId": null,
"Overview": "The Old Country Bumpkin Guards a Princess",
"RunTimeTicks": 14190920000,
"ProductionYear": 2025,
"IndexNumber": 10,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentBackdropItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentBackdropImageTags": [
"dc43552819fa7d366704ff8095c9ade3"
],
"UserData": {
"PlayedPercentage": 71.43748044524246,
"PlaybackPositionTicks": 10137635700,
"PlayCount": 2,
"IsFavorite": false,
"LastPlayedDate": "2026-08-07T07:33:27.4085253Z",
"Played": false,
"Key": "43c0f58d-4f0c-13e4-df01-d737b695fae7",
"ItemId": "43c0f58d4f0c13e4df01d737b695fae7"
},
"SeriesName": "From Old Country Bumpkin to Master Swordsman",
"SeriesId": "4ec83fddae279cb643ee43fd98b46fae",
"SeasonId": "dd6bcdb07a02ca4703cf0bbbbb8756dd",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "706c2f150135aca34013456b52f2e831",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "cd3442706567c380bd407071fabd8fe4"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "e084254c0c748aab184892550afbb359",
"ParentThumbItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentThumbImageTag": "5fa59d330afc0100a2f98689abab3895",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "The Old Country Bumpkin Watches Them Grow",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "3719831143416b4b4a99e1d7767863c4",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2026-07-14T23:00:00.0000000Z",
"ChannelId": null,
"RunTimeTicks": 14130560000,
"ProductionYear": 2026,
"IndexNumber": 2,
"ParentIndexNumber": 2,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentBackdropItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentBackdropImageTags": [
"dc43552819fa7d366704ff8095c9ade3"
],
"UserData": {
"PlayedPercentage": 75.48837837990851,
"PlaybackPositionTicks": 10666930600,
"PlayCount": 1,
"IsFavorite": false,
"LastPlayedDate": "2026-08-07T07:30:29.3856702Z",
"Played": false,
"Key": "37198311-4341-6b4b-4a99-e1d7767863c4",
"ItemId": "3719831143416b4b4a99e1d7767863c4"
},
"SeriesName": "From Old Country Bumpkin to Master Swordsman",
"SeriesId": "4ec83fddae279cb643ee43fd98b46fae",
"SeasonId": "bd5bfb3cf98a2b4459c1822c3ed05708",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "706c2f150135aca34013456b52f2e831",
"SeasonName": "Season 2",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "ff28bd9900f55a4ebdddefcfa1fdfa94"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "e084254c0c748aab184892550afbb359",
"ParentThumbItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentThumbImageTag": "5fa59d330afc0100a2f98689abab3895",
"LocationType": "FileSystem",
"MediaType": "Video"
}
],
"TotalRecordCount": 6,
"StartIndex": 0
}
+41
View File
@@ -0,0 +1,41 @@
{
"Items": [
{
"Name": "Arifureta: From Commonplace to World's Strongest",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "550eff23b37ec61082c988678ccc214c",
"PremiereDate": "2019-07-08T00:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"CommunityRating": 7,
"RunTimeTicks": 14400000000,
"ProductionYear": 2019,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 41,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "550eff23-b37e-c610-82c9-88678ccc214c",
"ItemId": "550eff23b37ec61082c988678ccc214c"
},
"Status": "Continuing",
"AirDays": [],
"ImageTags": {
"Thumb": "75adcf7a63337d9888fa5d994779c879",
"Primary": "dec730fd3bd5791ff7bb4eb9be416d4a",
"Logo": "8c0b809b58b8d300454e6d5aac22c334"
},
"BackdropImageTags": [
"a47f1e94c9e55509901bc1ea5c6fc743"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2025-02-17T00:00:00.0000000Z"
}
],
"TotalRecordCount": 1,
"StartIndex": 0
}
+80
View File
@@ -0,0 +1,80 @@
{
"Items": [
{
"Name": "Season 2",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "331a23499d051bc288d281f2c72557a9",
"PremiereDate": "2022-01-13T00:00:00.0000000Z",
"ChannelId": null,
"ProductionYear": 2022,
"IndexNumber": 2,
"IsFolder": true,
"Type": "Season",
"ParentLogoItemId": "550eff23b37ec61082c988678ccc214c",
"ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c",
"ParentBackdropImageTags": [
"a47f1e94c9e55509901bc1ea5c6fc743"
],
"UserData": {
"UnplayedItemCount": 12,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "357019002",
"ItemId": "331a23499d051bc288d281f2c72557a9"
},
"SeriesName": "Arifureta: From Commonplace to World's Strongest",
"SeriesId": "550eff23b37ec61082c988678ccc214c",
"SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a",
"ImageTags": {
"Primary": "a9324a83f2d95bd0e355af4d6bae8d8e"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334",
"ParentThumbItemId": "550eff23b37ec61082c988678ccc214c",
"ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879",
"LocationType": "FileSystem",
"MediaType": "Unknown"
},
{
"Name": "Season 3",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "fc4506ab4a72e877de564512df943912",
"PremiereDate": "2024-10-14T00:00:00.0000000Z",
"ChannelId": null,
"ProductionYear": 2024,
"IndexNumber": 3,
"IsFolder": true,
"Type": "Season",
"ParentLogoItemId": "550eff23b37ec61082c988678ccc214c",
"ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c",
"ParentBackdropImageTags": [
"a47f1e94c9e55509901bc1ea5c6fc743"
],
"UserData": {
"UnplayedItemCount": 29,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "357019003",
"ItemId": "fc4506ab4a72e877de564512df943912"
},
"SeriesName": "Arifureta: From Commonplace to World's Strongest",
"SeriesId": "550eff23b37ec61082c988678ccc214c",
"SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a",
"ImageTags": {
"Primary": "1aa8960ccda05eacdb423d7681959e68"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334",
"ParentThumbItemId": "550eff23b37ec61082c988678ccc214c",
"ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879",
"LocationType": "FileSystem",
"MediaType": "Unknown"
}
],
"TotalRecordCount": 2,
"StartIndex": 0
}
+136
View File
@@ -0,0 +1,136 @@
{
"Name": "Arifureta: From Commonplace to World's Strongest",
"OriginalTitle": "ありふれた職業で世界最強",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "550eff23b37ec61082c988678ccc214c",
"Etag": "d4a24baa76616db3b2b8b8c1e0d71abb",
"DateCreated": "2025-05-07T14:25:18.703198Z",
"DateLastMediaAdded": "2026-05-02T23:41:55.0996881Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "arifureta: from commonplace to worlds strongest",
"PremiereDate": "2019-07-08T00:00:00.0000000Z",
"Path": "/media/Arifureta - From Commonplace to World's Strongest",
"EnableMediaSourceDisplay": true,
"OfficialRating": "TV-14",
"ChannelId": null,
"Overview": "Seventeen-year-old Hajime Nagumo is your average, everyday otaku. However, his simple life of pulling all-nighters and sleeping in school is suddenly turned upside down when he, along with the rest of his class, is summoned to a fantasy world! They're treated like heroes and tasked with the duty of saving the human race from utter extinction. But what should have been any otaku's wet dream quickly turns into Hajime's nightmare. While the rest of his class are blessed with godlike powers, Hajime's job, Synergist, only has a single transmutation skill. Ridiculed and bullied by his classmates for being weak, he soon finds himself in despair. Will he be able to survive in this dangerous world of monsters and demons with only a glorified blacksmith's level of strength?",
"Taglines": [],
"Genres": [
"Action",
"Adventure",
"Animation",
"Anime",
"Comedy",
"Fantasy",
"Romance"
],
"CommunityRating": 7,
"CumulativeRunTimeTicks": 14400000000,
"RunTimeTicks": 14400000000,
"PlayAccess": "Full",
"ProductionYear": 2019,
"RemoteTrailers": [
{
"Url": "https://www.youtube.com/watch?v=LHmdL6x9-kw"
}
],
"ProviderIds": {
"Imdb": "tt10431290",
"Tmdb": "86034",
"TvMaze": "42647",
"Tvdb": "357019"
},
"IsFolder": true,
"ParentId": "703778056c3d84357a1071566e9e67f0",
"Type": "Series",
"Studios": [
{
"Name": "AT-X",
"Id": "8846d500e2ea8fab0f33f774bbd3dae8"
}
],
"GenreItems": [
{
"Name": "Action",
"Id": "ce06903d834d2c3417e0889dd4049f3b"
},
{
"Name": "Adventure",
"Id": "51cec9645b896084d12b259acd05ccb1"
},
{
"Name": "Animation",
"Id": "074229225b7254292f131f9f49f8dcd6"
},
{
"Name": "Anime",
"Id": "f89b4d4d7733020ed2721d8fec37f26c"
},
{
"Name": "Comedy",
"Id": "08d31605d366d63a7a924f944b4417f1"
},
{
"Name": "Fantasy",
"Id": "a30dcc65be22eb3c21c03f7c1c7a57d1"
},
{
"Name": "Romance",
"Id": "1ffc72e19987e5fa4047c6a6870646cf"
}
],
"LocalTrailerCount": 0,
"UserData": {
"PlayedPercentage": 0,
"UnplayedItemCount": 41,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "550eff23-b37e-c610-82c9-88678ccc214c",
"ItemId": "550eff23b37ec61082c988678ccc214c"
},
"RecursiveItemCount": 41,
"ChildCount": 2,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "f63033ff6886ecc7083a696cbeced1b0",
"Status": "Continuing",
"AirDays": [],
"Tags": [
"vampire",
"high school",
"harem",
"eye patch",
"adventurer",
"anime",
"sword and sorcery",
"japanese high school",
"isekai",
"human vampire relationship",
"guns",
"vampiress (female vampire)",
"based on light novel",
"adventure",
"classmates relationship",
"teen fantasy",
"transported to another world",
"marie antoinette syndrome",
"progression fantasy"
],
"PrimaryImageAspectRatio": 0.68,
"ImageTags": {
"Banner": "65c09ab8820443a6f4e3901761afe309",
"Thumb": "75adcf7a63337d9888fa5d994779c879",
"Primary": "dec730fd3bd5791ff7bb4eb9be416d4a",
"Logo": "8c0b809b58b8d300454e6d5aac22c334"
},
"BackdropImageTags": [
"a47f1e94c9e55509901bc1ea5c6fc743"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2025-02-17T00:00:00.0000000Z",
"LockedFields": [],
"LockData": false
}
+178
View File
@@ -0,0 +1,178 @@
{
"Items": [
{
"Name": "The \"Hentai\" Prince and the Stony Cat.",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "641983c75927d5537627e94c17b3e64b",
"PremiereDate": "2013-04-13T00:00:00.0000000Z",
"OfficialRating": "16+",
"ChannelId": null,
"Overview": "The story centers around a second-year high school boy named Yokodera Youto. Youto is always thinking about his carnal desires, but no one acknowledges him as a pervert. He learns about a cat statue that supposedly grants wishes. The boy goes to pray that he will be able to express his lustful thoughts whenever and wherever he wants. At the statue, Youto encounters Tsutsukakushi Tsukiko, a girl from his high school with her own wish - that she would not display her real intentions so readily.",
"CommunityRating": 6.7,
"RunTimeTicks": 14400000000,
"ProductionYear": 2013,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 12,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "641983c7-5927-d553-7627-e94c17b3e64b",
"ItemId": "641983c75927d5537627e94c17b3e64b"
},
"Status": "Ended",
"AirDays": [],
"PrimaryImageAspectRatio": 0.68,
"ImageTags": {
"Logo": "f80eeb7006d8c06ca6f6c2aa7ac7d4f7",
"Primary": "ab8565151c2041121d6d029dd887b4bc",
"Thumb": "5d8cd55d4fcd3d4e30c2a14b8a2b0b01"
},
"BackdropImageTags": [
"936ef50544a0ec3c90cd0b87933380ab"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2013-06-29T00:00:00.0000000Z"
},
{
"Name": "The 100 Girlfriends Who Really, Really, Really, Really, REALLY Love You",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "ef10dde161235f90e8e74a08d9e7a5d2",
"PremiereDate": "2023-10-07T23:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"Overview": "Rentaro Aijo was rejected 100 times in middle school. He visits a shrine and prays for better luck in high school. The God of Love appears and promises that he'll soon meet 100 people he's destined to date. But there's a catch—once destiny introduces someone to him, the two must happily love each other. If they don't, they'll die. What will befall Rentaro and his 100 girlfriends in high school?",
"CommunityRating": 8.191,
"RunTimeTicks": 13800000000,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 5,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "432050",
"ItemId": "ef10dde161235f90e8e74a08d9e7a5d2"
},
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Logo": "7054282a1c940755ae402362a9c02640",
"Primary": "68864a1bebb104897cc86a99895e8605",
"Thumb": "ef9d6c7d5ff08671067df0d3d596528e"
},
"BackdropImageTags": [
"f803fa33c65061f1db7a9a828d471cee"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2026-07-04T23:00:00.0000000Z"
},
{
"Name": "An Adventurer's Daily Grind at Age 29",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "d8f4c16028dfbe5fceea58fbca1e5dde",
"PremiereDate": "2026-01-07T00:00:00.0000000Z",
"ChannelId": null,
"Overview": "Born and raised in the slums, 29-year-old adventurer Hajime Shinonome now lives a quiet life in Komai Village. But his peaceful days take a turn when he discovers Lirui, an abandoned young girl, being attacked by a slime. Moved by her desperation and reminded of his own past, he takes her back to the village. Hajime and Lirui begin a new chapter filled with unexpected challenges and gentle days!",
"RunTimeTicks": 0,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 1,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "d8f4c160-28df-be5f-ceea-58fbca1e5dde",
"ItemId": "d8f4c16028dfbe5fceea58fbca1e5dde"
},
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Thumb": "c719cde4ea4edfe7c6af878176b97893",
"Primary": "e9232151950ab8b77b1ad75a146b8ad1",
"Logo": "4ba4c8e585bf1d69258c69275dc9dace"
},
"BackdropImageTags": [
"a1fbebbc6001c31b5d31d1618bc4e02f"
],
"LocationType": "FileSystem",
"MediaType": "Unknown"
},
{
"Name": "All Purpose Cultural Cat Girl Nuku Nuku",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "5dc14d44923d13244827cb8dea8b021b",
"PremiereDate": "1992-10-20T00:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"Overview": "A scientist, his son and an incomplete biotech-type android are sneaking away from the scientist's psychotic wife.",
"CommunityRating": 6.9,
"RunTimeTicks": 16800000000,
"ProductionYear": 1992,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 6,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "5dc14d44-923d-1324-4827-cb8dea8b021b",
"ItemId": "5dc14d44923d13244827cb8dea8b021b"
},
"Status": "Ended",
"AirDays": [],
"PrimaryImageAspectRatio": 0.68,
"ImageTags": {
"Logo": "a30fabfe01b6d52bf8517a3f587acc3d",
"Primary": "10bf60033861fbb49f3d8b9114e0574c"
},
"BackdropImageTags": [
"31617fcc4d1ac4d07bafaca9aa78bf4a"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "1998-12-09T00:00:00.0000000Z"
},
{
"Name": "Alma-chan Wants to Be a Family!",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "9650f99aff2a6f8c0760f1960e6f3e2e",
"PremiereDate": "2025-10-05T00:00:00.0000000Z",
"ChannelId": null,
"Overview": "A body that combines the appearance of an adorable young girl with overwhelming combat efficiency. An AI with intelligence and decision-making skills that surpass human capabilities—the perfect autonomous robot, \"Alma\" was created by the overlooked young genius scientists Enji and Suzume whose brilliance should've been recognized by the world! Alma calls Enji and Suzume \"Father\" and \"Mother\" which leaves them completely flustered. Regardless, the three begin a life together as a family in order to nurture Alma's powerful AI and help her evolve into the ultimate robot. Alma's curiosity has no limits as everything she sees and touches is new to her. Alma keeps Enji and Suzume on their toes with her unmatched learning capabilities and unstoppable energy as she explores the bigger world. A brand-new kind of family comedy (?!) begins!!",
"RunTimeTicks": 0,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 9,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "9650f99a-ff2a-6f8c-0760-f1960e6f3e2e",
"ItemId": "9650f99aff2a6f8c0760f1960e6f3e2e"
},
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Logo": "a82211f52d8ba53e372c2d2b1ee4f875",
"Primary": "131e571c94242ad5988b6720fb6bfb73"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Unknown"
}
],
"TotalRecordCount": 218,
"StartIndex": 0
}
+147
View File
@@ -0,0 +1,147 @@
{
"Items": [
{
"Name": "Home Videos and Photos",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "ee75511ae395034b1e7e657707b15125",
"Etag": "dfde42d71f54840418f9dc0dbdeba7f7",
"DateCreated": "2026-07-29T11:24:09.6344657Z",
"DateLastMediaAdded": "0001-01-01T00:00:00.0000000Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "home videos and photos",
"Path": "/media/Home Videos and Photos",
"EnableMediaSourceDisplay": true,
"ChannelId": null,
"Taglines": [],
"Genres": [],
"PlayAccess": "Full",
"RemoteTrailers": [],
"ProviderIds": {},
"IsFolder": true,
"ParentId": "e9d5075a555c1cbc394eec4cef295274",
"Type": "CollectionFolder",
"Studios": [],
"GenreItems": [],
"LocalTrailerCount": 0,
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "ee75511a-e395-034b-1e7e-657707b15125",
"ItemId": "ee75511ae395034b1e7e657707b15125"
},
"ChildCount": 5,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "ee75511ae395034b1e7e657707b15125",
"Tags": [],
"PrimaryImageAspectRatio": 1.7777777777777777,
"CollectionType": "homevideos",
"ImageTags": {
"Primary": "5362f3bd0bdcc65ba270901419b25f5a"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"LockedFields": [],
"LockData": false
},
{
"Name": "Movies",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "f137a2dd21bbc1b99aa5c0f6bf02a805",
"Etag": "0caf9bb08808c2d953883faf04ad1f24",
"DateCreated": "2025-05-25T14:55:08.1257922Z",
"DateLastMediaAdded": "0001-01-01T00:00:00.0000000Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "movies",
"Path": "/media/Movies",
"EnableMediaSourceDisplay": true,
"ChannelId": null,
"Taglines": [],
"Genres": [],
"PlayAccess": "Full",
"RemoteTrailers": [],
"ProviderIds": {},
"IsFolder": true,
"ParentId": "e9d5075a555c1cbc394eec4cef295274",
"Type": "CollectionFolder",
"Studios": [],
"GenreItems": [],
"LocalTrailerCount": 0,
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "f137a2dd-21bb-c1b9-9aa5-c0f6bf02a805",
"ItemId": "f137a2dd21bbc1b99aa5c0f6bf02a805"
},
"ChildCount": 8,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "f137a2dd21bbc1b99aa5c0f6bf02a805",
"Tags": [],
"PrimaryImageAspectRatio": 1.7777777777777777,
"CollectionType": "movies",
"ImageTags": {
"Primary": "79616b543c8fab3f8a13d958ab093aad"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"LockedFields": [],
"LockData": false
},
{
"Name": "Shows",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "a656b907eb3a73532e40e44b968d0225",
"Etag": "e68ec49fd39c5d0e50d5f07bf0d5c239",
"DateCreated": "2025-05-25T14:54:44.9810996Z",
"DateLastMediaAdded": "0001-01-01T00:00:00.0000000Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "shows",
"Path": "/media/Shows",
"EnableMediaSourceDisplay": true,
"ChannelId": null,
"Taglines": [],
"Genres": [],
"PlayAccess": "Full",
"RemoteTrailers": [],
"ProviderIds": {},
"IsFolder": true,
"ParentId": "e9d5075a555c1cbc394eec4cef295274",
"Type": "CollectionFolder",
"Studios": [],
"GenreItems": [],
"LocalTrailerCount": 0,
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "a656b907-eb3a-7353-2e40-e44b968d0225",
"ItemId": "a656b907eb3a73532e40e44b968d0225"
},
"ChildCount": 1,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "a656b907eb3a73532e40e44b968d0225",
"Tags": [],
"PrimaryImageAspectRatio": 1.7777777777777777,
"CollectionType": "tvshows",
"ImageTags": {
"Primary": "b9a66a993f6c977d989d8daf213c6a59"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"LockedFields": [],
"LockData": false
}
],
"TotalRecordCount": 3,
"StartIndex": 0
}
@@ -0,0 +1,373 @@
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
/// Records requests and answers from captured fixtures (test/fixtures/jellyfin,
/// captured from a Jellyfin 10.11.11 server, personal data stripped).
class FakeHttp implements JellyfinHttp {
final requests = <({String method, Uri uri, Map<String, String> headers, String? body})>[];
final routes = <String, JellyfinHttpResponse>{};
void on(String method, String path, {String? fixture, String body = '', int status = 200}) {
routes['$method $path'] =
JellyfinHttpResponse(status, fixture == null ? body : _fixture(fixture));
}
@override
Future<JellyfinHttpResponse> send(String method, Uri uri,
{Map<String, String> headers = const {}, String? body}) async {
requests.add((method: method, uri: uri, headers: headers, body: body));
return routes['$method ${uri.path}'] ?? const JellyfinHttpResponse(404, '');
}
@override
void close() {}
({String method, Uri uri, Map<String, String> headers, String? body}) get last => requests.last;
}
String _fixture(String name) => File('test/fixtures/jellyfin/$name.json').readAsStringSync();
const info = JellyfinClientInfo(
client: 'Watch Together', device: 'Test PC', deviceId: 'dev-1', version: '1.2.3');
const uid = 'f00703a1d1ac44fe8a8a0ddf2ca05757';
void main() {
late FakeHttp http;
late JellyfinClient client;
setUp(() {
http = FakeHttp();
client = JellyfinClient(
baseUrl: 'https://example.invalid/', clientInfo: info, accessToken: 'TOK', userId: uid, http: http);
});
group('auth header', () {
test('has all four client fields, token only when given, values encoded', () {
expect(info.authorizationHeader(),
'MediaBrowser Client="Watch%20Together", Device="Test%20PC", DeviceId="dev-1", Version="1.2.3"');
expect(info.authorizationHeader('abc'), endsWith(', Token="abc"'));
});
});
group('server + auth', () {
test('publicInfo', () async {
http.on('GET', '/System/Info/Public', fixture: 'pub');
final s = await client.publicInfo();
expect(s.id, '122fce17c5b9413088af4be266bad6f4');
expect(s.version, '10.11.11');
expect(s.name, 'Example Server');
expect(http.last.headers['Authorization'], isNot(contains('Token=')));
});
test('Quick Connect: initiate is a POST without token, then sign-in stores the token', () async {
final anon = JellyfinClient(baseUrl: 'https://example.invalid', clientInfo: info, http: http);
http.on('POST', '/QuickConnect/Initiate', fixture: 'quickconnect_initiate');
final qc = await anon.quickConnectInitiate();
expect(qc.code, '437392');
expect(qc.secret, hasLength(64));
expect(http.last.method, 'POST');
expect(http.last.headers['Authorization'], info.authorizationHeader());
var polls = 0;
http.on('GET', '/QuickConnect/Connect', body: jsonEncode({'Authenticated': false, 'Secret': qc.secret, 'Code': qc.code}));
http.on('POST', '/Users/AuthenticateWithQuickConnect', fixture: 'auth_result');
final result = await anon.waitForQuickConnect(qc, interval: Duration.zero, isCancelled: () {
if (++polls == 3) {
http.on('GET', '/QuickConnect/Connect',
body: jsonEncode({'Authenticated': true, 'Secret': qc.secret, 'Code': qc.code}));
}
return false;
});
expect(http.requests.where((r) => r.uri.path == '/QuickConnect/Connect').first.uri.queryParameters['secret'],
qc.secret);
expect(jsonDecode(http.last.body!), {'Secret': qc.secret});
expect(result!.accessToken, 'TEST_ACCESS_TOKEN');
expect(result.user.name, 'testuser');
expect(result.serverId, '122fce17c5b9413088af4be266bad6f4');
expect(anon.accessToken, 'TEST_ACCESS_TOKEN');
expect(anon.userId, uid);
});
test('Quick Connect wait can be cancelled', () async {
http.on('GET', '/QuickConnect/Connect', body: jsonEncode({'Authenticated': false}));
final r = await client.waitForQuickConnect(
const QuickConnectSession(code: '1', secret: 's'),
interval: Duration.zero,
isCancelled: () => http.requests.length >= 2);
expect(r, isNull);
});
test('password sign-in', () async {
final anon = JellyfinClient(baseUrl: 'https://example.invalid', clientInfo: info, http: http);
http.on('POST', '/Users/AuthenticateByName', fixture: 'auth_result');
await anon.authenticateByName('testuser', 'pw "x"');
expect(jsonDecode(http.last.body!), {'Username': 'testuser', 'Pw': 'pw "x"'});
expect(anon.isAuthenticated, isTrue);
});
test('validateToken: user on 200, null on 401, throws otherwise', () async {
http.on('GET', '/Users/Me', fixture: 'me');
expect((await client.validateToken())!.name, 'testuser');
expect(http.last.headers['Authorization'], contains('Token="TOK"'));
http.on('GET', '/Users/Me', status: 401);
expect(await client.validateToken(), isNull);
http.on('GET', '/Users/Me', status: 502);
expect(client.validateToken(), throwsA(isA<JellyfinException>()));
});
test('logout posts and forgets the token', () async {
http.on('POST', '/Sessions/Logout', status: 204);
await client.logout();
expect(http.last.uri.path, '/Sessions/Logout');
expect(client.accessToken, isNull);
expect(client.isAuthenticated, isFalse);
});
});
group('browsing', () {
test('user views', () async {
http.on('GET', '/UserViews', fixture: 'views');
final views = await client.userViews();
expect(views.map((v) => v.collectionType), ['homevideos', 'movies', 'tvshows']);
expect(views[2].kind, JellyfinItemKind.collectionFolder);
expect(http.last.uri.queryParameters['userId'], uid);
});
test('resume: episodes with position and parent art fallbacks', () async {
http.on('GET', '/UserItems/Resume', fixture: 'resume');
final items = await client.resume(limit: 3);
final e = items.first;
expect(http.last.uri.queryParameters, containsPair('mediaTypes', 'Video'));
expect(e.kind, JellyfinItemKind.episode);
expect(e.seriesName, 'Black Summoner');
expect(e.userData.isResumable, isTrue);
expect(e.userData.position, ticksToDuration(12080340000));
expect(e.episodeLabel, 'S1E3');
// Own primary (episode still); backdrop/logo/thumb come from the series.
expect(e.image(JellyfinImageType.primary)!.itemId, e.id);
expect(e.image(JellyfinImageType.backdrop)!.itemId, e.seriesId);
expect(e.image(JellyfinImageType.logo)!.tag, 'fe59b8bb26fe8d676956311969a816a4');
expect(e.image(JellyfinImageType.thumb)!.itemId, e.seriesId);
});
test('next up and latest', () async {
http.on('GET', '/Shows/NextUp', fixture: 'nextup');
http.on('GET', '/Items/Latest', fixture: 'latest_shows');
expect((await client.nextUp()).first.kind, JellyfinItemKind.episode);
expect(http.last.uri.queryParameters['enableResumable'], 'false');
final latest = await client.latest('a656b907eb3a73532e40e44b968d0225', limit: 4);
expect(latest, hasLength(4));
expect(latest.every((i) => i.kind == JellyfinItemKind.series), isTrue);
expect(http.last.uri.queryParameters['parentId'], 'a656b907eb3a73532e40e44b968d0225');
});
test('library page: query params and paging', () async {
http.on('GET', '/Items', fixture: 'series_page');
const q = JellyfinItemQuery(
parentId: 'a656b907eb3a73532e40e44b968d0225',
includeItemTypes: [JellyfinItemType.series],
recursive: true,
filters: [JellyfinFilter.isUnplayed],
);
final page = await client.items(q.page(0, 5));
final qp = http.last.uri.queryParameters;
expect(qp['includeItemTypes'], 'Series');
expect(qp['recursive'], 'true');
expect(qp['sortBy'], 'SortName');
expect(qp['sortOrder'], 'Ascending');
expect(qp['filters'], 'IsUnplayed');
expect(qp['startIndex'], '0');
expect(qp['limit'], '5');
expect(page.totalRecordCount, 218);
expect(page.items, hasLength(5));
expect(page.hasMore, isTrue);
final s = page.items.first;
expect(s.kind, JellyfinItemKind.series);
expect(s.userData.unplayedItemCount, 12);
expect(s.image(JellyfinImageType.backdrop)!.itemId, s.id);
});
test('search', () async {
http.on('GET', '/Items', fixture: 'search');
final r = await client.search('arifureta');
expect(http.last.uri.queryParameters['searchTerm'], 'arifureta');
expect(http.last.uri.queryParameters['includeItemTypes'], 'Movie,Series,Episode');
expect(r.single.name, startsWith('Arifureta'));
});
test('item detail: media source and streams', () async {
http.on('GET', '/Items/b18826be994070a79f9b52bc0f66f052', fixture: 'episode_detail');
final e = await client.item('b18826be994070a79f9b52bc0f66f052');
expect(e.runTime, const Duration(seconds: 1420, microseconds: 112000));
final src = e.mediaSources.single;
expect(src.supportsDirectPlay, isTrue);
expect(src.size, 1525319390);
expect(src.streamsOf(JellyfinStreamType.audio).map((s) => s.language), ['jpn', 'eng']);
expect(src.streamsOf(JellyfinStreamType.subtitle), hasLength(17));
expect(e.mediaStreams, hasLength(20));
});
test('seasons and episodes with user data', () async {
http.on('GET', '/Shows/550eff23b37ec61082c988678ccc214c/Seasons', fixture: 'seasons');
http.on('GET', '/Shows/550eff23b37ec61082c988678ccc214c/Episodes', fixture: 'episodes');
final seasons = await client.seasons('550eff23b37ec61082c988678ccc214c');
expect(seasons.map((s) => s.indexNumber), [2, 3]);
expect(seasons.first.userData.unplayedItemCount, 12);
final eps = await client.episodes('550eff23b37ec61082c988678ccc214c');
expect(http.last.uri.queryParameters['isMissing'], 'false');
expect(eps, hasLength(41));
expect(eps.every((e) => !e.userData.played && e.userData.playbackPositionTicks == 0), isTrue);
});
test('nextEpisode crosses seasons and skips other copies of the same episode', () async {
http.on('GET', '/Shows/550eff23b37ec61082c988678ccc214c/Episodes', fixture: 'episodes');
final eps = await client.episodes('550eff23b37ec61082c988678ccc214c');
final s2e12 = eps.firstWhere((e) => e.episodeLabel == 'S2E12');
final next = await client.nextEpisode(s2e12);
expect(next!.episodeLabel, 'S3E1');
expect(next.id, 'bbc737c39115b7fb0d23e45ed7b1ddb1');
// S3E1 exists twice (two files); the successor of either is S3E2.
expect(episodeAfter(eps, 'bbc737c39115b7fb0d23e45ed7b1ddb1')!.episodeLabel, 'S3E2');
expect(episodeAfter(eps, '96d4408695909a6b8a23f0a4a09ab553')!.episodeLabel, 'S3E2');
expect(episodeAfter(eps, eps.last.id), isNull);
expect(episodeAfter(eps, 'nope'), isNull);
});
});
group('images', () {
test('url with tag and maxWidth, no token', () {
final url = client.imageUrl(
const JellyfinImageRef('abc', JellyfinImageType.primary, 'tag1'), maxWidth: 300);
expect(url.toString(),
'https://example.invalid/Items/abc/Images/Primary?tag=tag1&maxWidth=300&quality=90');
final bd = client.imageUrl(
const JellyfinImageRef('abc', JellyfinImageType.backdrop, 't', index: 1));
expect(bd.path, '/Items/abc/Images/Backdrop/1');
});
test('base path is kept', () {
final c = JellyfinClient(baseUrl: 'https://example.invalid/jf/web/', clientInfo: info, http: http);
expect(c.baseUrl, 'https://example.invalid/jf');
expect(c.imageUrl(const JellyfinImageRef('a', JellyfinImageType.logo, 't')).path,
'/jf/Items/a/Images/Logo');
});
});
group('playback', () {
test('playbackInfo asks for direct play and parses the session', () async {
http.on('POST', '/Items/b18826be994070a79f9b52bc0f66f052/PlaybackInfo', fixture: 'playback_info');
final pi = await client.playbackInfo('b18826be994070a79f9b52bc0f66f052');
expect(jsonDecode(http.last.body!), {
'UserId': uid,
'EnableDirectPlay': true,
'EnableDirectStream': true,
'EnableTranscoding': false,
});
expect(pi.playSessionId, isNotEmpty);
final src = pi.primarySource!;
expect(src.id, 'b18826be994070a79f9b52bc0f66f052');
expect(src.defaultAudioStreamIndex, 1);
final eng = src.streamAt(3)!;
expect(eng.type, JellyfinStreamType.subtitle);
expect(eng.title, 'English (Full Subtitle)');
expect(eng.codec, 'ass');
expect(eng.isExternal, isFalse);
final jpn = src.streamAt(1)!;
expect((jpn.language, jpn.codec, jpn.channels, jpn.isDefault), ('jpn', 'eac3', 2, true));
// mpv ids are per type, 1-based.
expect(src.mpvTrackId(1), 1);
expect(src.mpvTrackId(2), 2);
expect(src.mpvTrackId(3), 1);
expect(src.mpvTrackId(5), 3);
expect(src.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, 3), 5);
});
test('stream URLs: with token for mpv, without for sharing', () {
final shared = client.shareableStreamUrl('item1', mediaSourceId: 'src1');
expect(shared.toString(),
'https://example.invalid/Videos/item1/stream?static=true&mediaSourceId=src1');
expect(client.streamUrl('item1', mediaSourceId: 'src1').queryParameters,
{'static': 'true', 'mediaSourceId': 'src1', 'api_key': 'TOK'});
expect(client.streamUrl('item1').queryParameters['mediaSourceId'], 'item1');
final other = JellyfinClient(
baseUrl: 'https://example.invalid', clientInfo: info, accessToken: 'OTHER', http: http);
expect(other.authorizeUrl(client.streamUrl('item1')).queryParameters['api_key'], 'OTHER');
expect(client.authHeaders['Authorization'], contains('Token="TOK"'));
});
});
group('progress', () {
const r = JellyfinPlaybackReport(
itemId: 'i',
mediaSourceId: 'm',
playSessionId: 'p',
positionTicks: 30 * jellyfinTicksPerSecond,
audioStreamIndex: 1,
subtitleStreamIndex: 3,
);
test('playing / progress / stopped bodies', () async {
for (final p in ['/Sessions/Playing', '/Sessions/Playing/Progress', '/Sessions/Playing/Stopped']) {
http.on('POST', p, status: 204);
}
await client.reportPlaybackStart(r);
expect(http.last.uri.path, '/Sessions/Playing');
expect(jsonDecode(http.last.body!), {
'ItemId': 'i',
'MediaSourceId': 'm',
'PlaySessionId': 'p',
'PositionTicks': 300000000,
'IsPaused': false,
'CanSeek': true,
'PlayMethod': 'DirectPlay',
'AudioStreamIndex': 1,
'SubtitleStreamIndex': 3,
});
await client.reportPlaybackProgress(r.copyWith(isPaused: true, subtitleStreamIndex: -1));
expect(http.last.uri.path, '/Sessions/Playing/Progress');
final body = jsonDecode(http.last.body!) as Map;
expect((body['IsPaused'], body['SubtitleStreamIndex']), (true, -1));
await client.reportPlaybackStopped(r);
expect(jsonDecode(http.last.body!),
{'ItemId': 'i', 'MediaSourceId': 'm', 'PlaySessionId': 'p', 'PositionTicks': 300000000});
});
test('played / unplayed / resume position', () async {
const ud = '{"Played":false,"PlaybackPositionTicks":0,"PlayCount":0}';
http.on('POST', '/UserPlayedItems/x', body: '{"Played":true,"PlayCount":1}');
http.on('DELETE', '/UserPlayedItems/x', body: ud);
http.on('POST', '/UserItems/x/UserData', body: ud);
expect((await client.markPlayed('x')).played, isTrue);
expect(http.last.uri.queryParameters['userId'], uid);
expect((await client.markUnplayed('x')).played, isFalse);
expect(http.last.method, 'DELETE');
await client.setResumePosition('x', 42);
expect(jsonDecode(http.last.body!), {'PlaybackPositionTicks': 42});
await client.clearResumePosition('x');
expect(jsonDecode(http.last.body!), {'PlaybackPositionTicks': 0});
});
test('ticks conversion', () {
expect(durationToTicks(const Duration(seconds: 1)), jellyfinTicksPerSecond);
expect(ticksToDuration(14201120000), const Duration(milliseconds: 1420112));
});
});
test('errors carry the status code', () async {
http.on('GET', '/Items/zzz', status: 404, body: 'Not found');
await expectLater(
client.item('zzz'),
throwsA(isA<JellyfinException>()
.having((e) => e.statusCode, 'statusCode', 404)
.having((e) => e.isUnauthorized, 'isUnauthorized', isFalse)));
});
test('user-scoped calls need a user id', () {
final anon = JellyfinClient(baseUrl: 'https://example.invalid', clientInfo: info, http: http);
expect(() => anon.userViews(), throwsA(isA<JellyfinException>()));
expect(() => anon.streamUrl('x'), throwsA(isA<JellyfinException>()));
});
}
@@ -0,0 +1,73 @@
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
const id = 'b18826be994070a79f9b52bc0f66f052';
const sid = '122fce17c5b9413088af4be266bad6f4';
void main() {
group('normalizeServerUrl', () {
final cases = {
'jelly.example.com': 'https://jelly.example.com',
' https://jelly.example.com/ ': 'https://jelly.example.com',
'https://jelly.example.com/web/#/home.html': 'https://jelly.example.com',
'https://jelly.example.com/web/index.html': 'https://jelly.example.com',
'http://192.168.1.5:8096': 'http://192.168.1.5:8096',
'192.168.1.5:8096/web/': 'http://192.168.1.5:8096',
'localhost:8096': 'http://localhost:8096',
'https://example.com/jellyfin/web/#/home': 'https://example.com/jellyfin',
'https://example.com/jellyfin/': 'https://example.com/jellyfin',
'HTTPS://Jelly.Example.com': 'https://jelly.example.com',
'https://jelly.example.com/Items/$id/Download?api_key=x': 'https://jelly.example.com',
};
cases.forEach((input, want) {
test(input, () => expect(normalizeServerUrl(input), want));
});
test('rejects junk', () {
expect(normalizeServerUrl(''), isNull);
expect(normalizeServerUrl('ftp://x.com'), isNull);
});
});
group('parseJellyfinLink', () {
test('web details link with serverId', () {
expect(parseJellyfinLink('https://jelly.example.com/web/#/details?id=$id&serverId=$sid'),
const JellyfinLink(baseUrl: 'https://jelly.example.com', itemId: id, serverId: sid));
});
test('old web link (index.html#!/)', () {
final l = parseJellyfinLink('https://jelly.example.com/web/index.html#!/details?id=$id')!;
expect((l.baseUrl, l.itemId, l.serverId), ('https://jelly.example.com', id, null));
});
test('sub-path and port are kept', () {
expect(parseJellyfinLink('http://10.0.0.2:8096/jf/web/#/details?id=$id')!.baseUrl,
'http://10.0.0.2:8096/jf');
});
test('API links', () {
for (final url in [
'https://jelly.example.com/Items/$id/Download?api_key=k',
'https://jelly.example.com/Videos/$id/stream?static=true&mediaSourceId=$id',
'https://jelly.example.com/Videos/$id/stream.mkv',
'https://jelly.example.com/Items/$id/Images/Primary?tag=x',
'https://jelly.example.com/Users/f00703a1d1ac44fe8a8a0ddf2ca05757/Items/$id',
'https://jelly.example.com/Items/$id',
]) {
expect(parseJellyfinLink(url),
const JellyfinLink(baseUrl: 'https://jelly.example.com', itemId: id), reason: url);
}
});
test('dashed and upper-case GUIDs are normalised', () {
expect(parseJellyfinLink('https://j.example.com/Items/B18826BE-9940-70A7-9F9B-52BC0F66F052')!.itemId, id);
});
test('not Jellyfin', () {
expect(parseJellyfinLink('https://www.youtube.com/watch?v=abc'), isNull);
expect(parseJellyfinLink('https://jelly.example.com/web/#/home.html'), isNull);
expect(parseJellyfinLink('https://jelly.example.com/Items/notanid'), isNull);
expect(parseJellyfinLink('/local/file.mkv'), isNull);
});
});
}
+176
View File
@@ -0,0 +1,176 @@
// Live tests against a real Jellyfin server. Skipped unless configured:
//
// JELLYFIN_TEST_URL, JELLYFIN_TEST_TOKEN server and a user access token
// JELLYFIN_TEST_DEVICE, JELLYFIN_TEST_VERSION, JELLYFIN_TEST_DEVICE_ID,
// JELLYFIN_TEST_CLIENT the token's own device fields
//
// The device fields are required because Jellyfin overwrites a token's stored
// device name and app version with whatever the Authorization header says;
// borrowing another app's token with our own fields would rename that
// device on the user's account.
//
// The write test (Playing -> Progress -> Stopped, then restore) also needs
// JELLYFIN_TEST_WRITE_ITEM an episode id whose whole series is unwatched
// and refuses to run if the episode or any episode of its series has been
// played or has a resume position. Never commit these values.
library;
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
void main() {
final env = Platform.environment;
final url = env['JELLYFIN_TEST_URL'];
final token = env['JELLYFIN_TEST_TOKEN'];
final device = env['JELLYFIN_TEST_DEVICE'];
final version = env['JELLYFIN_TEST_VERSION'];
final skip = (url == null || token == null || device == null || version == null)
? 'JELLYFIN_TEST_* not set'
: null;
late JellyfinClient client;
late JellyfinUser me;
setUpAll(() async {
if (skip != null) return;
client = JellyfinClient(
baseUrl: url!,
clientInfo: JellyfinClientInfo(
client: env['JELLYFIN_TEST_CLIENT'] ?? 'Watch Together',
device: device!,
deviceId: env['JELLYFIN_TEST_DEVICE_ID'] ?? 'wt-live-test',
version: version!,
),
accessToken: token,
);
me = (await client.validateToken())!;
});
tearDownAll(() {
if (skip == null) client.close();
});
test('server info and token', () async {
final info = await client.publicInfo();
expect(info.id, isNotEmpty);
expect(info.version, isNotEmpty);
expect(me.id, client.userId);
}, skip: skip);
test('Quick Connect initiate works (no sign-in completed)', () async {
final anon = JellyfinClient(
baseUrl: url!,
clientInfo: const JellyfinClientInfo(
client: 'Watch Together', device: 'live test', deviceId: 'wt-live-test-qc', version: '0.0.0'),
);
addTearDown(anon.close);
if (!await anon.quickConnectEnabled()) return markTestSkipped('Quick Connect disabled');
final qc = await anon.quickConnectInitiate();
expect(qc.code, matches(RegExp(r'^\d{6}$')));
expect(qc.secret, isNotEmpty);
final state = await anon.quickConnectCheck(qc.secret);
expect(state.authenticated, isFalse);
}, skip: skip);
test('browse: views, resume, next up, latest, library page, search', () async {
final views = await client.userViews();
expect(views, isNotEmpty);
await client.resume(limit: 5);
await client.nextUp(limit: 5);
final tv = views.where((v) => v.collectionType == 'tvshows').firstOrNull;
if (tv == null) return;
expect(await client.latest(tv.id, limit: 3), isNotEmpty);
final page = await client.items(JellyfinItemQuery(
parentId: tv.id, includeItemTypes: const [JellyfinItemType.series], recursive: true, limit: 3));
expect(page.items, hasLength(3));
expect(page.totalRecordCount, greaterThanOrEqualTo(3));
final second = await client.items(JellyfinItemQuery(
parentId: tv.id, includeItemTypes: const [JellyfinItemType.series], recursive: true)
.page(3, 3));
expect(second.items.first.id, isNot(page.items.first.id));
final hits = await client.search(page.items.first.name.split(' ').first);
expect(hits, isNotEmpty);
}, skip: skip);
test('series -> seasons -> episodes -> next, playback info, ranged stream', () async {
final views = await client.userViews();
final tv = views.firstWhere((v) => v.collectionType == 'tvshows');
final series = (await client.items(JellyfinItemQuery(
parentId: tv.id, includeItemTypes: const [JellyfinItemType.series], recursive: true, limit: 1)))
.items
.single;
expect(await client.seasons(series.id), isNotEmpty);
final eps = await client.episodes(series.id, withMediaStreams: true);
expect(eps, isNotEmpty);
if (eps.length > 1) expect((await client.nextEpisode(eps.first))!.id, isNot(eps.first.id));
final detail = await client.item(eps.first.id);
expect(detail.mediaSources, isNotEmpty);
final pi = await client.playbackInfo(eps.first.id);
expect(pi.playSessionId, isNotEmpty);
final src = pi.primarySource!;
expect(src.supportsDirectPlay, isTrue);
final http = HttpClient();
addTearDown(() => http.close(force: true));
final req = await http.getUrl(client.streamUrl(eps.first.id, mediaSourceId: src.id));
req.headers.set(HttpHeaders.rangeHeader, 'bytes=0-1023');
final res = await req.close();
await res.drain<void>();
expect(res.statusCode, 206);
expect(res.headers.value(HttpHeaders.contentRangeHeader), 'bytes 0-1023/${src.size}');
}, skip: skip);
final writeItem = env['JELLYFIN_TEST_WRITE_ITEM'];
test('progress reporting changes user data, then restore', () async {
final ep = await client.item(writeItem!);
final siblings = await client.episodes(ep.seriesId!);
final touched = siblings.where(
(e) => e.userData.played || e.userData.playbackPositionTicks > 0 || e.userData.playCount > 0);
if (touched.isNotEmpty || ep.userData.played || ep.userData.playbackPositionTicks > 0) {
fail('refusing: series ${ep.seriesName} has watched episodes (${touched.map((e) => e.id)})');
}
var restored = false;
Future<JellyfinUserData> restore() async {
await client.markUnplayed(ep.id);
await client.clearResumePosition(ep.id);
restored = true;
return client.userData(ep.id);
}
addTearDown(() async {
if (!restored) await restore();
});
final pi = await client.playbackInfo(ep.id);
final src = pi.primarySource!;
final pos = (ep.runTimeTicks! * 0.3).round();
final report = JellyfinPlaybackReport(
itemId: ep.id,
mediaSourceId: src.id,
playSessionId: pi.playSessionId,
audioStreamIndex: src.defaultAudioStreamIndex,
subtitleStreamIndex: src.defaultSubtitleStreamIndex ?? -1,
);
await client.reportPlaybackStart(report);
await client.reportPlaybackProgress(report.copyWith(positionTicks: pos));
await client.reportPlaybackProgress(report.copyWith(positionTicks: pos, isPaused: true));
await client.reportPlaybackStopped(report.copyWith(positionTicks: pos));
final after = await client.userData(ep.id);
printOnFailure('after: played=${after.played} pos=${after.playbackPositionTicks}');
expect(after.played, isFalse);
expect(after.playbackPositionTicks, closeTo(pos, jellyfinTicksPerSecond));
final back = await restore();
expect(back.played, isFalse);
expect(back.playbackPositionTicks, 0);
expect(back.playCount, 0);
print('live write test on ${ep.seriesName} ${ep.episodeLabel} (${ep.id}): '
'after=${after.playbackPositionTicks} restored pos=${back.playbackPositionTicks} '
'played=${back.played} playCount=${back.playCount} lastPlayed=${back.lastPlayedDate}');
}, skip: skip ?? (writeItem == null ? 'JELLYFIN_TEST_WRITE_ITEM not set' : null));
}
@@ -0,0 +1,131 @@
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
JellyfinMediaStream audio(int i, String? lang, String? title, String codec, int ch, {bool def = false}) =>
JellyfinMediaStream(
index: i, type: JellyfinStreamType.audio, language: lang, title: title, codec: codec, channels: ch, isDefault: def);
JellyfinMediaStream sub(int i, String? lang, String? title, String codec,
{bool forced = false, bool def = false, bool hi = false}) =>
JellyfinMediaStream(
index: i,
type: JellyfinStreamType.subtitle,
language: lang,
title: title,
codec: codec,
isForced: forced,
isDefault: def,
isHearingImpaired: hi);
void main() {
// Black Summoner S1E6 -> S1E7 (captured): jpn+eng eac3 audio, 17 ass/srt subs.
final eps = (jsonDecode(File('test/fixtures/jellyfin/bs_episodes.json').readAsStringSync())['Items'] as List)
.map((e) => JellyfinItem.fromJson(e as Map<String, dynamic>))
.toList();
final e6 = eps.firstWhere((e) => e.indexNumber == 6).mediaStreams;
final e7 = eps.firstWhere((e) => e.indexNumber == 7).mediaStreams;
JellyfinMediaStream at(List<JellyfinMediaStream> s, int i) => s.firstWhere((x) => x.index == i);
group('captured episodes', () {
test('each track carries over to the same track', () {
for (final s in e6.where((s) => s.type != JellyfinStreamType.video)) {
expect(matchTrack(TrackPreference.fromStream(s), e7)!.index, s.index, reason: '$s');
}
});
test('jpn audio + Signs subs', () {
final r = matchTracks(audio: at(e6, 1), subtitle: at(e6, 4), streams: e7);
expect((r.audioStreamIndex, r.subtitleStreamIndex), (1, 4));
});
test('subtitles off carries over as -1', () {
final r = matchTracks(audio: at(e6, 2), subtitlesOff: true, streams: e7);
expect((r.audioStreamIndex, r.subtitleStreamIndex), (2, -1));
});
test('nothing picked: no override', () {
final r = matchTracks(streams: e7);
expect((r.audioStreamIndex, r.subtitleStreamIndex), (null, null));
});
});
group('different release next episode', () {
// Next episode from another group: indices shift, titles differ.
final next = [
const JellyfinMediaStream(index: 0, type: JellyfinStreamType.video, codec: 'hevc'),
audio(1, 'ja', null, 'aac', 2, def: true),
audio(2, 'en', 'English 5.1', 'eac3', 6),
audio(3, 'en', 'English Stereo', 'aac', 2),
sub(4, 'eng', 'Signs & Songs', 'ass', forced: true),
sub(5, 'eng', 'Full', 'ass', def: true),
sub(6, 'eng', 'Full [SDH]', 'subrip', hi: true),
sub(7, 'ger', 'Vollständig', 'ass'),
sub(8, 'spa', 'Español (España)', 'ass'),
sub(9, 'spa', 'Español (Latinoamérica)', 'ass'),
];
test('Full subtitles -> "Full", not "Signs & Songs"', () {
expect(matchTrack(TrackPreference.fromStream(at(e6, 3)), next)!.index, 5);
});
test('Signs -> "Signs & Songs"', () {
expect(matchTrack(TrackPreference.fromStream(at(e6, 4)), next)!.index, 4);
});
test('CC (hearing impaired, subrip) -> SDH when titles do not help', () {
expect(matchTrack(TrackPreference.fromStream(at(e6, 5)), next)!.index, 6);
});
test('language is required, 639-1 / 639-2 / B codes equated', () {
expect(matchTrack(TrackPreference.fromStream(at(e6, 1)), next)!.index, 1); // jpn -> ja
expect(matchTrack(TrackPreference.fromStream(at(e6, 7)), next)!.index, 7); // deu -> ger
expect(matchTrack(TrackPreference.fromStream(at(e6, 10)), next), isNull); // fra: none
});
test('title picks the regional variant', () {
expect(
matchTrack(const TrackPreference(type: JellyfinStreamType.subtitle, language: 'spa', title: 'Spanish (Latin America) Latinoamérica'), next)!
.index,
9);
});
test('codec then channels break ties', () {
// "English" eac3 stereo: title comes first, "English Stereo" is closer
// than "English 5.1" even though the codec differs.
expect(matchTrack(TrackPreference.fromStream(at(e6, 2)), next)!.index, 3);
// Same titles: codec beats channel count.
final same = [audio(1, 'eng', 'English', 'aac', 2), audio(2, 'eng', 'English', 'eac3', 6)];
expect(matchTrack(TrackPreference.fromStream(at(e6, 2)), same)!.index, 2);
// No codec match: closest channel count.
expect(
matchTrack(const TrackPreference(type: JellyfinStreamType.audio, language: 'eng', codec: 'opus', channels: 2), next)!
.index,
3);
expect(
matchTrack(const TrackPreference(type: JellyfinStreamType.audio, language: 'eng', codec: 'opus', channels: 8), next)!
.index,
2);
});
test('type is respected', () {
expect(matchTrack(const TrackPreference(type: JellyfinStreamType.audio, language: 'spa'), next), isNull);
});
});
test('normalizeLanguage', () {
expect(normalizeLanguage('en'), 'eng');
expect(normalizeLanguage('en-US'), 'eng');
expect(normalizeLanguage('FRE'), 'fra');
expect(normalizeLanguage('und'), isNull);
expect(normalizeLanguage(''), isNull);
expect(normalizeLanguage('tlh'), 'tlh');
});
test('undetermined language only matches undetermined', () {
final streams = [audio(1, null, null, 'aac', 2), audio(2, 'eng', null, 'aac', 2)];
expect(matchTrack(const TrackPreference(type: JellyfinStreamType.audio, language: 'und'), streams)!.index, 1);
});
}