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:
1 parent
d8d487c189
commit
4814bfb561
29 files changed
+9973
No files matched your search
@@ -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';
|
||||
@@ -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(', ')}';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user