feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
@@ -9,22 +10,38 @@ import '../utils/url_utils.dart';
|
||||
/// (e.g. database column values).
|
||||
enum ConnectionKind {
|
||||
plex,
|
||||
jellyfin;
|
||||
jellyfin,
|
||||
emby;
|
||||
|
||||
String get id => switch (this) {
|
||||
ConnectionKind.plex => 'plex',
|
||||
ConnectionKind.jellyfin => 'jellyfin',
|
||||
ConnectionKind.emby => 'emby',
|
||||
};
|
||||
|
||||
static ConnectionKind fromId(String id) => switch (id) {
|
||||
'plex' => ConnectionKind.plex,
|
||||
'jellyfin' => ConnectionKind.jellyfin,
|
||||
'emby' => ConnectionKind.emby,
|
||||
_ => throw ArgumentError('Unknown ConnectionKind id: $id'),
|
||||
};
|
||||
|
||||
MediaBackend get backend => switch (this) {
|
||||
ConnectionKind.plex => MediaBackend.plex,
|
||||
ConnectionKind.jellyfin => MediaBackend.jellyfin,
|
||||
ConnectionKind.emby => MediaBackend.emby,
|
||||
};
|
||||
|
||||
/// The MediaBrowser dialect this kind speaks, or `null` for Plex.
|
||||
MediaBrowserDialect? get dialect => switch (this) {
|
||||
ConnectionKind.plex => null,
|
||||
ConnectionKind.jellyfin => MediaBrowserDialect.jellyfin,
|
||||
ConnectionKind.emby => MediaBrowserDialect.emby,
|
||||
};
|
||||
|
||||
static ConnectionKind fromDialect(MediaBrowserDialect dialect) => switch (dialect) {
|
||||
MediaBrowserDialect.jellyfin => ConnectionKind.jellyfin,
|
||||
MediaBrowserDialect.emby => ConnectionKind.emby,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +205,9 @@ class PlexAccountConnection extends Connection {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-server Jellyfin connection.
|
||||
/// A single-server connection to a MediaBrowser-family server — Jellyfin or its
|
||||
/// Emby ancestor. [dialect] selects which of the two wire dialects this
|
||||
/// connection speaks; every other field has the same meaning on both.
|
||||
class JellyfinConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
@@ -202,10 +221,14 @@ class JellyfinConnection extends Connection {
|
||||
@override
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
/// Which MediaBrowser dialect this server speaks. Drives [kind], [backend]
|
||||
/// and every route/capability delta in [JellyfinClient].
|
||||
final MediaBrowserDialect dialect;
|
||||
|
||||
/// Active server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`.
|
||||
final String baseUrl;
|
||||
|
||||
/// Candidate server URLs for this Jellyfin server, with [baseUrl] first.
|
||||
/// Candidate server URLs for this server, with [baseUrl] first.
|
||||
/// Existing installs only have [baseUrl]; deserialization backfills this.
|
||||
final List<String> baseUrls;
|
||||
|
||||
@@ -215,7 +238,7 @@ class JellyfinConnection extends Connection {
|
||||
/// Server's machine identifier (System/Info `Id`).
|
||||
final String serverMachineId;
|
||||
|
||||
/// Authenticated Jellyfin user id (UUID).
|
||||
/// Authenticated user id. A UUID on Jellyfin, an opaque hex string on Emby.
|
||||
final String userId;
|
||||
|
||||
/// Authenticated user's display name.
|
||||
@@ -228,13 +251,13 @@ class JellyfinConnection extends Connection {
|
||||
/// `Authorization: MediaBrowser DeviceId="..."` header).
|
||||
final String deviceId;
|
||||
|
||||
/// Whether this user is a Jellyfin admin (`/Users/{id}.Policy.IsAdministrator`).
|
||||
/// Whether this user is a server admin (`/Users/{id}.Policy.IsAdministrator`).
|
||||
/// Captured at auth time so the UI can gate admin-only entries (delete,
|
||||
/// match/unmatch, edit metadata) without an extra round-trip.
|
||||
final bool isAdministrator;
|
||||
|
||||
/// The authenticated user's `PrimaryImageTag`, or `null` when they have no
|
||||
/// profile picture. Jellyfin omits the key entirely in that case, and the
|
||||
/// profile picture. The server omits the key entirely in that case, and the
|
||||
/// tag is `MD5(imagePath + lastModified)` so it changes on every upload —
|
||||
/// which makes the derived avatar URL self-invalidating. Captured at auth
|
||||
/// time and refreshed by [JellyfinClient.checkHealth].
|
||||
@@ -250,6 +273,7 @@ class JellyfinConnection extends Connection {
|
||||
required this.userName,
|
||||
required this.accessToken,
|
||||
required this.deviceId,
|
||||
this.dialect = MediaBrowserDialect.jellyfin,
|
||||
this.isAdministrator = false,
|
||||
this.primaryImageTag,
|
||||
this.status = ConnectionStatus.unknown,
|
||||
@@ -259,7 +283,7 @@ class JellyfinConnection extends Connection {
|
||||
baseUrls = _normalizeBaseUrls(baseUrl, baseUrls);
|
||||
|
||||
@override
|
||||
ConnectionKind get kind => ConnectionKind.jellyfin;
|
||||
ConnectionKind get kind => ConnectionKind.fromDialect(dialect);
|
||||
|
||||
@override
|
||||
String get displayName => '$userName · $serverName';
|
||||
@@ -306,11 +330,12 @@ class JellyfinConnection extends Connection {
|
||||
String? userName,
|
||||
String? accessToken,
|
||||
String? deviceId,
|
||||
MediaBrowserDialect? dialect,
|
||||
bool? isAdministrator,
|
||||
String? primaryImageTag,
|
||||
|
||||
/// Deleting a Jellyfin profile picture drops `PrimaryImageTag` from the
|
||||
/// user DTO, so a refresh must be able to null the cached value — a bare
|
||||
/// Deleting a profile picture drops `PrimaryImageTag` from the user DTO, so
|
||||
/// a refresh must be able to null the cached value — a bare
|
||||
/// `primaryImageTag: null` is indistinguishable from "unchanged".
|
||||
bool clearPrimaryImageTag = false,
|
||||
ConnectionStatus? status,
|
||||
@@ -328,6 +353,7 @@ class JellyfinConnection extends Connection {
|
||||
userName: userName ?? this.userName,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
dialect: dialect ?? this.dialect,
|
||||
isAdministrator: isAdministrator ?? this.isAdministrator,
|
||||
primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag),
|
||||
status: status ?? this.status,
|
||||
@@ -336,6 +362,9 @@ class JellyfinConnection extends Connection {
|
||||
);
|
||||
}
|
||||
|
||||
/// The persisted payload deliberately omits [dialect]: the `connections.kind`
|
||||
/// column is the authoritative, indexed discriminator and
|
||||
/// [JellyfinConnection.fromConfigJson] receives it from there.
|
||||
@override
|
||||
Map<String, Object?> toConfigJson() {
|
||||
return {
|
||||
@@ -358,6 +387,7 @@ class JellyfinConnection extends Connection {
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
||||
}) {
|
||||
final rawBaseUrls = json['baseUrls'];
|
||||
final baseUrls = rawBaseUrls is List ? rawBaseUrls.whereType<String>().toList(growable: false) : const <String>[];
|
||||
@@ -369,7 +399,8 @@ class JellyfinConnection extends Connection {
|
||||
id: id,
|
||||
baseUrl: baseUrl,
|
||||
baseUrls: baseUrls,
|
||||
serverName: json['serverName'] as String? ?? 'Jellyfin',
|
||||
dialect: dialect,
|
||||
serverName: json['serverName'] as String? ?? dialect.productName,
|
||||
serverMachineId: json['serverMachineId'] as String? ?? '',
|
||||
userId: json['userId'] as String? ?? '',
|
||||
userName: json['userName'] as String? ?? '',
|
||||
|
||||
@@ -161,12 +161,13 @@ class ConnectionRegistry {
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
),
|
||||
ConnectionKind.jellyfin => JellyfinConnection.fromConfigJson(
|
||||
ConnectionKind.jellyfin || ConnectionKind.emby => JellyfinConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
dialect: kind.dialect!,
|
||||
),
|
||||
};
|
||||
if (revealed.migrated) {
|
||||
|
||||
@@ -235,7 +235,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
static bool _containsPlaintextConnectionCredential(String kind, Map<String, dynamic> config) {
|
||||
bool isPlaintext(Object? value) => value is String && value.isNotEmpty && !CredentialVault.isProtected(value);
|
||||
|
||||
if (kind == 'jellyfin') return isPlaintext(config['accessToken']);
|
||||
if (kind == 'jellyfin' || kind == 'emby') return isPlaintext(config['accessToken']);
|
||||
if (kind != 'plex') return false;
|
||||
if (isPlaintext(config['accountToken'])) return true;
|
||||
final servers = config['servers'];
|
||||
|
||||
@@ -4497,7 +4497,7 @@ class ConnectionRow extends DataClass implements Insertable<ConnectionRow> {
|
||||
/// (one per account); for Jellyfin it's the server's machineId.
|
||||
final String id;
|
||||
|
||||
/// Backend kind: `'plex'` or `'jellyfin'`.
|
||||
/// Backend kind: `'plex'`, `'jellyfin'`, or `'emby'`.
|
||||
final String kind;
|
||||
|
||||
/// User-visible label (account email, server name).
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../media/ids.dart';
|
||||
import '../media/media_backend.dart';
|
||||
|
||||
import 'app_database.dart';
|
||||
import '../models/download_models.dart';
|
||||
@@ -202,22 +203,30 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
final connectionRows = await select(connections).get();
|
||||
final connectionIds = connectionRows.map((row) => row.id).toSet();
|
||||
final connectionKindsById = {for (final row in connectionRows) row.id: row.kind};
|
||||
final jellyfinIdentities = <String, ({String machineId, String? userId})>{};
|
||||
final jellyfinMachineIds = <String>{};
|
||||
for (final connection in connectionRows.where((row) => row.kind == 'jellyfin')) {
|
||||
final identity = _jellyfinConnectionIdentity(connection);
|
||||
jellyfinIdentities[connection.id] = identity;
|
||||
jellyfinMachineIds.add(identity.machineId);
|
||||
final mediaBrowserIdentities = <String, ({String machineId, String? userId, String backendId})>{};
|
||||
final mediaBrowserMachineIds = <String>{};
|
||||
// `Connections.kind` is the authoritative dialect discriminator; both
|
||||
// MediaBrowser kinds use the same compound machine/user scope shape.
|
||||
for (final connection in connectionRows.where(
|
||||
(row) => row.kind == MediaBackend.jellyfin.id || row.kind == MediaBackend.emby.id,
|
||||
)) {
|
||||
final identity = _mediaBrowserConnectionIdentity(connection);
|
||||
mediaBrowserIdentities[connection.id] = (
|
||||
machineId: identity.machineId,
|
||||
userId: identity.userId,
|
||||
backendId: connection.kind,
|
||||
);
|
||||
mediaBrowserMachineIds.add(identity.machineId);
|
||||
}
|
||||
final jellyfinScopesByProfileAndMachine = <String, Map<String, Set<String>>>{};
|
||||
final mediaBrowserScopesByProfileAndMachine = <String, Map<String, Set<({String scopeId, String backendId})>>>{};
|
||||
for (final binding in await select(profileConnections).get()) {
|
||||
if (binding.userIdentifier.isEmpty) continue;
|
||||
final identity = jellyfinIdentities[binding.connectionId];
|
||||
final identity = mediaBrowserIdentities[binding.connectionId];
|
||||
if (identity == null || identity.userId != null && identity.userId != binding.userIdentifier) continue;
|
||||
jellyfinScopesByProfileAndMachine
|
||||
.putIfAbsent(binding.profileId, () => <String, Set<String>>{})
|
||||
.putIfAbsent(identity.machineId, () => <String>{})
|
||||
.add('${identity.machineId}/${binding.userIdentifier}');
|
||||
mediaBrowserScopesByProfileAndMachine
|
||||
.putIfAbsent(binding.profileId, () => <String, Set<({String scopeId, String backendId})>>{})
|
||||
.putIfAbsent(identity.machineId, () => <({String scopeId, String backendId})>{})
|
||||
.add((scopeId: '${identity.machineId}/${binding.userIdentifier}', backendId: identity.backendId));
|
||||
}
|
||||
final ownedKeys = <String>{
|
||||
for (final owner in owners)
|
||||
@@ -239,35 +248,37 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
await addDownloadOwner(
|
||||
profileId: profileId,
|
||||
globalKey: row.globalKey,
|
||||
backendId: 'plex',
|
||||
backendId: MediaBackend.plex.id,
|
||||
clientScopeId: scopeId,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
final jellyfinScopes = jellyfinScopesByProfileAndMachine[profileId]?[row.serverId] ?? const <String>{};
|
||||
if (jellyfinScopes.length == 1) {
|
||||
final adoptingScope = jellyfinScopes.single;
|
||||
final mediaBrowserScopes =
|
||||
mediaBrowserScopesByProfileAndMachine[profileId]?[row.serverId] ??
|
||||
const <({String scopeId, String backendId})>{};
|
||||
if (mediaBrowserScopes.length == 1) {
|
||||
final adopting = mediaBrowserScopes.single;
|
||||
await transaction(() async {
|
||||
if (isStillActive != null && !isStillActive()) return;
|
||||
await updateDownloadedMediaClientScope(row.globalKey, adoptingScope);
|
||||
await updateDownloadedMediaClientScope(row.globalKey, adopting.scopeId);
|
||||
await addDownloadOwner(
|
||||
profileId: profileId,
|
||||
globalKey: row.globalKey,
|
||||
backendId: 'jellyfin',
|
||||
clientScopeId: adoptingScope,
|
||||
backendId: adopting.backendId,
|
||||
clientScopeId: adopting.scopeId,
|
||||
);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// A compound non-Plex scope is a legacy Jellyfin user namespace.
|
||||
// A compound non-Plex scope is a legacy MediaBrowser user namespace.
|
||||
// Never attach it to another profile unless that profile has exactly
|
||||
// one matching Jellyfin binding. The same applies when persisted
|
||||
// Jellyfin connections identify the machine but the profile has zero
|
||||
// or multiple possible users.
|
||||
final hasLegacyJellyfinScope = scopeId?.startsWith('${row.serverId}/') ?? false;
|
||||
if (hasLegacyJellyfinScope || jellyfinMachineIds.contains(row.serverId)) continue;
|
||||
// one matching MediaBrowser binding. The same applies when persisted
|
||||
// MediaBrowser connections identify the machine but the profile has
|
||||
// zero or multiple possible users.
|
||||
final hasLegacyMediaBrowserScope = scopeId?.startsWith('${row.serverId}/') ?? false;
|
||||
if (hasLegacyMediaBrowserScope || mediaBrowserMachineIds.contains(row.serverId)) continue;
|
||||
|
||||
final backendId = connectionKindsById[scopeId];
|
||||
await addDownloadOwner(
|
||||
@@ -694,7 +705,7 @@ bool _isValidDownloadOwner(
|
||||
return localProfileIds.isEmpty;
|
||||
}
|
||||
|
||||
({String machineId, String? userId}) _jellyfinConnectionIdentity(ConnectionRow connection) {
|
||||
({String machineId, String? userId}) _mediaBrowserConnectionIdentity(ConnectionRow connection) {
|
||||
final separator = connection.id.indexOf('/');
|
||||
var machineId = separator < 0 ? connection.id : connection.id.substring(0, separator);
|
||||
String? userId = separator < 0 || separator == connection.id.length - 1
|
||||
|
||||
@@ -131,8 +131,8 @@ class SyncRuleDownloads extends Table {
|
||||
/// Persisted media-server connections.
|
||||
///
|
||||
/// One row per "connection" the user has added — a Plex account (with its
|
||||
/// discovered servers and active Home profile) or a single Jellyfin server.
|
||||
/// The [configJson] payload is backend-specific and parsed by the
|
||||
/// discovered servers and active Home profile) or a single MediaBrowser
|
||||
/// server/user. The [configJson] payload is backend-specific and parsed by the
|
||||
/// [Connection] sealed class.
|
||||
@DataClassName('ConnectionRow')
|
||||
@TableIndex(name: 'idx_connections_kind', columns: {#kind})
|
||||
@@ -141,7 +141,7 @@ class Connections extends Table {
|
||||
/// (one per account); for Jellyfin it's the server's machineId.
|
||||
TextColumn get id => text()();
|
||||
|
||||
/// Backend kind: `'plex'` or `'jellyfin'`.
|
||||
/// Backend kind: `'plex'`, `'jellyfin'`, or `'emby'`.
|
||||
TextColumn get kind => text()();
|
||||
|
||||
/// User-visible label (account email, server name).
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Təsdiqləmə gözlənilir...\nSəyahətçinizdən (brauzer) daxil olun.",
|
||||
"useBrowser": "Səyahətçini istifadə et",
|
||||
"or": "və ya",
|
||||
"connectToJellyfin": "Jellyfin-ə qoşul",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Sürətli Qoşulmanı istifadə et",
|
||||
"quickConnectInstructions": "Jellyfin-də Sürətli Qoşulmanı açın və bu kodu daxil edin.",
|
||||
"quickConnectWaiting": "Təsdiq gözlənilir…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "${name} üçün seansın vaxtı bitdi",
|
||||
"sessionExpiredMany": "${count} server üçün seansın vaxtı bitdi",
|
||||
"signInAgain": "Yenidən daxil ol",
|
||||
"editJellyfinTitle": "Jellyfin qoşulmasını dəyişdir",
|
||||
"editJellyfinIntro": "${serverName} üçün URL əlavə edin və ya silin."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Kəşf et",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin serveri əlavə et",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Server URL-ləri",
|
||||
"serverUrlsHelper": "Vergüllə ayrılmış bir neçə URL-ə icazə verilir.",
|
||||
"findServer": "Server tap",
|
||||
"searchingLocalServers": "Yerli Jellyfin serverləri axtarılır...",
|
||||
"localServers": "Yerli Jellyfin serverləri",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "İstifadəçi adı",
|
||||
"password": "Şifrə",
|
||||
"signIn": "Daxil ol",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Plex ilə daxil ol",
|
||||
"pinExpired": "PIN-in vaxtı bitdi. Lütfən təzədən cəhd edin.",
|
||||
"failedToRegisterAccount": "Hesab qeydiyyatı uğursuz oldu: ${error}",
|
||||
"enterJellyfinUrlError": "Jellyfin server URL-inizi daxil edin",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Qoşulma əlavə et",
|
||||
"addConnectionTitleScoped": "${name} profilinə əlavə et",
|
||||
"signInWithPlexCard": "Plex ilə daxil ol",
|
||||
"signInWithPlexCardSubtitle": "Bu cihazı səlahiyyətləndirin.",
|
||||
"signInWithPlexCardSubtitleScoped": "Plex hesabını səlahiyyətləndirin.",
|
||||
"connectToJellyfinCard": "Jellyfin-ə qoşul",
|
||||
"connectToJellyfinCardSubtitle": "Server URL, istifadəçi adı və şifrənizi daxil edin.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Jellyfin serverinə daxil olun. ${name} profilinə bağlanır.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Başqa profildən götür",
|
||||
"borrowFromAnotherProfileSubtitle": "Başqa profilin qoşulmasını yenidən istifadə edin."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Изчакване на удостоверяване...\nВлезте от браузъра си.",
|
||||
"useBrowser": "Използвай браузър",
|
||||
"or": "или",
|
||||
"connectToJellyfin": "Свържи се с Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Използвай Quick Connect",
|
||||
"quickConnectInstructions": "Отворете Quick Connect в Jellyfin и въведете този код.",
|
||||
"quickConnectWaiting": "Изчакване на одобрение…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Сесията за ${name} е изтекла",
|
||||
"sessionExpiredMany": "Сесиите за ${count} сървъра са изтекли",
|
||||
"signInAgain": "Влез отново",
|
||||
"editJellyfinTitle": "Редактиране на Jellyfin връзка",
|
||||
"editJellyfinIntro": "Добавете или премахнете URL адреси за ${serverName}. Plezy ще използва достъпния URL адрес с най-ниска латентност."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Открий",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Добави Jellyfin сървър",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "URL адреси на сървъра",
|
||||
"serverUrlsHelper": "Позволени са няколко URL адреса, разделени със запетаи.",
|
||||
"findServer": "Намери сървър",
|
||||
"searchingLocalServers": "Търсене на локални Jellyfin сървъри...",
|
||||
"localServers": "Локални Jellyfin сървъри",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Потребителско име",
|
||||
"password": "Парола",
|
||||
"signIn": "Вход",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Вход с Plex",
|
||||
"pinExpired": "PIN-ът изтече преди вход. Моля, опитайте отново.",
|
||||
"failedToRegisterAccount": "Неуспешна регистрация на акаунт: ${error}",
|
||||
"enterJellyfinUrlError": "Въведете URL адреса на вашия Jellyfin сървър",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Добави връзка",
|
||||
"addConnectionTitleScoped": "Добави към ${name}",
|
||||
"signInWithPlexCard": "Вход с Plex",
|
||||
"signInWithPlexCardSubtitle": "Удостоверете това устройство. Споделените сървъри се добавят.",
|
||||
"signInWithPlexCardSubtitleScoped": "Удостоверете Plex акаунт. Домашните потребители стават профили.",
|
||||
"connectToJellyfinCard": "Свързване с Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Въведете URL адрес на сървъра, потребителско име и парола.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Вход в Jellyfin сървър. Свързва се с ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Използвай от друг профил",
|
||||
"borrowFromAnotherProfileSubtitle": "Използвай връзка от друг профил. PIN-защитените профили изискват PIN."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Venter på godkendelse...\nLog ind fra din browser.",
|
||||
"useBrowser": "Brug browseren",
|
||||
"or": "eller",
|
||||
"connectToJellyfin": "Forbind til Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Brug Quick Connect",
|
||||
"quickConnectInstructions": "Åbn Quick Connect i Jellyfin, og indtast denne kode.",
|
||||
"quickConnectWaiting": "Venter på godkendelse…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Sessionen er udløbet for ${name}",
|
||||
"sessionExpiredMany": "Sessionerne er udløbet for ${count} servere",
|
||||
"signInAgain": "Log ind igen",
|
||||
"editJellyfinTitle": "Rediger Jellyfin-forbindelse",
|
||||
"editJellyfinIntro": "Tilføj eller fjern URL'er for ${serverName}. Plezy bruger den tilgængelige URL med lavest latenstid."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Opdag",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Tilføj Jellyfin-server",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Server-URL'er",
|
||||
"serverUrlsHelper": "Du kan angive flere URL'er adskilt med komma.",
|
||||
"findServer": "Find server",
|
||||
"searchingLocalServers": "Søger efter lokale Jellyfin-servere...",
|
||||
"localServers": "Lokale Jellyfin-servere",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Brugernavn",
|
||||
"password": "Adgangskode",
|
||||
"signIn": "Log ind",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Log ind med Plex",
|
||||
"pinExpired": "PIN-koden udløb før login. Prøv igen.",
|
||||
"failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}",
|
||||
"enterJellyfinUrlError": "Angiv URL'en til din Jellyfin-server",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Tilføj forbindelse",
|
||||
"addConnectionTitleScoped": "Tilføj til ${name}",
|
||||
"signInWithPlexCard": "Log ind med Plex",
|
||||
"signInWithPlexCardSubtitle": "Godkend denne enhed. Delte servere tilføjes.",
|
||||
"signInWithPlexCardSubtitleScoped": "Godkend en Plex-konto. Plex Home-brugere bliver til profiler.",
|
||||
"connectToJellyfinCard": "Forbind til Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Indtast din server-URL, dit brugernavn og din adgangskode.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Log ind på en Jellyfin-server. Serveren knyttes til ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Lån fra en anden profil",
|
||||
"borrowFromAnotherProfileSubtitle": "Genbrug en anden profils forbindelse. PIN-beskyttede profiler kræver en PIN."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Warte auf die Authentifizierung …\nMelde dich über deinen Browser an.",
|
||||
"useBrowser": "Browser verwenden",
|
||||
"or": "oder",
|
||||
"connectToJellyfin": "Mit Jellyfin verbinden",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Quick Connect verwenden",
|
||||
"quickConnectInstructions": "Öffne Quick Connect in Jellyfin und gib diesen Code ein.",
|
||||
"quickConnectWaiting": "Warte auf Bestätigung…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Sitzung für ${name} abgelaufen",
|
||||
"sessionExpiredMany": "Sitzungen für ${count} Server abgelaufen",
|
||||
"signInAgain": "Erneut anmelden",
|
||||
"editJellyfinTitle": "Jellyfin-Verbindung bearbeiten",
|
||||
"editJellyfinIntro": "Füge URLs für ${serverName} hinzu oder entferne sie. Plezy verwendet die erreichbare URL mit der geringsten Latenz."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Entdecken",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin-Server hinzufügen",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Server-URLs",
|
||||
"serverUrlsHelper": "Mehrere URLs möglich, durch Kommas getrennt.",
|
||||
"findServer": "Server finden",
|
||||
"searchingLocalServers": "Suche nach lokalen Jellyfin-Servern …",
|
||||
"localServers": "Lokale Jellyfin-Server",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"signIn": "Anmelden",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Mit Plex anmelden",
|
||||
"pinExpired": "PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.",
|
||||
"failedToRegisterAccount": "Konto konnte nicht registriert werden: ${error}",
|
||||
"enterJellyfinUrlError": "Gib die URL deines Jellyfin-Servers ein",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Verbindung hinzufügen",
|
||||
"addConnectionTitleScoped": "Zu ${name} hinzufügen",
|
||||
"signInWithPlexCard": "Mit Plex anmelden",
|
||||
"signInWithPlexCardSubtitle": "Dieses Gerät autorisieren. Geteilte Server werden hinzugefügt.",
|
||||
"signInWithPlexCardSubtitleScoped": "Ein Plex-Konto autorisieren. Home-Benutzer werden zu Profilen.",
|
||||
"connectToJellyfinCard": "Mit Jellyfin verbinden",
|
||||
"connectToJellyfinCardSubtitle": "Gib Server-URL, Benutzername und Passwort ein.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Von einem anderen Profil ausleihen",
|
||||
"borrowFromAnotherProfileSubtitle": "Verbindung eines anderen Profils wiederverwenden. PIN-geschützte Profile erfordern eine PIN."
|
||||
}
|
||||
|
||||
+14
-14
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Waiting for authentication...\nSign in from your browser.",
|
||||
"useBrowser": "Use browser",
|
||||
"or": "or",
|
||||
"connectToJellyfin": "Connect to Jellyfin",
|
||||
"connectToMediaBrowser": "Connect to ${product}",
|
||||
"useQuickConnect": "Use Quick Connect",
|
||||
"quickConnectInstructions": "Open Quick Connect in Jellyfin and enter this code.",
|
||||
"quickConnectWaiting": "Waiting for approval…",
|
||||
@@ -802,7 +802,7 @@
|
||||
"borrowAddTo": "Add to ${displayName}",
|
||||
"borrowExplain": "Borrow another profile's connection. PIN-protected profiles require a PIN.",
|
||||
"borrowEmpty": "Nothing to borrow yet.",
|
||||
"borrowEmptySubtitle": "Connect Plex or Jellyfin to another profile first.",
|
||||
"borrowEmptySubtitle": "Connect Plex, Jellyfin, or Emby to another profile first.",
|
||||
"borrowLoadFailed": "Available connections could not be loaded. Try again.",
|
||||
"borrowFromProfile": "From ${displayName}",
|
||||
"borrowConnectionBorrowed": "Connection borrowed.",
|
||||
@@ -822,13 +822,13 @@
|
||||
"connections": {
|
||||
"sectionTitle": "Connections",
|
||||
"addConnection": "Add connection",
|
||||
"addConnectionSubtitleNoProfile": "Sign in with Plex or connect a Jellyfin server",
|
||||
"addConnectionSubtitleScoped": "Add to ${displayName}: Plex, Jellyfin, or another profile connection",
|
||||
"addConnectionSubtitleNoProfile": "Sign in with Plex or connect a Jellyfin or Emby server",
|
||||
"addConnectionSubtitleScoped": "Add to ${displayName}: Plex, Jellyfin, Emby, or another profile connection",
|
||||
"sessionExpiredOne": "Session expired for ${name}",
|
||||
"sessionExpiredMany": "Session expired for ${count} servers",
|
||||
"signInAgain": "Sign in again",
|
||||
"editJellyfinTitle": "Edit Jellyfin connection",
|
||||
"editJellyfinIntro": "Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency."
|
||||
"editMediaBrowserTitle": "Edit ${product} connection",
|
||||
"editMediaBrowserIntro": "Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency."
|
||||
},
|
||||
"discover": {
|
||||
"title": "Discover",
|
||||
@@ -966,7 +966,7 @@
|
||||
"title": "About",
|
||||
"openSourceLicenses": "Open Source Licenses",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "A beautiful Plex and Jellyfin client for Flutter",
|
||||
"appDescription": "A beautiful Plex, Jellyfin, and Emby client for Flutter",
|
||||
"viewLicensesDescription": "View licenses of third-party libraries"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Add Jellyfin server",
|
||||
"addMediaBrowserTitle": "Add ${product} server",
|
||||
"serverUrls": "Server URLs",
|
||||
"serverUrlsHelper": "Multiple URLs allowed, separated by commas.",
|
||||
"findServer": "Find server",
|
||||
"searchingLocalServers": "Looking for local Jellyfin servers...",
|
||||
"localServers": "Local Jellyfin servers",
|
||||
"searchingLocalMediaBrowserServers": "Looking for local ${product} servers...",
|
||||
"localMediaBrowserServers": "Local ${product} servers",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"signIn": "Sign in",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Sign in with Plex",
|
||||
"pinExpired": "PIN expired before sign-in. Please try again.",
|
||||
"failedToRegisterAccount": "Failed to register account: ${error}",
|
||||
"enterJellyfinUrlError": "Enter your Jellyfin server URL",
|
||||
"enterMediaBrowserUrlError": "Enter your ${product} server URL",
|
||||
"addConnectionTitle": "Add connection",
|
||||
"addConnectionTitleScoped": "Add to ${name}",
|
||||
"signInWithPlexCard": "Sign in with Plex",
|
||||
"signInWithPlexCardSubtitle": "Authorize this device. Shared servers are added.",
|
||||
"signInWithPlexCardSubtitleScoped": "Authorize a Plex account. Home users become profiles.",
|
||||
"connectToJellyfinCard": "Connect to Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Enter your server URL, username, and password.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Sign in to a Jellyfin server. Binds to ${name}.",
|
||||
"connectToMediaBrowserCard": "Connect to ${product}",
|
||||
"connectToMediaBrowserCardSubtitle": "Enter your server URL, username, and password.",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "Sign in to your ${product} server. Binds to ${name}.",
|
||||
"borrowFromAnotherProfile": "Borrow from another profile",
|
||||
"borrowFromAnotherProfileSubtitle": "Reuse another profile's connection. PIN-protected profiles require a PIN."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Esperando autenticación...\nInicia sesión desde tu navegador.",
|
||||
"useBrowser": "Usar navegador",
|
||||
"or": "o",
|
||||
"connectToJellyfin": "Conectar a Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Usar Quick Connect",
|
||||
"quickConnectInstructions": "Abre Quick Connect en Jellyfin e introduce este código.",
|
||||
"quickConnectWaiting": "Esperando aprobación…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Sesión caducada para ${name}",
|
||||
"sessionExpiredMany": "Sesión caducada para ${count} servidores",
|
||||
"signInAgain": "Iniciar sesión de nuevo",
|
||||
"editJellyfinTitle": "Editar conexión de Jellyfin",
|
||||
"editJellyfinIntro": "Añade o elimina direcciones URL para ${serverName}. Plezy usará la dirección accesible con menor latencia."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Descubrir",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Añadir servidor Jellyfin",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Direcciones URL del servidor",
|
||||
"serverUrlsHelper": "Se permiten varias URL, separadas por comas.",
|
||||
"findServer": "Buscar servidor",
|
||||
"searchingLocalServers": "Buscando servidores Jellyfin locales...",
|
||||
"localServers": "Servidores Jellyfin locales",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Usuario",
|
||||
"password": "Contraseña",
|
||||
"signIn": "Iniciar sesión",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Iniciar sesión con Plex",
|
||||
"pinExpired": "El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.",
|
||||
"failedToRegisterAccount": "No se pudo registrar la cuenta: ${error}",
|
||||
"enterJellyfinUrlError": "Introduce la URL de tu servidor Jellyfin",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Añadir conexión",
|
||||
"addConnectionTitleScoped": "Añadir a ${name}",
|
||||
"signInWithPlexCard": "Iniciar sesión con Plex",
|
||||
"signInWithPlexCardSubtitle": "Autoriza este dispositivo. Se añaden servidores compartidos.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autoriza una cuenta Plex. Los usuarios de Home se convierten en perfiles.",
|
||||
"connectToJellyfinCard": "Conectar a Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Introduce la URL del servidor, usuario y contraseña.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Inicia sesión en un servidor Jellyfin. Se vincula a ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Tomar prestado de otro perfil",
|
||||
"borrowFromAnotherProfileSubtitle": "Reutiliza la conexión de otro perfil. Los perfiles protegidos con PIN requieren un PIN."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "En attente d'authentification...\nConnectez-vous depuis votre navigateur.",
|
||||
"useBrowser": "Utiliser le navigateur",
|
||||
"or": "ou",
|
||||
"connectToJellyfin": "Se connecter à Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Utiliser Quick Connect",
|
||||
"quickConnectInstructions": "Ouvrez Quick Connect dans Jellyfin et saisissez ce code.",
|
||||
"quickConnectWaiting": "En attente d'approbation…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Session expirée pour ${name}",
|
||||
"sessionExpiredMany": "Session expirée pour ${count} serveurs",
|
||||
"signInAgain": "Se reconnecter",
|
||||
"editJellyfinTitle": "Modifier la connexion Jellyfin",
|
||||
"editJellyfinIntro": "Ajoutez ou supprimez des URL pour ${serverName}. Plezy utilisera l'URL joignable avec la latence la plus faible."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Découvrir",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Ajouter un serveur Jellyfin",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "URL du serveur",
|
||||
"serverUrlsHelper": "Plusieurs URL possibles, séparées par des virgules.",
|
||||
"findServer": "Rechercher un serveur",
|
||||
"searchingLocalServers": "Recherche de serveurs Jellyfin locaux...",
|
||||
"localServers": "Serveurs Jellyfin locaux",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"signIn": "Se connecter",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Se connecter avec Plex",
|
||||
"pinExpired": "Le PIN a expiré avant la connexion. Veuillez réessayer.",
|
||||
"failedToRegisterAccount": "Échec de l'enregistrement du compte : ${error}",
|
||||
"enterJellyfinUrlError": "Saisissez l'URL de votre serveur Jellyfin",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Ajouter une connexion",
|
||||
"addConnectionTitleScoped": "Ajouter à ${name}",
|
||||
"signInWithPlexCard": "Se connecter avec Plex",
|
||||
"signInWithPlexCardSubtitle": "Autorisez cet appareil. Les serveurs partagés sont ajoutés.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autorisez un compte Plex. Les utilisateurs Home deviennent des profils.",
|
||||
"connectToJellyfinCard": "Se connecter à Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Saisissez l'URL du serveur, le nom d'utilisateur et le mot de passe.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Connectez-vous à un serveur Jellyfin. Cette connexion sera liée à ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Emprunter à un autre profil",
|
||||
"borrowFromAnotherProfileSubtitle": "Réutiliser la connexion d'un autre profil. Les profils protégés par PIN exigent un PIN."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Várakozás a hitelesítésre...\nJelentkezz be a böngésződben.",
|
||||
"useBrowser": "Böngésző használata",
|
||||
"or": "vagy",
|
||||
"connectToJellyfin": "Csatlakozás Jellyfinhez",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Quick Connect használata",
|
||||
"quickConnectInstructions": "Nyisd meg a Quick Connect-et a Jellyfinben, és add meg ezt a kódot.",
|
||||
"quickConnectWaiting": "Várakozás a jóváhagyásra…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "A(z) ${name} munkamenete lejárt",
|
||||
"sessionExpiredMany": "${count} szerver munkamenete lejárt",
|
||||
"signInAgain": "Bejelentkezés újra",
|
||||
"editJellyfinTitle": "Jellyfin kapcsolat szerkesztése",
|
||||
"editJellyfinIntro": "URL-ek hozzáadása vagy eltávolítása ehhez: ${serverName}. A Plezy a legalacsonyabb késleltetésű, elérhető URL-t fogja használni."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Felfedezés",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin szerver hozzáadása",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Szerver URL-címei",
|
||||
"serverUrlsHelper": "Több URL is megadható, vesszővel elválasztva.",
|
||||
"findServer": "Szerver keresése",
|
||||
"searchingLocalServers": "Helyi Jellyfin-szerverek keresése...",
|
||||
"localServers": "Helyi Jellyfin-szerverek",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Felhasználónév",
|
||||
"password": "Jelszó",
|
||||
"signIn": "Bejelentkezés",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Bejelentkezés Plexszel",
|
||||
"pinExpired": "A PIN-kód a bejelentkezés előtt lejárt. Próbáld újra.",
|
||||
"failedToRegisterAccount": "Nem sikerült a fiók regisztrálása: ${error}",
|
||||
"enterJellyfinUrlError": "Add meg a Jellyfin szervered URL-jét",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Kapcsolat hozzáadása",
|
||||
"addConnectionTitleScoped": "Hozzáadás a következőhöz: ${name}",
|
||||
"signInWithPlexCard": "Bejelentkezés Plexszel",
|
||||
"signInWithPlexCardSubtitle": "Eszköz engedélyezése. A megosztott szerverek hozzáadásra kerülnek.",
|
||||
"signInWithPlexCardSubtitleScoped": "Plex-fiók engedélyezése. A Plex Home-felhasználókból profilok lesznek.",
|
||||
"connectToJellyfinCard": "Csatlakozás Jellyfinhez",
|
||||
"connectToJellyfinCardSubtitle": "Add meg a szerver URL-jét, felhasználónevedet és jelszavadat.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Bejelentkezés egy Jellyfin-szerverre. Hozzárendelés ehhez: ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Kapcsolat használata másik profilból",
|
||||
"borrowFromAnotherProfileSubtitle": "Egy másik profil kapcsolatának használata. A PIN-kóddal védett profilokhoz PIN-kód szükséges."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "In attesa di autenticazione...\nAccedi dal browser.",
|
||||
"useBrowser": "Usa il browser",
|
||||
"or": "o",
|
||||
"connectToJellyfin": "Connettiti a Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Usa Quick Connect",
|
||||
"quickConnectInstructions": "Apri Quick Connect in Jellyfin e inserisci questo codice.",
|
||||
"quickConnectWaiting": "In attesa di approvazione…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Sessione scaduta per ${name}",
|
||||
"sessionExpiredMany": "Sessione scaduta per ${count} server",
|
||||
"signInAgain": "Accedi di nuovo",
|
||||
"editJellyfinTitle": "Modifica connessione Jellyfin",
|
||||
"editJellyfinIntro": "Aggiungi o rimuovi URL per ${serverName}. Plezy userà l'URL raggiungibile con la latenza più bassa."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Esplora",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Aggiungi server Jellyfin",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "URL del server",
|
||||
"serverUrlsHelper": "Sono consentiti più URL, separati da virgole.",
|
||||
"findServer": "Trova il server",
|
||||
"searchingLocalServers": "Ricerca dei server Jellyfin locali...",
|
||||
"localServers": "Server Jellyfin locali",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Nome utente",
|
||||
"password": "Password",
|
||||
"signIn": "Accedi",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Accedi con Plex",
|
||||
"pinExpired": "PIN scaduto prima dell'accesso. Riprova.",
|
||||
"failedToRegisterAccount": "Registrazione account non riuscita: ${error}",
|
||||
"enterJellyfinUrlError": "Inserisci l'URL del tuo server Jellyfin",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Aggiungi connessione",
|
||||
"addConnectionTitleScoped": "Aggiungi a ${name}",
|
||||
"signInWithPlexCard": "Accedi con Plex",
|
||||
"signInWithPlexCardSubtitle": "Autorizza questo dispositivo. I server condivisi vengono aggiunti.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autorizza un account Plex. Gli utenti Home diventano profili.",
|
||||
"connectToJellyfinCard": "Connettiti a Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Inserisci l'URL del server, il nome utente e la password.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Accedi a un server Jellyfin. Verrà associato a ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Prendi in prestito da un altro profilo",
|
||||
"borrowFromAnotherProfileSubtitle": "Riutilizza la connessione di un altro profilo. I profili protetti da PIN richiedono un PIN."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "認証を待っています…\nブラウザでサインインしてください。",
|
||||
"useBrowser": "ブラウザを使用",
|
||||
"or": "または",
|
||||
"connectToJellyfin": "Jellyfinに接続",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Quick Connect を使う",
|
||||
"quickConnectInstructions": "JellyfinでQuick Connectを開き、このコードを入力してください。",
|
||||
"quickConnectWaiting": "承認を待っています…",
|
||||
@@ -826,8 +826,8 @@
|
||||
"sessionExpiredOne": "${name} のセッションの有効期限が切れました",
|
||||
"sessionExpiredMany": "${count} 台のサーバーのセッションの有効期限が切れました",
|
||||
"signInAgain": "再度サインイン",
|
||||
"editJellyfinTitle": "Jellyfin接続を編集",
|
||||
"editJellyfinIntro": "${serverName}のURLを追加または削除します。Plezyは接続可能なURLのうち遅延が最も少ないものを使用します。"
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "探す",
|
||||
@@ -1867,12 +1867,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfinサーバーを追加",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "サーバーURL",
|
||||
"serverUrlsHelper": "複数のURLをカンマ区切りで入力できます。",
|
||||
"findServer": "サーバーを検索",
|
||||
"searchingLocalServers": "ローカルのJellyfinサーバーを検索中…",
|
||||
"localServers": "ローカルのJellyfinサーバー",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"signIn": "サインイン",
|
||||
@@ -1884,15 +1884,15 @@
|
||||
"addPlexTitle": "Plexでサインイン",
|
||||
"pinExpired": "サインイン前にPINの有効期限が切れました。もう一度お試しください。",
|
||||
"failedToRegisterAccount": "アカウントの登録に失敗しました: ${error}",
|
||||
"enterJellyfinUrlError": "JellyfinサーバーのURLを入力してください",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "接続を追加",
|
||||
"addConnectionTitleScoped": "${name}に追加",
|
||||
"signInWithPlexCard": "Plexでサインイン",
|
||||
"signInWithPlexCardSubtitle": "このデバイスを承認します。共有サーバーが追加されます。",
|
||||
"signInWithPlexCardSubtitleScoped": "Plexアカウントを承認します。Homeユーザーはプロフィールになります。",
|
||||
"connectToJellyfinCard": "Jellyfinに接続",
|
||||
"connectToJellyfinCardSubtitle": "サーバーURL、ユーザー名、パスワードを入力してください。",
|
||||
"connectToJellyfinCardSubtitleScoped": "Jellyfinサーバーにサインインします。${name}にひも付けられます。",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "別のプロフィールの接続を利用",
|
||||
"borrowFromAnotherProfileSubtitle": "別のプロフィールの接続を再利用します。PINで保護されたプロフィールにはPINが必要です。"
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Растау күтілуде...\nБраузеріңізден кіріңіз.",
|
||||
"useBrowser": "Браузерді пайдалану",
|
||||
"or": "немесе",
|
||||
"connectToJellyfin": "Jellyfin-ге қосылу",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Жылдам қосылуды пайдалану",
|
||||
"quickConnectInstructions": "Jellyfin-де Жылдам қосылуды ашып, осы кодты енгізіңіз.",
|
||||
"quickConnectWaiting": "Растау күтілуде…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "${name} үшін сеанс мерзімі өтті",
|
||||
"sessionExpiredMany": "${count} сервер үшін сеанс мерзімі өтті",
|
||||
"signInAgain": "Қайтадан кіру",
|
||||
"editJellyfinTitle": "Jellyfin қосылымын өңдеу",
|
||||
"editJellyfinIntro": "${serverName} үшін URL мекенжайын қосыңыз немесе өшіріңіз."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Шолу",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin серверін қосу",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Сервер URL-дері",
|
||||
"serverUrlsHelper": "Үтірмен бөлінген бірнеше URL мекенжайына рұқсат етіледі.",
|
||||
"findServer": "Серверді табу",
|
||||
"searchingLocalServers": "Жергілікті Jellyfin серверлері ізделуде...",
|
||||
"localServers": "Жергілікті Jellyfin серверлері",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Пайдаланушы аты",
|
||||
"password": "Құпия сөз",
|
||||
"signIn": "Кіру",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Plex арқылы кіру",
|
||||
"pinExpired": "PIN код мерзімі өтті.",
|
||||
"failedToRegisterAccount": "Тіркелгіні тіркеу қатесі: ${error}",
|
||||
"enterJellyfinUrlError": "Jellyfin сервер URL-ін енгізіңіз",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Қосылым қосу",
|
||||
"addConnectionTitleScoped": "${name} профиліне қосу",
|
||||
"signInWithPlexCard": "Plex арқылы кіру",
|
||||
"signInWithPlexCardSubtitle": "Осы құрылғыны авторизациялау.",
|
||||
"signInWithPlexCardSubtitleScoped": "Plex тіркелгісін авторизациялау.",
|
||||
"connectToJellyfinCard": "Jellyfin-ге қосылу",
|
||||
"connectToJellyfinCardSubtitle": "Сервер URL-ін, пайдаланушы атын енгізіңіз.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Jellyfin серверіне кіру. ${name} профиліне жалғануда.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Басқа профильден алу",
|
||||
"borrowFromAnotherProfileSubtitle": "Басқа профильдің қосылымын қайта пайдалану."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "인증 대기 중...\n브라우저에서 로그인하세요.",
|
||||
"useBrowser": "브라우저 사용",
|
||||
"or": "또는",
|
||||
"connectToJellyfin": "Jellyfin에 연결",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Quick Connect 사용",
|
||||
"quickConnectInstructions": "Jellyfin에서 Quick Connect를 열고 이 코드를 입력하세요.",
|
||||
"quickConnectWaiting": "승인 대기 중…",
|
||||
@@ -826,8 +826,8 @@
|
||||
"sessionExpiredOne": "${name}의 세션이 만료되었습니다",
|
||||
"sessionExpiredMany": "${count}개 서버의 세션이 만료되었습니다",
|
||||
"signInAgain": "다시 로그인",
|
||||
"editJellyfinTitle": "Jellyfin 연결 편집",
|
||||
"editJellyfinIntro": "${serverName}의 URL을 추가하거나 제거하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "둘러보기",
|
||||
@@ -1867,12 +1867,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin 서버 추가",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "서버 URL",
|
||||
"serverUrlsHelper": "쉼표로 구분하여 여러 URL을 입력할 수 있습니다.",
|
||||
"findServer": "서버 찾기",
|
||||
"searchingLocalServers": "로컬 Jellyfin 서버 검색 중...",
|
||||
"localServers": "로컬 Jellyfin 서버",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "사용자 이름",
|
||||
"password": "비밀번호",
|
||||
"signIn": "로그인",
|
||||
@@ -1884,15 +1884,15 @@
|
||||
"addPlexTitle": "Plex로 로그인",
|
||||
"pinExpired": "로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.",
|
||||
"failedToRegisterAccount": "계정 등록 실패: ${error}",
|
||||
"enterJellyfinUrlError": "Jellyfin 서버 URL을 입력하세요",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "연결 추가",
|
||||
"addConnectionTitleScoped": "${name}에 추가",
|
||||
"signInWithPlexCard": "Plex로 로그인",
|
||||
"signInWithPlexCardSubtitle": "이 기기를 승인합니다. 공유 서버가 추가됩니다.",
|
||||
"signInWithPlexCardSubtitleScoped": "Plex 계정을 승인합니다. Home 사용자는 프로필이 됩니다.",
|
||||
"connectToJellyfinCard": "Jellyfin에 연결",
|
||||
"connectToJellyfinCardSubtitle": "서버 URL, 사용자 이름, 비밀번호를 입력하세요.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "다른 프로필에서 빌리기",
|
||||
"borrowFromAnotherProfileSubtitle": "다른 프로필의 연결을 재사용합니다. PIN으로 보호된 프로필에는 PIN이 필요합니다."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Venter på autentisering...\nLogg inn fra nettleseren.",
|
||||
"useBrowser": "Bruk nettleser",
|
||||
"or": "eller",
|
||||
"connectToJellyfin": "Koble til Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Bruk Quick Connect",
|
||||
"quickConnectInstructions": "Åpne Quick Connect i Jellyfin og skriv inn denne koden.",
|
||||
"quickConnectWaiting": "Venter på godkjenning…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Økten er utløpt for ${name}",
|
||||
"sessionExpiredMany": "Økten er utløpt for ${count} servere",
|
||||
"signInAgain": "Logg inn igjen",
|
||||
"editJellyfinTitle": "Rediger Jellyfin-tilkobling",
|
||||
"editJellyfinIntro": "Legg til eller fjern URL-er for ${serverName}. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Oppdag",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Legg til Jellyfin-server",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Server-URL-er",
|
||||
"serverUrlsHelper": "Flere URL-er er tillatt, atskilt med komma.",
|
||||
"findServer": "Finn server",
|
||||
"searchingLocalServers": "Søker etter lokale Jellyfin-servere...",
|
||||
"localServers": "Lokale Jellyfin-servere",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Brukernavn",
|
||||
"password": "Passord",
|
||||
"signIn": "Logg inn",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Logg inn med Plex",
|
||||
"pinExpired": "PIN-koden utløp før innloggingen var fullført. Prøv igjen.",
|
||||
"failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}",
|
||||
"enterJellyfinUrlError": "Oppgi URL-en til Jellyfin-serveren din",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Legg til tilkobling",
|
||||
"addConnectionTitleScoped": "Legg til for ${name}",
|
||||
"signInWithPlexCard": "Logg inn med Plex",
|
||||
"signInWithPlexCardSubtitle": "Autoriser denne enheten. Delte servere legges til.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autoriser en Plex-konto. Home-brukere blir profiler.",
|
||||
"connectToJellyfinCard": "Koble til Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Skriv inn server-URL, brukernavn og passord.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Logg på en Jellyfin-server. Knyttes til ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Lån fra en annen profil",
|
||||
"borrowFromAnotherProfileSubtitle": "Gjenbruk en annen profils tilkobling. PIN-beskyttede profiler krever PIN."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Wachten op authenticatie...\nMeld je aan via je browser.",
|
||||
"useBrowser": "Gebruik browser",
|
||||
"or": "of",
|
||||
"connectToJellyfin": "Verbinden met Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Quick Connect gebruiken",
|
||||
"quickConnectInstructions": "Open Quick Connect in Jellyfin en voer deze code in.",
|
||||
"quickConnectWaiting": "Wachten op goedkeuring…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Sessie verlopen voor ${name}",
|
||||
"sessionExpiredMany": "Sessie verlopen voor ${count} servers",
|
||||
"signInAgain": "Opnieuw aanmelden",
|
||||
"editJellyfinTitle": "Jellyfin-verbinding bewerken",
|
||||
"editJellyfinIntro": "Voeg URL's voor ${serverName} toe of verwijder ze. Plezy gebruikt de bereikbare URL met de laagste latentie."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Ontdekken",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin-server toevoegen",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Server-URL's",
|
||||
"serverUrlsHelper": "Meerdere URL's toegestaan, gescheiden door komma's.",
|
||||
"findServer": "Server zoeken",
|
||||
"searchingLocalServers": "Lokale Jellyfin-servers zoeken...",
|
||||
"localServers": "Lokale Jellyfin-servers",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Gebruikersnaam",
|
||||
"password": "Wachtwoord",
|
||||
"signIn": "Inloggen",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Inloggen met Plex",
|
||||
"pinExpired": "De pincode verliep voordat je kon inloggen. Probeer het opnieuw.",
|
||||
"failedToRegisterAccount": "Account registreren mislukt: ${error}",
|
||||
"enterJellyfinUrlError": "Voer de URL van je Jellyfin-server in",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Verbinding toevoegen",
|
||||
"addConnectionTitleScoped": "Toevoegen aan ${name}",
|
||||
"signInWithPlexCard": "Inloggen met Plex",
|
||||
"signInWithPlexCardSubtitle": "Autoriseer dit apparaat. Gedeelde servers worden toegevoegd.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autoriseer een Plex-account. Home-gebruikers worden profielen.",
|
||||
"connectToJellyfinCard": "Verbinden met Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Voer je server-URL, gebruikersnaam en wachtwoord in.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Van een ander profiel lenen",
|
||||
"borrowFromAnotherProfileSubtitle": "Hergebruik de verbinding van een ander profiel. Voor profielen met pincodebeveiliging is een pincode vereist."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Oczekiwanie na uwierzytelnienie...\nZaloguj się w przeglądarce.",
|
||||
"useBrowser": "Użyj przeglądarki",
|
||||
"or": "lub",
|
||||
"connectToJellyfin": "Połącz z Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Użyj Quick Connect",
|
||||
"quickConnectInstructions": "Otwórz Quick Connect w Jellyfin i wpisz ten kod.",
|
||||
"quickConnectWaiting": "Oczekiwanie na zatwierdzenie…",
|
||||
@@ -829,8 +829,8 @@
|
||||
"sessionExpiredOne": "Sesja wygasła dla ${name}",
|
||||
"sessionExpiredMany": "Sesja wygasła dla ${count} serwerów",
|
||||
"signInAgain": "Zaloguj się ponownie",
|
||||
"editJellyfinTitle": "Edytuj połączenie Jellyfin",
|
||||
"editJellyfinIntro": "Dodaj lub usuń adresy URL dla ${serverName}. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Odkryj",
|
||||
@@ -1885,12 +1885,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Dodaj serwer Jellyfin",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Adresy URL serwera",
|
||||
"serverUrlsHelper": "Można podać wiele adresów URL rozdzielonych przecinkami.",
|
||||
"findServer": "Znajdź serwer",
|
||||
"searchingLocalServers": "Szukanie lokalnych serwerów Jellyfin...",
|
||||
"localServers": "Lokalne serwery Jellyfin",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Nazwa użytkownika",
|
||||
"password": "Hasło",
|
||||
"signIn": "Zaloguj się",
|
||||
@@ -1902,15 +1902,15 @@
|
||||
"addPlexTitle": "Zaloguj się przez Plex",
|
||||
"pinExpired": "PIN wygasł przed zalogowaniem. Spróbuj ponownie.",
|
||||
"failedToRegisterAccount": "Nie udało się zarejestrować konta: ${error}",
|
||||
"enterJellyfinUrlError": "Podaj URL serwera Jellyfin",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Dodaj połączenie",
|
||||
"addConnectionTitleScoped": "Dodaj do ${name}",
|
||||
"signInWithPlexCard": "Zaloguj się przez Plex",
|
||||
"signInWithPlexCardSubtitle": "Autoryzuj to urządzenie. Serwery udostępnione zostaną dodane.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autoryzuj konto Plex. Użytkownicy Home staną się profilami.",
|
||||
"connectToJellyfinCard": "Połącz z Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Wpisz URL serwera, nazwę użytkownika i hasło.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Zaloguj się do serwera Jellyfin. Powiązane z ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Pożycz z innego profilu",
|
||||
"borrowFromAnotherProfileSubtitle": "Użyj połączenia innego profilu. Profile chronione PIN-em wymagają podania PIN-u."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Aguardando autenticação...\nEntre pelo navegador.",
|
||||
"useBrowser": "Usar navegador",
|
||||
"or": "ou",
|
||||
"connectToJellyfin": "Conectar ao Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Usar Quick Connect",
|
||||
"quickConnectInstructions": "Abra o Quick Connect no Jellyfin e insira este código.",
|
||||
"quickConnectWaiting": "Aguardando aprovação…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Sessão de ${name} expirada",
|
||||
"sessionExpiredMany": "Sessões expiradas em ${count} servidores",
|
||||
"signInAgain": "Entrar novamente",
|
||||
"editJellyfinTitle": "Editar conexão Jellyfin",
|
||||
"editJellyfinIntro": "Adicione ou remova URLs de ${serverName}. O Plezy usará a URL acessível com a menor latência."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Descobrir",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Adicionar servidor Jellyfin",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "URLs do servidor",
|
||||
"serverUrlsHelper": "Várias URLs são permitidas, separadas por vírgulas.",
|
||||
"findServer": "Encontrar servidor",
|
||||
"searchingLocalServers": "Procurando servidores Jellyfin locais...",
|
||||
"localServers": "Servidores Jellyfin locais",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Usuário",
|
||||
"password": "Senha",
|
||||
"signIn": "Entrar",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Entrar com Plex",
|
||||
"pinExpired": "O PIN expirou antes de entrar. Tente novamente.",
|
||||
"failedToRegisterAccount": "Falha ao registrar a conta: ${error}",
|
||||
"enterJellyfinUrlError": "Insira a URL do seu servidor Jellyfin",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Adicionar conexão",
|
||||
"addConnectionTitleScoped": "Adicionar a ${name}",
|
||||
"signInWithPlexCard": "Entrar com Plex",
|
||||
"signInWithPlexCardSubtitle": "Autorize este dispositivo. Servidores compartilhados são adicionados.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autorize uma conta Plex. Os usuários do Plex Home se tornam perfis.",
|
||||
"connectToJellyfinCard": "Conectar ao Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Insira URL do servidor, usuário e senha.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Entre em um servidor Jellyfin. A conexão será vinculada a ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Pegar emprestado de outro perfil",
|
||||
"borrowFromAnotherProfileSubtitle": "Reutilize a conexão de outro perfil. Perfis protegidos por PIN exigem PIN."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Ожидание аутентификации...\nВыполните вход в браузере.",
|
||||
"useBrowser": "Использовать браузер",
|
||||
"or": "или",
|
||||
"connectToJellyfin": "Подключиться к Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Использовать Quick Connect",
|
||||
"quickConnectInstructions": "Откройте Quick Connect в Jellyfin и введите этот код.",
|
||||
"quickConnectWaiting": "Ожидание подтверждения…",
|
||||
@@ -829,8 +829,8 @@
|
||||
"sessionExpiredOne": "Сессия истекла для ${name}",
|
||||
"sessionExpiredMany": "Сессия истекла для ${count} серверов",
|
||||
"signInAgain": "Войти снова",
|
||||
"editJellyfinTitle": "Изменить подключение Jellyfin",
|
||||
"editJellyfinIntro": "Добавьте или удалите URL для ${serverName}. Plezy будет использовать доступный URL с минимальной задержкой."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Обзор",
|
||||
@@ -1885,12 +1885,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Добавить сервер Jellyfin",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "URL-адреса сервера",
|
||||
"serverUrlsHelper": "Можно указать несколько URL через запятую.",
|
||||
"findServer": "Найти сервер",
|
||||
"searchingLocalServers": "Поиск локальных серверов Jellyfin...",
|
||||
"localServers": "Локальные серверы Jellyfin",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"signIn": "Войти",
|
||||
@@ -1902,15 +1902,15 @@
|
||||
"addPlexTitle": "Войти через Plex",
|
||||
"pinExpired": "Срок действия PIN истёк до входа. Попробуйте снова.",
|
||||
"failedToRegisterAccount": "Не удалось зарегистрировать учётную запись: ${error}",
|
||||
"enterJellyfinUrlError": "Введите URL вашего сервера Jellyfin",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Добавить подключение",
|
||||
"addConnectionTitleScoped": "Добавить в ${name}",
|
||||
"signInWithPlexCard": "Войти через Plex",
|
||||
"signInWithPlexCardSubtitle": "Авторизуйте это устройство. Общие серверы будут добавлены.",
|
||||
"signInWithPlexCardSubtitleScoped": "Авторизуйте аккаунт Plex. Пользователи Home станут профилями.",
|
||||
"connectToJellyfinCard": "Подключиться к Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Введите URL сервера, имя пользователя и пароль.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Войдите на сервер Jellyfin. Привязывается к ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Использовать подключение другого профиля",
|
||||
"borrowFromAnotherProfileSubtitle": "Повторно используйте подключение другого профиля. Для защищённых профилей потребуется PIN."
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 32946 (1497 per locale)
|
||||
/// Strings: 32736 (1488 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$az extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Təsdiqləmə gözlənilir...\nSəyahətçinizdən (brauzer) daxil olun.';
|
||||
@override String get useBrowser => 'Səyahətçini istifadə et';
|
||||
@override String get or => 'və ya';
|
||||
@override String get connectToJellyfin => 'Jellyfin-ə qoşul';
|
||||
@override String get useQuickConnect => 'Sürətli Qoşulmanı istifadə et';
|
||||
@override String get quickConnectInstructions => 'Jellyfin-də Sürətli Qoşulmanı açın və bu kodu daxil edin.';
|
||||
@override String get quickConnectWaiting => 'Təsdiq gözlənilir…';
|
||||
@@ -922,8 +921,6 @@ class _Translations$connections$az extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => '${name} üçün seansın vaxtı bitdi';
|
||||
@override String sessionExpiredMany({required Object count}) => '${count} server üçün seansın vaxtı bitdi';
|
||||
@override String get signInAgain => 'Yenidən daxil ol';
|
||||
@override String get editJellyfinTitle => 'Jellyfin qoşulmasını dəyişdir';
|
||||
@override String editJellyfinIntro({required Object serverName}) => '${serverName} üçün URL əlavə edin və ya silin.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1814,12 +1811,9 @@ class _Translations$addServer$az extends Translations$addServer$en {
|
||||
final TranslationsAz _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Jellyfin serveri əlavə et';
|
||||
@override String get serverUrls => 'Server URL-ləri';
|
||||
@override String get serverUrlsHelper => 'Vergüllə ayrılmış bir neçə URL-ə icazə verilir.';
|
||||
@override String get findServer => 'Server tap';
|
||||
@override String get searchingLocalServers => 'Yerli Jellyfin serverləri axtarılır...';
|
||||
@override String get localServers => 'Yerli Jellyfin serverləri';
|
||||
@override String get username => 'İstifadəçi adı';
|
||||
@override String get password => 'Şifrə';
|
||||
@override String get signIn => 'Daxil ol';
|
||||
@@ -1831,15 +1825,11 @@ class _Translations$addServer$az extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Plex ilə daxil ol';
|
||||
@override String get pinExpired => 'PIN-in vaxtı bitdi. Lütfən təzədən cəhd edin.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Hesab qeydiyyatı uğursuz oldu: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Jellyfin server URL-inizi daxil edin';
|
||||
@override String get addConnectionTitle => 'Qoşulma əlavə et';
|
||||
@override String addConnectionTitleScoped({required Object name}) => '${name} profilinə əlavə et';
|
||||
@override String get signInWithPlexCard => 'Plex ilə daxil ol';
|
||||
@override String get signInWithPlexCardSubtitle => 'Bu cihazı səlahiyyətləndirin.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Plex hesabını səlahiyyətləndirin.';
|
||||
@override String get connectToJellyfinCard => 'Jellyfin-ə qoşul';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Server URL, istifadəçi adı və şifrənizi daxil edin.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfin serverinə daxil olun. ${name} profilinə bağlanır.';
|
||||
@override String get borrowFromAnotherProfile => 'Başqa profildən götür';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Başqa profilin qoşulmasını yenidən istifadə edin.';
|
||||
}
|
||||
@@ -2219,7 +2209,6 @@ extension on TranslationsAz {
|
||||
'auth.waitingForAuth' => 'Təsdiqləmə gözlənilir...\nSəyahətçinizdən (brauzer) daxil olun.',
|
||||
'auth.useBrowser' => 'Səyahətçini istifadə et',
|
||||
'auth.or' => 'və ya',
|
||||
'auth.connectToJellyfin' => 'Jellyfin-ə qoşul',
|
||||
'auth.useQuickConnect' => 'Sürətli Qoşulmanı istifadə et',
|
||||
'auth.quickConnectInstructions' => 'Jellyfin-də Sürətli Qoşulmanı açın və bu kodu daxil edin.',
|
||||
'auth.quickConnectWaiting' => 'Təsdiq gözlənilir…',
|
||||
@@ -2722,9 +2711,9 @@ extension on TranslationsAz {
|
||||
'videoControls.noChaptersAvailable' => 'Hissələr əlçatan deyil',
|
||||
'videoControls.queue' => 'Növbə',
|
||||
'videoControls.noQueueItems' => 'Növbədə element yoxdur',
|
||||
'videoControls.searchSubtitles' => 'Altyazı axtar',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.searchSubtitles' => 'Altyazı axtar',
|
||||
'videoControls.language' => 'Dil',
|
||||
'videoControls.noSubtitlesFound' => 'Altyazı tapılmadı',
|
||||
'videoControls.subtitleDownloaded' => 'Altyazı yükləndi',
|
||||
@@ -2884,8 +2873,6 @@ extension on TranslationsAz {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => '${name} üçün seansın vaxtı bitdi',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => '${count} server üçün seansın vaxtı bitdi',
|
||||
'connections.signInAgain' => 'Yenidən daxil ol',
|
||||
'connections.editJellyfinTitle' => 'Jellyfin qoşulmasını dəyişdir',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName} üçün URL əlavə edin və ya silin.',
|
||||
'discover.title' => 'Kəşf et',
|
||||
'discover.noContentAvailable' => 'Məzmun əlçatan deyil',
|
||||
'discover.addMediaToLibraries' => 'Kitabxanalarınıza bir az media əlavə edin',
|
||||
@@ -3236,11 +3223,11 @@ extension on TranslationsAz {
|
||||
'watchTogether.enterCodeHint' => '5 rəqəmli/hərfli kodu daxil edin',
|
||||
'watchTogether.pasteFromClipboard' => 'Buferdən yapışdır',
|
||||
'watchTogether.pleaseEnterCode' => 'Lütfən seans kodunu daxil edin',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.codeMustBe5Chars' => 'Seans kodu 5 simvol olmalıdır',
|
||||
'watchTogether.joinInstructions' => 'Qoşulmaq üçün təşkilatçının seans kodunu daxil edin.',
|
||||
'watchTogether.failedToCreate' => 'Seans yaradıla bilmədi',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.failedToJoin' => 'Seansa qoşuluna bilmədi',
|
||||
'watchTogether.sessionCodeCopied' => 'Seans kodu buferə kopyalandı',
|
||||
'watchTogether.relayUnreachable' => 'Rele serverinə çatmaq olmur. İnternet provayderinin bloklaması Birlikdə İzləməyə mane ola bilər.',
|
||||
@@ -3678,12 +3665,9 @@ extension on TranslationsAz {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Yalnız aşağıda seçilən kitabxanaları eyniləşdir.',
|
||||
'services.libraryFilter.libraries' => 'Kitabxanalar',
|
||||
'services.libraryFilter.noLibraries' => 'Kitabxana yoxdur',
|
||||
'addServer.addJellyfinTitle' => 'Jellyfin serveri əlavə et',
|
||||
'addServer.serverUrls' => 'Server URL-ləri',
|
||||
'addServer.serverUrlsHelper' => 'Vergüllə ayrılmış bir neçə URL-ə icazə verilir.',
|
||||
'addServer.findServer' => 'Server tap',
|
||||
'addServer.searchingLocalServers' => 'Yerli Jellyfin serverləri axtarılır...',
|
||||
'addServer.localServers' => 'Yerli Jellyfin serverləri',
|
||||
'addServer.username' => 'İstifadəçi adı',
|
||||
'addServer.password' => 'Şifrə',
|
||||
'addServer.signIn' => 'Daxil ol',
|
||||
@@ -3695,15 +3679,11 @@ extension on TranslationsAz {
|
||||
'addServer.addPlexTitle' => 'Plex ilə daxil ol',
|
||||
'addServer.pinExpired' => 'PIN-in vaxtı bitdi. Lütfən təzədən cəhd edin.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Hesab qeydiyyatı uğursuz oldu: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Jellyfin server URL-inizi daxil edin',
|
||||
'addServer.addConnectionTitle' => 'Qoşulma əlavə et',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name} profilinə əlavə et',
|
||||
'addServer.signInWithPlexCard' => 'Plex ilə daxil ol',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Bu cihazı səlahiyyətləndirin.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Plex hesabını səlahiyyətləndirin.',
|
||||
'addServer.connectToJellyfinCard' => 'Jellyfin-ə qoşul',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Server URL, istifadəçi adı və şifrənizi daxil edin.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfin serverinə daxil olun. ${name} profilinə bağlanır.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Başqa profildən götür',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Başqa profilin qoşulmasını yenidən istifadə edin.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$bg extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Изчакване на удостоверяване...\nВлезте от браузъра си.';
|
||||
@override String get useBrowser => 'Използвай браузър';
|
||||
@override String get or => 'или';
|
||||
@override String get connectToJellyfin => 'Свържи се с Jellyfin';
|
||||
@override String get useQuickConnect => 'Използвай Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Отворете Quick Connect в Jellyfin и въведете този код.';
|
||||
@override String get quickConnectWaiting => 'Изчакване на одобрение…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$bg extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Сесията за ${name} е изтекла';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Сесиите за ${count} сървъра са изтекли';
|
||||
@override String get signInAgain => 'Влез отново';
|
||||
@override String get editJellyfinTitle => 'Редактиране на Jellyfin връзка';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Добавете или премахнете URL адреси за ${serverName}. Plezy ще използва достъпния URL адрес с най-ниска латентност.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$bg extends Translations$addServer$en {
|
||||
final TranslationsBg _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Добави Jellyfin сървър';
|
||||
@override String get serverUrls => 'URL адреси на сървъра';
|
||||
@override String get serverUrlsHelper => 'Позволени са няколко URL адреса, разделени със запетаи.';
|
||||
@override String get findServer => 'Намери сървър';
|
||||
@override String get searchingLocalServers => 'Търсене на локални Jellyfin сървъри...';
|
||||
@override String get localServers => 'Локални Jellyfin сървъри';
|
||||
@override String get username => 'Потребителско име';
|
||||
@override String get password => 'Парола';
|
||||
@override String get signIn => 'Вход';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$bg extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Вход с Plex';
|
||||
@override String get pinExpired => 'PIN-ът изтече преди вход. Моля, опитайте отново.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Неуспешна регистрация на акаунт: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Въведете URL адреса на вашия Jellyfin сървър';
|
||||
@override String get addConnectionTitle => 'Добави връзка';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Добави към ${name}';
|
||||
@override String get signInWithPlexCard => 'Вход с Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Удостоверете това устройство. Споделените сървъри се добавят.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Удостоверете Plex акаунт. Домашните потребители стават профили.';
|
||||
@override String get connectToJellyfinCard => 'Свързване с Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Въведете URL адрес на сървъра, потребителско име и парола.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Вход в Jellyfin сървър. Свързва се с ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Използвай от друг профил';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Използвай връзка от друг профил. PIN-защитените профили изискват PIN.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsBg {
|
||||
'auth.waitingForAuth' => 'Изчакване на удостоверяване...\nВлезте от браузъра си.',
|
||||
'auth.useBrowser' => 'Използвай браузър',
|
||||
'auth.or' => 'или',
|
||||
'auth.connectToJellyfin' => 'Свържи се с Jellyfin',
|
||||
'auth.useQuickConnect' => 'Използвай Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Отворете Quick Connect в Jellyfin и въведете този код.',
|
||||
'auth.quickConnectWaiting' => 'Изчакване на одобрение…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsBg {
|
||||
'videoControls.searchSubtitles' => 'Търсене на субтитри',
|
||||
'videoControls.language' => 'Език',
|
||||
'videoControls.noSubtitlesFound' => 'Не са намерени субтитри',
|
||||
'videoControls.subtitleDownloaded' => 'Субтитърът е изтеглен',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Субтитърът е изтеглен',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Субтитрите са изтеглени, но не можаха да бъдат избрани',
|
||||
'videoControls.subtitleDownloadFailed' => 'Неуспешно изтегляне на субтитър',
|
||||
'videoControls.searchLanguages' => 'Търсене на езици...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsBg {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Сесията за ${name} е изтекла',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Сесиите за ${count} сървъра са изтекли',
|
||||
'connections.signInAgain' => 'Влез отново',
|
||||
'connections.editJellyfinTitle' => 'Редактиране на Jellyfin връзка',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Добавете или премахнете URL адреси за ${serverName}. Plezy ще използва достъпния URL адрес с най-ниска латентност.',
|
||||
'discover.title' => 'Открий',
|
||||
'discover.noContentAvailable' => 'Няма налично съдържание',
|
||||
'discover.addMediaToLibraries' => 'Добавете медия към библиотеките си',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsBg {
|
||||
'watchTogether.failedToCreate' => 'Неуспешно създаване на сесия',
|
||||
'watchTogether.failedToJoin' => 'Неуспешно присъединяване към сесия',
|
||||
'watchTogether.sessionCodeCopied' => 'Кодът на сесията е копиран в клипборда',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Релейният сървър е недостъпен. Възможно е интернет доставчикът да блокира гледането заедно.',
|
||||
'watchTogether.reconnectingToHost' => 'Повторно свързване с организатора...',
|
||||
'watchTogether.currentPlayback' => 'Текущо възпроизвеждане',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Присъедини се към текущото възпроизвеждане',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Върнете се към това, което организаторът гледа в момента',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Неуспешно отваряне на текущото възпроизвеждане',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsBg {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Синхронизирай само отметнатите по-долу библиотеки.',
|
||||
'services.libraryFilter.libraries' => 'Библиотеки',
|
||||
'services.libraryFilter.noLibraries' => 'Няма налични библиотеки',
|
||||
'addServer.addJellyfinTitle' => 'Добави Jellyfin сървър',
|
||||
'addServer.serverUrls' => 'URL адреси на сървъра',
|
||||
'addServer.serverUrlsHelper' => 'Позволени са няколко URL адреса, разделени със запетаи.',
|
||||
'addServer.findServer' => 'Намери сървър',
|
||||
'addServer.searchingLocalServers' => 'Търсене на локални Jellyfin сървъри...',
|
||||
'addServer.localServers' => 'Локални Jellyfin сървъри',
|
||||
'addServer.username' => 'Потребителско име',
|
||||
'addServer.password' => 'Парола',
|
||||
'addServer.signIn' => 'Вход',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsBg {
|
||||
'addServer.addPlexTitle' => 'Вход с Plex',
|
||||
'addServer.pinExpired' => 'PIN-ът изтече преди вход. Моля, опитайте отново.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Неуспешна регистрация на акаунт: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Въведете URL адреса на вашия Jellyfin сървър',
|
||||
'addServer.addConnectionTitle' => 'Добави връзка',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Добави към ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Вход с Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Удостоверете това устройство. Споделените сървъри се добавят.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Удостоверете Plex акаунт. Домашните потребители стават профили.',
|
||||
'addServer.connectToJellyfinCard' => 'Свързване с Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Въведете URL адрес на сървъра, потребителско име и парола.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Вход в Jellyfin сървър. Свързва се с ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Използвай от друг профил',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Използвай връзка от друг профил. PIN-защитените профили изискват PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$da extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Venter på godkendelse...\nLog ind fra din browser.';
|
||||
@override String get useBrowser => 'Brug browseren';
|
||||
@override String get or => 'eller';
|
||||
@override String get connectToJellyfin => 'Forbind til Jellyfin';
|
||||
@override String get useQuickConnect => 'Brug Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Åbn Quick Connect i Jellyfin, og indtast denne kode.';
|
||||
@override String get quickConnectWaiting => 'Venter på godkendelse…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$da extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sessionen er udløbet for ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sessionerne er udløbet for ${count} servere';
|
||||
@override String get signInAgain => 'Log ind igen';
|
||||
@override String get editJellyfinTitle => 'Rediger Jellyfin-forbindelse';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Tilføj eller fjern URL\'er for ${serverName}. Plezy bruger den tilgængelige URL med lavest latenstid.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$da extends Translations$addServer$en {
|
||||
final TranslationsDa _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Tilføj Jellyfin-server';
|
||||
@override String get serverUrls => 'Server-URL\'er';
|
||||
@override String get serverUrlsHelper => 'Du kan angive flere URL\'er adskilt med komma.';
|
||||
@override String get findServer => 'Find server';
|
||||
@override String get searchingLocalServers => 'Søger efter lokale Jellyfin-servere...';
|
||||
@override String get localServers => 'Lokale Jellyfin-servere';
|
||||
@override String get username => 'Brugernavn';
|
||||
@override String get password => 'Adgangskode';
|
||||
@override String get signIn => 'Log ind';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$da extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Log ind med Plex';
|
||||
@override String get pinExpired => 'PIN-koden udløb før login. Prøv igen.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Kunne ikke registrere kontoen: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Angiv URL\'en til din Jellyfin-server';
|
||||
@override String get addConnectionTitle => 'Tilføj forbindelse';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Tilføj til ${name}';
|
||||
@override String get signInWithPlexCard => 'Log ind med Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Godkend denne enhed. Delte servere tilføjes.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Godkend en Plex-konto. Plex Home-brugere bliver til profiler.';
|
||||
@override String get connectToJellyfinCard => 'Forbind til Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Indtast din server-URL, dit brugernavn og din adgangskode.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Log ind på en Jellyfin-server. Serveren knyttes til ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Lån fra en anden profil';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Genbrug en anden profils forbindelse. PIN-beskyttede profiler kræver en PIN.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsDa {
|
||||
'auth.waitingForAuth' => 'Venter på godkendelse...\nLog ind fra din browser.',
|
||||
'auth.useBrowser' => 'Brug browseren',
|
||||
'auth.or' => 'eller',
|
||||
'auth.connectToJellyfin' => 'Forbind til Jellyfin',
|
||||
'auth.useQuickConnect' => 'Brug Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Åbn Quick Connect i Jellyfin, og indtast denne kode.',
|
||||
'auth.quickConnectWaiting' => 'Venter på godkendelse…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsDa {
|
||||
'videoControls.searchSubtitles' => 'Søg undertekster',
|
||||
'videoControls.language' => 'Sprog',
|
||||
'videoControls.noSubtitlesFound' => 'Ingen undertekster fundet',
|
||||
'videoControls.subtitleDownloaded' => 'Undertekst downloadet',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Undertekst downloadet',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Underteksten blev downloadet, men kunne ikke vælges',
|
||||
'videoControls.subtitleDownloadFailed' => 'Kunne ikke downloade undertekst',
|
||||
'videoControls.searchLanguages' => 'Søg sprog...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsDa {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sessionen er udløbet for ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sessionerne er udløbet for ${count} servere',
|
||||
'connections.signInAgain' => 'Log ind igen',
|
||||
'connections.editJellyfinTitle' => 'Rediger Jellyfin-forbindelse',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Tilføj eller fjern URL\'er for ${serverName}. Plezy bruger den tilgængelige URL med lavest latenstid.',
|
||||
'discover.title' => 'Opdag',
|
||||
'discover.noContentAvailable' => 'Intet indhold tilgængeligt',
|
||||
'discover.addMediaToLibraries' => 'Tilføj medier til dine biblioteker',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsDa {
|
||||
'watchTogether.failedToCreate' => 'Kunne ikke oprette session',
|
||||
'watchTogether.failedToJoin' => 'Kunne ikke deltage i session',
|
||||
'watchTogether.sessionCodeCopied' => 'Sessionskode kopieret til udklipsholder',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Relayserveren kan ikke nås. Blokering hos internetudbyderen kan forhindre Se sammen.',
|
||||
'watchTogether.reconnectingToHost' => 'Genopretter forbindelse til vært...',
|
||||
'watchTogether.currentPlayback' => 'Nuværende afspilning',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Deltag i nuværende afspilning',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Hop tilbage til det værten ser nu',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Kunne ikke åbne nuværende afspilning',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsDa {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Synkroniser kun de biblioteker, du markerer nedenfor.',
|
||||
'services.libraryFilter.libraries' => 'Biblioteker',
|
||||
'services.libraryFilter.noLibraries' => 'Ingen biblioteker tilgængelige',
|
||||
'addServer.addJellyfinTitle' => 'Tilføj Jellyfin-server',
|
||||
'addServer.serverUrls' => 'Server-URL\'er',
|
||||
'addServer.serverUrlsHelper' => 'Du kan angive flere URL\'er adskilt med komma.',
|
||||
'addServer.findServer' => 'Find server',
|
||||
'addServer.searchingLocalServers' => 'Søger efter lokale Jellyfin-servere...',
|
||||
'addServer.localServers' => 'Lokale Jellyfin-servere',
|
||||
'addServer.username' => 'Brugernavn',
|
||||
'addServer.password' => 'Adgangskode',
|
||||
'addServer.signIn' => 'Log ind',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsDa {
|
||||
'addServer.addPlexTitle' => 'Log ind med Plex',
|
||||
'addServer.pinExpired' => 'PIN-koden udløb før login. Prøv igen.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Kunne ikke registrere kontoen: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Angiv URL\'en til din Jellyfin-server',
|
||||
'addServer.addConnectionTitle' => 'Tilføj forbindelse',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Tilføj til ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Log ind med Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Godkend denne enhed. Delte servere tilføjes.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Godkend en Plex-konto. Plex Home-brugere bliver til profiler.',
|
||||
'addServer.connectToJellyfinCard' => 'Forbind til Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Indtast din server-URL, dit brugernavn og din adgangskode.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Log ind på en Jellyfin-server. Serveren knyttes til ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Lån fra en anden profil',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Genbrug en anden profils forbindelse. PIN-beskyttede profiler kræver en PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$de extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Warte auf die Authentifizierung …\nMelde dich über deinen Browser an.';
|
||||
@override String get useBrowser => 'Browser verwenden';
|
||||
@override String get or => 'oder';
|
||||
@override String get connectToJellyfin => 'Mit Jellyfin verbinden';
|
||||
@override String get useQuickConnect => 'Quick Connect verwenden';
|
||||
@override String get quickConnectInstructions => 'Öffne Quick Connect in Jellyfin und gib diesen Code ein.';
|
||||
@override String get quickConnectWaiting => 'Warte auf Bestätigung…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$de extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sitzung für ${name} abgelaufen';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sitzungen für ${count} Server abgelaufen';
|
||||
@override String get signInAgain => 'Erneut anmelden';
|
||||
@override String get editJellyfinTitle => 'Jellyfin-Verbindung bearbeiten';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Füge URLs für ${serverName} hinzu oder entferne sie. Plezy verwendet die erreichbare URL mit der geringsten Latenz.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$de extends Translations$addServer$en {
|
||||
final TranslationsDe _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Jellyfin-Server hinzufügen';
|
||||
@override String get serverUrls => 'Server-URLs';
|
||||
@override String get serverUrlsHelper => 'Mehrere URLs möglich, durch Kommas getrennt.';
|
||||
@override String get findServer => 'Server finden';
|
||||
@override String get searchingLocalServers => 'Suche nach lokalen Jellyfin-Servern …';
|
||||
@override String get localServers => 'Lokale Jellyfin-Server';
|
||||
@override String get username => 'Benutzername';
|
||||
@override String get password => 'Passwort';
|
||||
@override String get signIn => 'Anmelden';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$de extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Mit Plex anmelden';
|
||||
@override String get pinExpired => 'PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Konto konnte nicht registriert werden: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Gib die URL deines Jellyfin-Servers ein';
|
||||
@override String get addConnectionTitle => 'Verbindung hinzufügen';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Zu ${name} hinzufügen';
|
||||
@override String get signInWithPlexCard => 'Mit Plex anmelden';
|
||||
@override String get signInWithPlexCardSubtitle => 'Dieses Gerät autorisieren. Geteilte Server werden hinzugefügt.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Ein Plex-Konto autorisieren. Home-Benutzer werden zu Profilen.';
|
||||
@override String get connectToJellyfinCard => 'Mit Jellyfin verbinden';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Gib Server-URL, Benutzername und Passwort ein.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.';
|
||||
@override String get borrowFromAnotherProfile => 'Von einem anderen Profil ausleihen';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Verbindung eines anderen Profils wiederverwenden. PIN-geschützte Profile erfordern eine PIN.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsDe {
|
||||
'auth.waitingForAuth' => 'Warte auf die Authentifizierung …\nMelde dich über deinen Browser an.',
|
||||
'auth.useBrowser' => 'Browser verwenden',
|
||||
'auth.or' => 'oder',
|
||||
'auth.connectToJellyfin' => 'Mit Jellyfin verbinden',
|
||||
'auth.useQuickConnect' => 'Quick Connect verwenden',
|
||||
'auth.quickConnectInstructions' => 'Öffne Quick Connect in Jellyfin und gib diesen Code ein.',
|
||||
'auth.quickConnectWaiting' => 'Warte auf Bestätigung…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsDe {
|
||||
'videoControls.searchSubtitles' => 'Untertitel suchen',
|
||||
'videoControls.language' => 'Sprache',
|
||||
'videoControls.noSubtitlesFound' => 'Keine Untertitel gefunden',
|
||||
'videoControls.subtitleDownloaded' => 'Untertitel heruntergeladen',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Untertitel heruntergeladen',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Der Untertitel wurde heruntergeladen, konnte aber nicht ausgewählt werden',
|
||||
'videoControls.subtitleDownloadFailed' => 'Untertitel konnte nicht heruntergeladen werden',
|
||||
'videoControls.searchLanguages' => 'Sprachen suchen...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsDe {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sitzung für ${name} abgelaufen',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sitzungen für ${count} Server abgelaufen',
|
||||
'connections.signInAgain' => 'Erneut anmelden',
|
||||
'connections.editJellyfinTitle' => 'Jellyfin-Verbindung bearbeiten',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Füge URLs für ${serverName} hinzu oder entferne sie. Plezy verwendet die erreichbare URL mit der geringsten Latenz.',
|
||||
'discover.title' => 'Entdecken',
|
||||
'discover.noContentAvailable' => 'Kein Inhalt verfügbar',
|
||||
'discover.addMediaToLibraries' => 'Medien zur Mediathek hinzufügen',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsDe {
|
||||
'watchTogether.failedToCreate' => 'Sitzung konnte nicht erstellt werden',
|
||||
'watchTogether.failedToJoin' => 'Beitritt zur Sitzung fehlgeschlagen',
|
||||
'watchTogether.sessionCodeCopied' => 'Sitzungscode in Zwischenablage kopiert',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Relay-Server nicht erreichbar. Eine Sperre durch den Internetanbieter kann gemeinsames Schauen verhindern.',
|
||||
'watchTogether.reconnectingToHost' => 'Verbindung zum Host wird wiederhergestellt …',
|
||||
'watchTogether.currentPlayback' => 'Aktuelle Wiedergabe',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Aktueller Wiedergabe beitreten',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Zu dem Inhalt wechseln, den der Host gerade ansieht',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Aktuelle Wiedergabe konnte nicht geöffnet werden',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsDe {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Nur die unten markierten Mediatheken synchronisieren.',
|
||||
'services.libraryFilter.libraries' => 'Mediatheken',
|
||||
'services.libraryFilter.noLibraries' => 'Keine Mediatheken verfügbar',
|
||||
'addServer.addJellyfinTitle' => 'Jellyfin-Server hinzufügen',
|
||||
'addServer.serverUrls' => 'Server-URLs',
|
||||
'addServer.serverUrlsHelper' => 'Mehrere URLs möglich, durch Kommas getrennt.',
|
||||
'addServer.findServer' => 'Server finden',
|
||||
'addServer.searchingLocalServers' => 'Suche nach lokalen Jellyfin-Servern …',
|
||||
'addServer.localServers' => 'Lokale Jellyfin-Server',
|
||||
'addServer.username' => 'Benutzername',
|
||||
'addServer.password' => 'Passwort',
|
||||
'addServer.signIn' => 'Anmelden',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsDe {
|
||||
'addServer.addPlexTitle' => 'Mit Plex anmelden',
|
||||
'addServer.pinExpired' => 'PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Konto konnte nicht registriert werden: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Gib die URL deines Jellyfin-Servers ein',
|
||||
'addServer.addConnectionTitle' => 'Verbindung hinzufügen',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Zu ${name} hinzufügen',
|
||||
'addServer.signInWithPlexCard' => 'Mit Plex anmelden',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Dieses Gerät autorisieren. Geteilte Server werden hinzugefügt.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Ein Plex-Konto autorisieren. Home-Benutzer werden zu Profilen.',
|
||||
'addServer.connectToJellyfinCard' => 'Mit Jellyfin verbinden',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Gib Server-URL, Benutzername und Passwort ein.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Von einem anderen Profil ausleihen',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Verbindung eines anderen Profils wiederverwenden. PIN-geschützte Profile erfordern eine PIN.',
|
||||
_ => null,
|
||||
|
||||
+40
-40
@@ -136,8 +136,8 @@ class Translations$auth$en {
|
||||
/// en: 'or'
|
||||
String get or => 'or';
|
||||
|
||||
/// en: 'Connect to Jellyfin'
|
||||
String get connectToJellyfin => 'Connect to Jellyfin';
|
||||
/// en: 'Connect to ${product}'
|
||||
String connectToMediaBrowser({required Object product}) => 'Connect to ${product}';
|
||||
|
||||
/// en: 'Use Quick Connect'
|
||||
String get useQuickConnect => 'Use Quick Connect';
|
||||
@@ -2413,8 +2413,8 @@ class Translations$profiles$en {
|
||||
/// en: 'Nothing to borrow yet.'
|
||||
String get borrowEmpty => 'Nothing to borrow yet.';
|
||||
|
||||
/// en: 'Connect Plex or Jellyfin to another profile first.'
|
||||
String get borrowEmptySubtitle => 'Connect Plex or Jellyfin to another profile first.';
|
||||
/// en: 'Connect Plex, Jellyfin, or Emby to another profile first.'
|
||||
String get borrowEmptySubtitle => 'Connect Plex, Jellyfin, or Emby to another profile first.';
|
||||
|
||||
/// en: 'Available connections could not be loaded. Try again.'
|
||||
String get borrowLoadFailed => 'Available connections could not be loaded. Try again.';
|
||||
@@ -2476,11 +2476,11 @@ class Translations$connections$en {
|
||||
/// en: 'Add connection'
|
||||
String get addConnection => 'Add connection';
|
||||
|
||||
/// en: 'Sign in with Plex or connect a Jellyfin server'
|
||||
String get addConnectionSubtitleNoProfile => 'Sign in with Plex or connect a Jellyfin server';
|
||||
/// en: 'Sign in with Plex or connect a Jellyfin or Emby server'
|
||||
String get addConnectionSubtitleNoProfile => 'Sign in with Plex or connect a Jellyfin or Emby server';
|
||||
|
||||
/// en: 'Add to ${displayName}: Plex, Jellyfin, or another profile connection'
|
||||
String addConnectionSubtitleScoped({required Object displayName}) => 'Add to ${displayName}: Plex, Jellyfin, or another profile connection';
|
||||
/// en: 'Add to ${displayName}: Plex, Jellyfin, Emby, or another profile connection'
|
||||
String addConnectionSubtitleScoped({required Object displayName}) => 'Add to ${displayName}: Plex, Jellyfin, Emby, or another profile connection';
|
||||
|
||||
/// en: 'Session expired for ${name}'
|
||||
String sessionExpiredOne({required Object name}) => 'Session expired for ${name}';
|
||||
@@ -2491,11 +2491,11 @@ class Translations$connections$en {
|
||||
/// en: 'Sign in again'
|
||||
String get signInAgain => 'Sign in again';
|
||||
|
||||
/// en: 'Edit Jellyfin connection'
|
||||
String get editJellyfinTitle => 'Edit Jellyfin connection';
|
||||
/// en: 'Edit ${product} connection'
|
||||
String editMediaBrowserTitle({required Object product}) => 'Edit ${product} connection';
|
||||
|
||||
/// en: 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.'
|
||||
String editJellyfinIntro({required Object serverName}) => 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.';
|
||||
String editMediaBrowserIntro({required Object serverName}) => 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -2778,8 +2778,8 @@ class Translations$about$en {
|
||||
/// en: 'Version ${version}'
|
||||
String versionLabel({required Object version}) => 'Version ${version}';
|
||||
|
||||
/// en: 'A beautiful Plex and Jellyfin client for Flutter'
|
||||
String get appDescription => 'A beautiful Plex and Jellyfin client for Flutter';
|
||||
/// en: 'A beautiful Plex, Jellyfin, and Emby client for Flutter'
|
||||
String get appDescription => 'A beautiful Plex, Jellyfin, and Emby client for Flutter';
|
||||
|
||||
/// en: 'View licenses of third-party libraries'
|
||||
String get viewLicensesDescription => 'View licenses of third-party libraries';
|
||||
@@ -4747,8 +4747,8 @@ class Translations$addServer$en {
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Add Jellyfin server'
|
||||
String get addJellyfinTitle => 'Add Jellyfin server';
|
||||
/// en: 'Add ${product} server'
|
||||
String addMediaBrowserTitle({required Object product}) => 'Add ${product} server';
|
||||
|
||||
/// en: 'Server URLs'
|
||||
String get serverUrls => 'Server URLs';
|
||||
@@ -4759,11 +4759,11 @@ class Translations$addServer$en {
|
||||
/// en: 'Find server'
|
||||
String get findServer => 'Find server';
|
||||
|
||||
/// en: 'Looking for local Jellyfin servers...'
|
||||
String get searchingLocalServers => 'Looking for local Jellyfin servers...';
|
||||
/// en: 'Looking for local ${product} servers...'
|
||||
String searchingLocalMediaBrowserServers({required Object product}) => 'Looking for local ${product} servers...';
|
||||
|
||||
/// en: 'Local Jellyfin servers'
|
||||
String get localServers => 'Local Jellyfin servers';
|
||||
/// en: 'Local ${product} servers'
|
||||
String localMediaBrowserServers({required Object product}) => 'Local ${product} servers';
|
||||
|
||||
/// en: 'Username'
|
||||
String get username => 'Username';
|
||||
@@ -4798,8 +4798,8 @@ class Translations$addServer$en {
|
||||
/// en: 'Failed to register account: ${error}'
|
||||
String failedToRegisterAccount({required Object error}) => 'Failed to register account: ${error}';
|
||||
|
||||
/// en: 'Enter your Jellyfin server URL'
|
||||
String get enterJellyfinUrlError => 'Enter your Jellyfin server URL';
|
||||
/// en: 'Enter your ${product} server URL'
|
||||
String enterMediaBrowserUrlError({required Object product}) => 'Enter your ${product} server URL';
|
||||
|
||||
/// en: 'Add connection'
|
||||
String get addConnectionTitle => 'Add connection';
|
||||
@@ -4816,14 +4816,14 @@ class Translations$addServer$en {
|
||||
/// en: 'Authorize a Plex account. Home users become profiles.'
|
||||
String get signInWithPlexCardSubtitleScoped => 'Authorize a Plex account. Home users become profiles.';
|
||||
|
||||
/// en: 'Connect to Jellyfin'
|
||||
String get connectToJellyfinCard => 'Connect to Jellyfin';
|
||||
/// en: 'Connect to ${product}'
|
||||
String connectToMediaBrowserCard({required Object product}) => 'Connect to ${product}';
|
||||
|
||||
/// en: 'Enter your server URL, username, and password.'
|
||||
String get connectToJellyfinCardSubtitle => 'Enter your server URL, username, and password.';
|
||||
String get connectToMediaBrowserCardSubtitle => 'Enter your server URL, username, and password.';
|
||||
|
||||
/// en: 'Sign in to a Jellyfin server. Binds to ${name}.'
|
||||
String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Sign in to a Jellyfin server. Binds to ${name}.';
|
||||
/// en: 'Sign in to your ${product} server. Binds to ${name}.'
|
||||
String connectToMediaBrowserCardSubtitleScoped({required Object product, required Object name}) => 'Sign in to your ${product} server. Binds to ${name}.';
|
||||
|
||||
/// en: 'Borrow from another profile'
|
||||
String get borrowFromAnotherProfile => 'Borrow from another profile';
|
||||
@@ -6043,7 +6043,7 @@ extension on Translations {
|
||||
'auth.waitingForAuth' => 'Waiting for authentication...\nSign in from your browser.',
|
||||
'auth.useBrowser' => 'Use browser',
|
||||
'auth.or' => 'or',
|
||||
'auth.connectToJellyfin' => 'Connect to Jellyfin',
|
||||
'auth.connectToMediaBrowser' => ({required Object product}) => 'Connect to ${product}',
|
||||
'auth.useQuickConnect' => 'Use Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Open Quick Connect in Jellyfin and enter this code.',
|
||||
'auth.quickConnectWaiting' => 'Waiting for approval…',
|
||||
@@ -6790,7 +6790,7 @@ extension on Translations {
|
||||
'profiles.borrowAddTo' => ({required Object displayName}) => 'Add to ${displayName}',
|
||||
'profiles.borrowExplain' => 'Borrow another profile\'s connection. PIN-protected profiles require a PIN.',
|
||||
'profiles.borrowEmpty' => 'Nothing to borrow yet.',
|
||||
'profiles.borrowEmptySubtitle' => 'Connect Plex or Jellyfin to another profile first.',
|
||||
'profiles.borrowEmptySubtitle' => 'Connect Plex, Jellyfin, or Emby to another profile first.',
|
||||
'profiles.borrowLoadFailed' => 'Available connections could not be loaded. Try again.',
|
||||
'profiles.borrowFromProfile' => ({required Object displayName}) => 'From ${displayName}',
|
||||
'profiles.borrowConnectionBorrowed' => 'Connection borrowed.',
|
||||
@@ -6808,13 +6808,13 @@ extension on Translations {
|
||||
'profiles.pinsDontMatch' => 'PINs don\'t match',
|
||||
'connections.sectionTitle' => 'Connections',
|
||||
'connections.addConnection' => 'Add connection',
|
||||
'connections.addConnectionSubtitleNoProfile' => 'Sign in with Plex or connect a Jellyfin server',
|
||||
'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Add to ${displayName}: Plex, Jellyfin, or another profile connection',
|
||||
'connections.addConnectionSubtitleNoProfile' => 'Sign in with Plex or connect a Jellyfin or Emby server',
|
||||
'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Add to ${displayName}: Plex, Jellyfin, Emby, or another profile connection',
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Session expired for ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Session expired for ${count} servers',
|
||||
'connections.signInAgain' => 'Sign in again',
|
||||
'connections.editJellyfinTitle' => 'Edit Jellyfin connection',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.',
|
||||
'connections.editMediaBrowserTitle' => ({required Object product}) => 'Edit ${product} connection',
|
||||
'connections.editMediaBrowserIntro' => ({required Object serverName}) => 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.',
|
||||
'discover.title' => 'Discover',
|
||||
'discover.noContentAvailable' => 'No content available',
|
||||
'discover.addMediaToLibraries' => 'Add some media to your libraries',
|
||||
@@ -6936,7 +6936,7 @@ extension on Translations {
|
||||
'about.title' => 'About',
|
||||
'about.openSourceLicenses' => 'Open Source Licenses',
|
||||
'about.versionLabel' => ({required Object version}) => 'Version ${version}',
|
||||
'about.appDescription' => 'A beautiful Plex and Jellyfin client for Flutter',
|
||||
'about.appDescription' => 'A beautiful Plex, Jellyfin, and Emby client for Flutter',
|
||||
'about.viewLicensesDescription' => 'View licenses of third-party libraries',
|
||||
'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'No servers found for ${username} (${email})',
|
||||
'serverSelection.failedToLoadServers' => ({required Object error}) => 'Failed to load servers: ${error}',
|
||||
@@ -7744,12 +7744,12 @@ extension on Translations {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Sync only the libraries checked below.',
|
||||
'services.libraryFilter.libraries' => 'Libraries',
|
||||
'services.libraryFilter.noLibraries' => 'No libraries available',
|
||||
'addServer.addJellyfinTitle' => 'Add Jellyfin server',
|
||||
'addServer.addMediaBrowserTitle' => ({required Object product}) => 'Add ${product} server',
|
||||
'addServer.serverUrls' => 'Server URLs',
|
||||
'addServer.serverUrlsHelper' => 'Multiple URLs allowed, separated by commas.',
|
||||
'addServer.findServer' => 'Find server',
|
||||
'addServer.searchingLocalServers' => 'Looking for local Jellyfin servers...',
|
||||
'addServer.localServers' => 'Local Jellyfin servers',
|
||||
'addServer.searchingLocalMediaBrowserServers' => ({required Object product}) => 'Looking for local ${product} servers...',
|
||||
'addServer.localMediaBrowserServers' => ({required Object product}) => 'Local ${product} servers',
|
||||
'addServer.username' => 'Username',
|
||||
'addServer.password' => 'Password',
|
||||
'addServer.signIn' => 'Sign in',
|
||||
@@ -7761,15 +7761,15 @@ extension on Translations {
|
||||
'addServer.addPlexTitle' => 'Sign in with Plex',
|
||||
'addServer.pinExpired' => 'PIN expired before sign-in. Please try again.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Failed to register account: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Enter your Jellyfin server URL',
|
||||
'addServer.enterMediaBrowserUrlError' => ({required Object product}) => 'Enter your ${product} server URL',
|
||||
'addServer.addConnectionTitle' => 'Add connection',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Add to ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Sign in with Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Authorize this device. Shared servers are added.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Authorize a Plex account. Home users become profiles.',
|
||||
'addServer.connectToJellyfinCard' => 'Connect to Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Enter your server URL, username, and password.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Sign in to a Jellyfin server. Binds to ${name}.',
|
||||
'addServer.connectToMediaBrowserCard' => ({required Object product}) => 'Connect to ${product}',
|
||||
'addServer.connectToMediaBrowserCardSubtitle' => 'Enter your server URL, username, and password.',
|
||||
'addServer.connectToMediaBrowserCardSubtitleScoped' => ({required Object product, required Object name}) => 'Sign in to your ${product} server. Binds to ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Borrow from another profile',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Reuse another profile\'s connection. PIN-protected profiles require a PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$es extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Esperando autenticación...\nInicia sesión desde tu navegador.';
|
||||
@override String get useBrowser => 'Usar navegador';
|
||||
@override String get or => 'o';
|
||||
@override String get connectToJellyfin => 'Conectar a Jellyfin';
|
||||
@override String get useQuickConnect => 'Usar Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Abre Quick Connect en Jellyfin e introduce este código.';
|
||||
@override String get quickConnectWaiting => 'Esperando aprobación…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$es extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sesión caducada para ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sesión caducada para ${count} servidores';
|
||||
@override String get signInAgain => 'Iniciar sesión de nuevo';
|
||||
@override String get editJellyfinTitle => 'Editar conexión de Jellyfin';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Añade o elimina direcciones URL para ${serverName}. Plezy usará la dirección accesible con menor latencia.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$es extends Translations$addServer$en {
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Añadir servidor Jellyfin';
|
||||
@override String get serverUrls => 'Direcciones URL del servidor';
|
||||
@override String get serverUrlsHelper => 'Se permiten varias URL, separadas por comas.';
|
||||
@override String get findServer => 'Buscar servidor';
|
||||
@override String get searchingLocalServers => 'Buscando servidores Jellyfin locales...';
|
||||
@override String get localServers => 'Servidores Jellyfin locales';
|
||||
@override String get username => 'Usuario';
|
||||
@override String get password => 'Contraseña';
|
||||
@override String get signIn => 'Iniciar sesión';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$es extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Iniciar sesión con Plex';
|
||||
@override String get pinExpired => 'El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'No se pudo registrar la cuenta: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Introduce la URL de tu servidor Jellyfin';
|
||||
@override String get addConnectionTitle => 'Añadir conexión';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Añadir a ${name}';
|
||||
@override String get signInWithPlexCard => 'Iniciar sesión con Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Autoriza este dispositivo. Se añaden servidores compartidos.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Autoriza una cuenta Plex. Los usuarios de Home se convierten en perfiles.';
|
||||
@override String get connectToJellyfinCard => 'Conectar a Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Introduce la URL del servidor, usuario y contraseña.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Inicia sesión en un servidor Jellyfin. Se vincula a ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Tomar prestado de otro perfil';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Reutiliza la conexión de otro perfil. Los perfiles protegidos con PIN requieren un PIN.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsEs {
|
||||
'auth.waitingForAuth' => 'Esperando autenticación...\nInicia sesión desde tu navegador.',
|
||||
'auth.useBrowser' => 'Usar navegador',
|
||||
'auth.or' => 'o',
|
||||
'auth.connectToJellyfin' => 'Conectar a Jellyfin',
|
||||
'auth.useQuickConnect' => 'Usar Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Abre Quick Connect en Jellyfin e introduce este código.',
|
||||
'auth.quickConnectWaiting' => 'Esperando aprobación…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsEs {
|
||||
'videoControls.searchSubtitles' => 'Buscar subtítulos',
|
||||
'videoControls.language' => 'Idioma',
|
||||
'videoControls.noSubtitlesFound' => 'No se encontraron subtítulos',
|
||||
'videoControls.subtitleDownloaded' => 'Subtítulo descargado',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Subtítulo descargado',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'El subtítulo se descargó, pero no se pudo seleccionar',
|
||||
'videoControls.subtitleDownloadFailed' => 'Error al descargar subtítulo',
|
||||
'videoControls.searchLanguages' => 'Buscar idiomas...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsEs {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sesión caducada para ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sesión caducada para ${count} servidores',
|
||||
'connections.signInAgain' => 'Iniciar sesión de nuevo',
|
||||
'connections.editJellyfinTitle' => 'Editar conexión de Jellyfin',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Añade o elimina direcciones URL para ${serverName}. Plezy usará la dirección accesible con menor latencia.',
|
||||
'discover.title' => 'Descubrir',
|
||||
'discover.noContentAvailable' => 'No hay contenido disponible',
|
||||
'discover.addMediaToLibraries' => 'Añade contenido a tus bibliotecas',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsEs {
|
||||
'watchTogether.failedToCreate' => 'Error al crear la sesión',
|
||||
'watchTogether.failedToJoin' => 'Error al unirse a la sesión',
|
||||
'watchTogether.sessionCodeCopied' => 'Código de sesión copiado al portapapeles',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'No se puede acceder al servidor de retransmisión. Es posible que tu proveedor de internet esté bloqueando Ver juntos.',
|
||||
'watchTogether.reconnectingToHost' => 'Reconectando con el anfitrión...',
|
||||
'watchTogether.currentPlayback' => 'Reproducción actual',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Unirse a la reproducción actual',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Vuelve a lo que el anfitrión está viendo ahora mismo',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'No se pudo abrir la reproducción actual',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsEs {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Sincronizar solo las bibliotecas seleccionadas abajo.',
|
||||
'services.libraryFilter.libraries' => 'Bibliotecas',
|
||||
'services.libraryFilter.noLibraries' => 'No hay bibliotecas disponibles',
|
||||
'addServer.addJellyfinTitle' => 'Añadir servidor Jellyfin',
|
||||
'addServer.serverUrls' => 'Direcciones URL del servidor',
|
||||
'addServer.serverUrlsHelper' => 'Se permiten varias URL, separadas por comas.',
|
||||
'addServer.findServer' => 'Buscar servidor',
|
||||
'addServer.searchingLocalServers' => 'Buscando servidores Jellyfin locales...',
|
||||
'addServer.localServers' => 'Servidores Jellyfin locales',
|
||||
'addServer.username' => 'Usuario',
|
||||
'addServer.password' => 'Contraseña',
|
||||
'addServer.signIn' => 'Iniciar sesión',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsEs {
|
||||
'addServer.addPlexTitle' => 'Iniciar sesión con Plex',
|
||||
'addServer.pinExpired' => 'El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'No se pudo registrar la cuenta: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Introduce la URL de tu servidor Jellyfin',
|
||||
'addServer.addConnectionTitle' => 'Añadir conexión',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Añadir a ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Iniciar sesión con Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Autoriza este dispositivo. Se añaden servidores compartidos.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Autoriza una cuenta Plex. Los usuarios de Home se convierten en perfiles.',
|
||||
'addServer.connectToJellyfinCard' => 'Conectar a Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Introduce la URL del servidor, usuario y contraseña.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Inicia sesión en un servidor Jellyfin. Se vincula a ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Tomar prestado de otro perfil',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Reutiliza la conexión de otro perfil. Los perfiles protegidos con PIN requieren un PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$fr extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'En attente d\'authentification...\nConnectez-vous depuis votre navigateur.';
|
||||
@override String get useBrowser => 'Utiliser le navigateur';
|
||||
@override String get or => 'ou';
|
||||
@override String get connectToJellyfin => 'Se connecter à Jellyfin';
|
||||
@override String get useQuickConnect => 'Utiliser Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Ouvrez Quick Connect dans Jellyfin et saisissez ce code.';
|
||||
@override String get quickConnectWaiting => 'En attente d\'approbation…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$fr extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Session expirée pour ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Session expirée pour ${count} serveurs';
|
||||
@override String get signInAgain => 'Se reconnecter';
|
||||
@override String get editJellyfinTitle => 'Modifier la connexion Jellyfin';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Ajoutez ou supprimez des URL pour ${serverName}. Plezy utilisera l\'URL joignable avec la latence la plus faible.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$fr extends Translations$addServer$en {
|
||||
final TranslationsFr _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Ajouter un serveur Jellyfin';
|
||||
@override String get serverUrls => 'URL du serveur';
|
||||
@override String get serverUrlsHelper => 'Plusieurs URL possibles, séparées par des virgules.';
|
||||
@override String get findServer => 'Rechercher un serveur';
|
||||
@override String get searchingLocalServers => 'Recherche de serveurs Jellyfin locaux...';
|
||||
@override String get localServers => 'Serveurs Jellyfin locaux';
|
||||
@override String get username => 'Nom d\'utilisateur';
|
||||
@override String get password => 'Mot de passe';
|
||||
@override String get signIn => 'Se connecter';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$fr extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Se connecter avec Plex';
|
||||
@override String get pinExpired => 'Le PIN a expiré avant la connexion. Veuillez réessayer.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Échec de l\'enregistrement du compte : ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Saisissez l\'URL de votre serveur Jellyfin';
|
||||
@override String get addConnectionTitle => 'Ajouter une connexion';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Ajouter à ${name}';
|
||||
@override String get signInWithPlexCard => 'Se connecter avec Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Autorisez cet appareil. Les serveurs partagés sont ajoutés.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Autorisez un compte Plex. Les utilisateurs Home deviennent des profils.';
|
||||
@override String get connectToJellyfinCard => 'Se connecter à Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Saisissez l\'URL du serveur, le nom d\'utilisateur et le mot de passe.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Connectez-vous à un serveur Jellyfin. Cette connexion sera liée à ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Emprunter à un autre profil';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Réutiliser la connexion d\'un autre profil. Les profils protégés par PIN exigent un PIN.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsFr {
|
||||
'auth.waitingForAuth' => 'En attente d\'authentification...\nConnectez-vous depuis votre navigateur.',
|
||||
'auth.useBrowser' => 'Utiliser le navigateur',
|
||||
'auth.or' => 'ou',
|
||||
'auth.connectToJellyfin' => 'Se connecter à Jellyfin',
|
||||
'auth.useQuickConnect' => 'Utiliser Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Ouvrez Quick Connect dans Jellyfin et saisissez ce code.',
|
||||
'auth.quickConnectWaiting' => 'En attente d\'approbation…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsFr {
|
||||
'videoControls.searchSubtitles' => 'Rechercher des sous-titres',
|
||||
'videoControls.language' => 'Langue',
|
||||
'videoControls.noSubtitlesFound' => 'Aucun sous-titre trouvé',
|
||||
'videoControls.subtitleDownloaded' => 'Sous-titre téléchargé',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Sous-titre téléchargé',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Le sous-titre a été téléchargé, mais n’a pas pu être sélectionné',
|
||||
'videoControls.subtitleDownloadFailed' => 'Échec du téléchargement du sous-titre',
|
||||
'videoControls.searchLanguages' => 'Rechercher des langues...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsFr {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Session expirée pour ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Session expirée pour ${count} serveurs',
|
||||
'connections.signInAgain' => 'Se reconnecter',
|
||||
'connections.editJellyfinTitle' => 'Modifier la connexion Jellyfin',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Ajoutez ou supprimez des URL pour ${serverName}. Plezy utilisera l\'URL joignable avec la latence la plus faible.',
|
||||
'discover.title' => 'Découvrir',
|
||||
'discover.noContentAvailable' => 'Aucun contenu disponible',
|
||||
'discover.addMediaToLibraries' => 'Ajoutez des médias à vos bibliothèques',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsFr {
|
||||
'watchTogether.failedToCreate' => 'Échec de la création de la session',
|
||||
'watchTogether.failedToJoin' => 'Échec de la connexion à la session',
|
||||
'watchTogether.sessionCodeCopied' => 'Code de session copié dans le presse-papiers',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Serveur relais inaccessible. Un blocage par le fournisseur d’accès peut empêcher le fonctionnement de Regarder ensemble.',
|
||||
'watchTogether.reconnectingToHost' => 'Reconnexion à l\'hôte...',
|
||||
'watchTogether.currentPlayback' => 'Lecture en cours',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Rejoindre la lecture en cours',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Reprendre le contenu que l’hôte regarde actuellement',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Impossible d\'ouvrir la lecture en cours',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsFr {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Synchroniser uniquement les bibliothèques cochées ci-dessous.',
|
||||
'services.libraryFilter.libraries' => 'Bibliothèques',
|
||||
'services.libraryFilter.noLibraries' => 'Aucune bibliothèque disponible',
|
||||
'addServer.addJellyfinTitle' => 'Ajouter un serveur Jellyfin',
|
||||
'addServer.serverUrls' => 'URL du serveur',
|
||||
'addServer.serverUrlsHelper' => 'Plusieurs URL possibles, séparées par des virgules.',
|
||||
'addServer.findServer' => 'Rechercher un serveur',
|
||||
'addServer.searchingLocalServers' => 'Recherche de serveurs Jellyfin locaux...',
|
||||
'addServer.localServers' => 'Serveurs Jellyfin locaux',
|
||||
'addServer.username' => 'Nom d\'utilisateur',
|
||||
'addServer.password' => 'Mot de passe',
|
||||
'addServer.signIn' => 'Se connecter',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsFr {
|
||||
'addServer.addPlexTitle' => 'Se connecter avec Plex',
|
||||
'addServer.pinExpired' => 'Le PIN a expiré avant la connexion. Veuillez réessayer.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Échec de l\'enregistrement du compte : ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Saisissez l\'URL de votre serveur Jellyfin',
|
||||
'addServer.addConnectionTitle' => 'Ajouter une connexion',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Ajouter à ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Se connecter avec Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Autorisez cet appareil. Les serveurs partagés sont ajoutés.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Autorisez un compte Plex. Les utilisateurs Home deviennent des profils.',
|
||||
'addServer.connectToJellyfinCard' => 'Se connecter à Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Saisissez l\'URL du serveur, le nom d\'utilisateur et le mot de passe.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Connectez-vous à un serveur Jellyfin. Cette connexion sera liée à ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Emprunter à un autre profil',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Réutiliser la connexion d\'un autre profil. Les profils protégés par PIN exigent un PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$hu extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Várakozás a hitelesítésre...\nJelentkezz be a böngésződben.';
|
||||
@override String get useBrowser => 'Böngésző használata';
|
||||
@override String get or => 'vagy';
|
||||
@override String get connectToJellyfin => 'Csatlakozás Jellyfinhez';
|
||||
@override String get useQuickConnect => 'Quick Connect használata';
|
||||
@override String get quickConnectInstructions => 'Nyisd meg a Quick Connect-et a Jellyfinben, és add meg ezt a kódot.';
|
||||
@override String get quickConnectWaiting => 'Várakozás a jóváhagyásra…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$hu extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'A(z) ${name} munkamenete lejárt';
|
||||
@override String sessionExpiredMany({required Object count}) => '${count} szerver munkamenete lejárt';
|
||||
@override String get signInAgain => 'Bejelentkezés újra';
|
||||
@override String get editJellyfinTitle => 'Jellyfin kapcsolat szerkesztése';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'URL-ek hozzáadása vagy eltávolítása ehhez: ${serverName}. A Plezy a legalacsonyabb késleltetésű, elérhető URL-t fogja használni.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$hu extends Translations$addServer$en {
|
||||
final TranslationsHu _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Jellyfin szerver hozzáadása';
|
||||
@override String get serverUrls => 'Szerver URL-címei';
|
||||
@override String get serverUrlsHelper => 'Több URL is megadható, vesszővel elválasztva.';
|
||||
@override String get findServer => 'Szerver keresése';
|
||||
@override String get searchingLocalServers => 'Helyi Jellyfin-szerverek keresése...';
|
||||
@override String get localServers => 'Helyi Jellyfin-szerverek';
|
||||
@override String get username => 'Felhasználónév';
|
||||
@override String get password => 'Jelszó';
|
||||
@override String get signIn => 'Bejelentkezés';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$hu extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Bejelentkezés Plexszel';
|
||||
@override String get pinExpired => 'A PIN-kód a bejelentkezés előtt lejárt. Próbáld újra.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Nem sikerült a fiók regisztrálása: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Add meg a Jellyfin szervered URL-jét';
|
||||
@override String get addConnectionTitle => 'Kapcsolat hozzáadása';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Hozzáadás a következőhöz: ${name}';
|
||||
@override String get signInWithPlexCard => 'Bejelentkezés Plexszel';
|
||||
@override String get signInWithPlexCardSubtitle => 'Eszköz engedélyezése. A megosztott szerverek hozzáadásra kerülnek.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Plex-fiók engedélyezése. A Plex Home-felhasználókból profilok lesznek.';
|
||||
@override String get connectToJellyfinCard => 'Csatlakozás Jellyfinhez';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Add meg a szerver URL-jét, felhasználónevedet és jelszavadat.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Bejelentkezés egy Jellyfin-szerverre. Hozzárendelés ehhez: ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Kapcsolat használata másik profilból';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Egy másik profil kapcsolatának használata. A PIN-kóddal védett profilokhoz PIN-kód szükséges.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsHu {
|
||||
'auth.waitingForAuth' => 'Várakozás a hitelesítésre...\nJelentkezz be a böngésződben.',
|
||||
'auth.useBrowser' => 'Böngésző használata',
|
||||
'auth.or' => 'vagy',
|
||||
'auth.connectToJellyfin' => 'Csatlakozás Jellyfinhez',
|
||||
'auth.useQuickConnect' => 'Quick Connect használata',
|
||||
'auth.quickConnectInstructions' => 'Nyisd meg a Quick Connect-et a Jellyfinben, és add meg ezt a kódot.',
|
||||
'auth.quickConnectWaiting' => 'Várakozás a jóváhagyásra…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsHu {
|
||||
'videoControls.searchSubtitles' => 'Feliratok keresése',
|
||||
'videoControls.language' => 'Nyelv',
|
||||
'videoControls.noSubtitlesFound' => 'Nem találhatók feliratok',
|
||||
'videoControls.subtitleDownloaded' => 'Felirat letöltve',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Felirat letöltve',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Felirat letöltve, de nem sikerült kiválasztani',
|
||||
'videoControls.subtitleDownloadFailed' => 'Nem sikerült a felirat letöltése',
|
||||
'videoControls.searchLanguages' => 'Nyelvek keresése...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsHu {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'A(z) ${name} munkamenete lejárt',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => '${count} szerver munkamenete lejárt',
|
||||
'connections.signInAgain' => 'Bejelentkezés újra',
|
||||
'connections.editJellyfinTitle' => 'Jellyfin kapcsolat szerkesztése',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'URL-ek hozzáadása vagy eltávolítása ehhez: ${serverName}. A Plezy a legalacsonyabb késleltetésű, elérhető URL-t fogja használni.',
|
||||
'discover.title' => 'Felfedezés',
|
||||
'discover.noContentAvailable' => 'Nincs elérhető tartalom',
|
||||
'discover.addMediaToLibraries' => 'Adj hozzá médiát a könyvtáraidhoz',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsHu {
|
||||
'watchTogether.failedToCreate' => 'Nem sikerült a munkamenet létrehozása',
|
||||
'watchTogether.failedToJoin' => 'Nem sikerült csatlakozni a munkamenethez',
|
||||
'watchTogether.sessionCodeCopied' => 'A munkamenetkód a vágólapra másolva',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'A relészerver nem érhető el. Az internetszolgáltató blokkolása megakadályozhatja a közös nézést.',
|
||||
'watchTogether.reconnectingToHost' => 'Újracsatlakozás a házigazdához...',
|
||||
'watchTogether.currentPlayback' => 'Jelenlegi lejátszás',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Csatlakozás a jelenlegi lejátszáshoz',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Visszatérés ahhoz, amit a házigazda éppen néz',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Nem sikerült megnyitni a jelenlegi lejátszást',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsHu {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Csak az alább bejelölt könyvtárak szinkronizálása.',
|
||||
'services.libraryFilter.libraries' => 'Könyvtárak',
|
||||
'services.libraryFilter.noLibraries' => 'Nincsenek elérhető könyvtárak',
|
||||
'addServer.addJellyfinTitle' => 'Jellyfin szerver hozzáadása',
|
||||
'addServer.serverUrls' => 'Szerver URL-címei',
|
||||
'addServer.serverUrlsHelper' => 'Több URL is megadható, vesszővel elválasztva.',
|
||||
'addServer.findServer' => 'Szerver keresése',
|
||||
'addServer.searchingLocalServers' => 'Helyi Jellyfin-szerverek keresése...',
|
||||
'addServer.localServers' => 'Helyi Jellyfin-szerverek',
|
||||
'addServer.username' => 'Felhasználónév',
|
||||
'addServer.password' => 'Jelszó',
|
||||
'addServer.signIn' => 'Bejelentkezés',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsHu {
|
||||
'addServer.addPlexTitle' => 'Bejelentkezés Plexszel',
|
||||
'addServer.pinExpired' => 'A PIN-kód a bejelentkezés előtt lejárt. Próbáld újra.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Nem sikerült a fiók regisztrálása: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Add meg a Jellyfin szervered URL-jét',
|
||||
'addServer.addConnectionTitle' => 'Kapcsolat hozzáadása',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Hozzáadás a következőhöz: ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Bejelentkezés Plexszel',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Eszköz engedélyezése. A megosztott szerverek hozzáadásra kerülnek.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Plex-fiók engedélyezése. A Plex Home-felhasználókból profilok lesznek.',
|
||||
'addServer.connectToJellyfinCard' => 'Csatlakozás Jellyfinhez',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Add meg a szerver URL-jét, felhasználónevedet és jelszavadat.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Bejelentkezés egy Jellyfin-szerverre. Hozzárendelés ehhez: ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Kapcsolat használata másik profilból',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Egy másik profil kapcsolatának használata. A PIN-kóddal védett profilokhoz PIN-kód szükséges.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$it extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'In attesa di autenticazione...\nAccedi dal browser.';
|
||||
@override String get useBrowser => 'Usa il browser';
|
||||
@override String get or => 'o';
|
||||
@override String get connectToJellyfin => 'Connettiti a Jellyfin';
|
||||
@override String get useQuickConnect => 'Usa Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Apri Quick Connect in Jellyfin e inserisci questo codice.';
|
||||
@override String get quickConnectWaiting => 'In attesa di approvazione…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$it extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sessione scaduta per ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sessione scaduta per ${count} server';
|
||||
@override String get signInAgain => 'Accedi di nuovo';
|
||||
@override String get editJellyfinTitle => 'Modifica connessione Jellyfin';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Aggiungi o rimuovi URL per ${serverName}. Plezy userà l\'URL raggiungibile con la latenza più bassa.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$it extends Translations$addServer$en {
|
||||
final TranslationsIt _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Aggiungi server Jellyfin';
|
||||
@override String get serverUrls => 'URL del server';
|
||||
@override String get serverUrlsHelper => 'Sono consentiti più URL, separati da virgole.';
|
||||
@override String get findServer => 'Trova il server';
|
||||
@override String get searchingLocalServers => 'Ricerca dei server Jellyfin locali...';
|
||||
@override String get localServers => 'Server Jellyfin locali';
|
||||
@override String get username => 'Nome utente';
|
||||
@override String get password => 'Password';
|
||||
@override String get signIn => 'Accedi';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$it extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Accedi con Plex';
|
||||
@override String get pinExpired => 'PIN scaduto prima dell\'accesso. Riprova.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Registrazione account non riuscita: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Inserisci l\'URL del tuo server Jellyfin';
|
||||
@override String get addConnectionTitle => 'Aggiungi connessione';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Aggiungi a ${name}';
|
||||
@override String get signInWithPlexCard => 'Accedi con Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Autorizza questo dispositivo. I server condivisi vengono aggiunti.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Autorizza un account Plex. Gli utenti Home diventano profili.';
|
||||
@override String get connectToJellyfinCard => 'Connettiti a Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Inserisci l\'URL del server, il nome utente e la password.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Accedi a un server Jellyfin. Verrà associato a ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Prendi in prestito da un altro profilo';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Riutilizza la connessione di un altro profilo. I profili protetti da PIN richiedono un PIN.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsIt {
|
||||
'auth.waitingForAuth' => 'In attesa di autenticazione...\nAccedi dal browser.',
|
||||
'auth.useBrowser' => 'Usa il browser',
|
||||
'auth.or' => 'o',
|
||||
'auth.connectToJellyfin' => 'Connettiti a Jellyfin',
|
||||
'auth.useQuickConnect' => 'Usa Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Apri Quick Connect in Jellyfin e inserisci questo codice.',
|
||||
'auth.quickConnectWaiting' => 'In attesa di approvazione…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsIt {
|
||||
'videoControls.searchSubtitles' => 'Cerca sottotitoli',
|
||||
'videoControls.language' => 'Lingua',
|
||||
'videoControls.noSubtitlesFound' => 'Nessun sottotitolo trovato',
|
||||
'videoControls.subtitleDownloaded' => 'Sottotitolo scaricato',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Sottotitolo scaricato',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Il sottotitolo è stato scaricato, ma non è stato possibile selezionarlo',
|
||||
'videoControls.subtitleDownloadFailed' => 'Impossibile scaricare il sottotitolo',
|
||||
'videoControls.searchLanguages' => 'Cerca lingue...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsIt {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sessione scaduta per ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sessione scaduta per ${count} server',
|
||||
'connections.signInAgain' => 'Accedi di nuovo',
|
||||
'connections.editJellyfinTitle' => 'Modifica connessione Jellyfin',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Aggiungi o rimuovi URL per ${serverName}. Plezy userà l\'URL raggiungibile con la latenza più bassa.',
|
||||
'discover.title' => 'Esplora',
|
||||
'discover.noContentAvailable' => 'Nessun contenuto disponibile',
|
||||
'discover.addMediaToLibraries' => 'Aggiungi contenuti multimediali alle tue librerie',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsIt {
|
||||
'watchTogether.failedToCreate' => 'Impossibile creare la sessione',
|
||||
'watchTogether.failedToJoin' => 'Impossibile unirsi alla sessione',
|
||||
'watchTogether.sessionCodeCopied' => 'Codice della sessione copiato negli appunti',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Il server relay non è raggiungibile. Eventuali blocchi dell\'ISP potrebbero impedire l\'uso di Guarda insieme.',
|
||||
'watchTogether.reconnectingToHost' => 'Riconnessione all\'host...',
|
||||
'watchTogether.currentPlayback' => 'Riproduzione corrente',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Unisciti alla riproduzione corrente',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Torna a ciò che l\'host sta guardando in questo momento',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Impossibile aprire la riproduzione corrente',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsIt {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Sincronizza solo le librerie selezionate qui sotto.',
|
||||
'services.libraryFilter.libraries' => 'Librerie',
|
||||
'services.libraryFilter.noLibraries' => 'Nessuna libreria disponibile',
|
||||
'addServer.addJellyfinTitle' => 'Aggiungi server Jellyfin',
|
||||
'addServer.serverUrls' => 'URL del server',
|
||||
'addServer.serverUrlsHelper' => 'Sono consentiti più URL, separati da virgole.',
|
||||
'addServer.findServer' => 'Trova il server',
|
||||
'addServer.searchingLocalServers' => 'Ricerca dei server Jellyfin locali...',
|
||||
'addServer.localServers' => 'Server Jellyfin locali',
|
||||
'addServer.username' => 'Nome utente',
|
||||
'addServer.password' => 'Password',
|
||||
'addServer.signIn' => 'Accedi',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsIt {
|
||||
'addServer.addPlexTitle' => 'Accedi con Plex',
|
||||
'addServer.pinExpired' => 'PIN scaduto prima dell\'accesso. Riprova.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Registrazione account non riuscita: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Inserisci l\'URL del tuo server Jellyfin',
|
||||
'addServer.addConnectionTitle' => 'Aggiungi connessione',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Aggiungi a ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Accedi con Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Autorizza questo dispositivo. I server condivisi vengono aggiunti.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Autorizza un account Plex. Gli utenti Home diventano profili.',
|
||||
'addServer.connectToJellyfinCard' => 'Connettiti a Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Inserisci l\'URL del server, il nome utente e la password.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Accedi a un server Jellyfin. Verrà associato a ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Prendi in prestito da un altro profilo',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Riutilizza la connessione di un altro profilo. I profili protetti da PIN richiedono un PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$ja extends Translations$auth$en {
|
||||
@override String get waitingForAuth => '認証を待っています…\nブラウザでサインインしてください。';
|
||||
@override String get useBrowser => 'ブラウザを使用';
|
||||
@override String get or => 'または';
|
||||
@override String get connectToJellyfin => 'Jellyfinに接続';
|
||||
@override String get useQuickConnect => 'Quick Connect を使う';
|
||||
@override String get quickConnectInstructions => 'JellyfinでQuick Connectを開き、このコードを入力してください。';
|
||||
@override String get quickConnectWaiting => '承認を待っています…';
|
||||
@@ -917,8 +916,6 @@ class _Translations$connections$ja extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => '${name} のセッションの有効期限が切れました';
|
||||
@override String sessionExpiredMany({required Object count}) => '${count} 台のサーバーのセッションの有効期限が切れました';
|
||||
@override String get signInAgain => '再度サインイン';
|
||||
@override String get editJellyfinTitle => 'Jellyfin接続を編集';
|
||||
@override String editJellyfinIntro({required Object serverName}) => '${serverName}のURLを追加または削除します。Plezyは接続可能なURLのうち遅延が最も少ないものを使用します。';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1800,12 +1797,9 @@ class _Translations$addServer$ja extends Translations$addServer$en {
|
||||
final TranslationsJa _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Jellyfinサーバーを追加';
|
||||
@override String get serverUrls => 'サーバーURL';
|
||||
@override String get serverUrlsHelper => '複数のURLをカンマ区切りで入力できます。';
|
||||
@override String get findServer => 'サーバーを検索';
|
||||
@override String get searchingLocalServers => 'ローカルのJellyfinサーバーを検索中…';
|
||||
@override String get localServers => 'ローカルのJellyfinサーバー';
|
||||
@override String get username => 'ユーザー名';
|
||||
@override String get password => 'パスワード';
|
||||
@override String get signIn => 'サインイン';
|
||||
@@ -1817,15 +1811,11 @@ class _Translations$addServer$ja extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Plexでサインイン';
|
||||
@override String get pinExpired => 'サインイン前にPINの有効期限が切れました。もう一度お試しください。';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'アカウントの登録に失敗しました: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'JellyfinサーバーのURLを入力してください';
|
||||
@override String get addConnectionTitle => '接続を追加';
|
||||
@override String addConnectionTitleScoped({required Object name}) => '${name}に追加';
|
||||
@override String get signInWithPlexCard => 'Plexでサインイン';
|
||||
@override String get signInWithPlexCardSubtitle => 'このデバイスを承認します。共有サーバーが追加されます。';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Plexアカウントを承認します。Homeユーザーはプロフィールになります。';
|
||||
@override String get connectToJellyfinCard => 'Jellyfinに接続';
|
||||
@override String get connectToJellyfinCardSubtitle => 'サーバーURL、ユーザー名、パスワードを入力してください。';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfinサーバーにサインインします。${name}にひも付けられます。';
|
||||
@override String get borrowFromAnotherProfile => '別のプロフィールの接続を利用';
|
||||
@override String get borrowFromAnotherProfileSubtitle => '別のプロフィールの接続を再利用します。PINで保護されたプロフィールにはPINが必要です。';
|
||||
}
|
||||
@@ -2205,7 +2195,6 @@ extension on TranslationsJa {
|
||||
'auth.waitingForAuth' => '認証を待っています…\nブラウザでサインインしてください。',
|
||||
'auth.useBrowser' => 'ブラウザを使用',
|
||||
'auth.or' => 'または',
|
||||
'auth.connectToJellyfin' => 'Jellyfinに接続',
|
||||
'auth.useQuickConnect' => 'Quick Connect を使う',
|
||||
'auth.quickConnectInstructions' => 'JellyfinでQuick Connectを開き、このコードを入力してください。',
|
||||
'auth.quickConnectWaiting' => '承認を待っています…',
|
||||
@@ -2708,9 +2697,9 @@ extension on TranslationsJa {
|
||||
'videoControls.searchSubtitles' => '字幕を検索',
|
||||
'videoControls.language' => '言語',
|
||||
'videoControls.noSubtitlesFound' => '字幕が見つかりません',
|
||||
'videoControls.subtitleDownloaded' => '字幕をダウンロードしました',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => '字幕をダウンロードしました',
|
||||
'videoControls.subtitleDownloadedNotApplied' => '字幕はダウンロードされましたが、選択できませんでした',
|
||||
'videoControls.subtitleDownloadFailed' => '字幕のダウンロードに失敗しました',
|
||||
'videoControls.searchLanguages' => '言語を検索…',
|
||||
@@ -2866,8 +2855,6 @@ extension on TranslationsJa {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => '${name} のセッションの有効期限が切れました',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => '${count} 台のサーバーのセッションの有効期限が切れました',
|
||||
'connections.signInAgain' => '再度サインイン',
|
||||
'connections.editJellyfinTitle' => 'Jellyfin接続を編集',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName}のURLを追加または削除します。Plezyは接続可能なURLのうち遅延が最も少ないものを使用します。',
|
||||
'discover.title' => '探す',
|
||||
'discover.noContentAvailable' => 'コンテンツがありません',
|
||||
'discover.addMediaToLibraries' => 'ライブラリにメディアを追加してください',
|
||||
@@ -3222,11 +3209,11 @@ extension on TranslationsJa {
|
||||
'watchTogether.failedToCreate' => 'セッションの作成に失敗しました',
|
||||
'watchTogether.failedToJoin' => 'セッションへの参加に失敗しました',
|
||||
'watchTogether.sessionCodeCopied' => 'セッションコードをクリップボードにコピーしました',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'リレーサーバーに接続できません。ISPによるブロックのため「一緒に見る」を利用できない可能性があります。',
|
||||
'watchTogether.reconnectingToHost' => 'ホストに再接続中…',
|
||||
'watchTogether.currentPlayback' => '現在の再生',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => '現在の再生に参加',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'ホストが現在視聴中のコンテンツに戻る',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => '現在の再生を開けませんでした',
|
||||
@@ -3653,12 +3640,9 @@ extension on TranslationsJa {
|
||||
'services.libraryFilter.modeHintWhitelist' => '下でチェックしたライブラリのみ同期します。',
|
||||
'services.libraryFilter.libraries' => 'ライブラリ',
|
||||
'services.libraryFilter.noLibraries' => '利用できるライブラリがありません',
|
||||
'addServer.addJellyfinTitle' => 'Jellyfinサーバーを追加',
|
||||
'addServer.serverUrls' => 'サーバーURL',
|
||||
'addServer.serverUrlsHelper' => '複数のURLをカンマ区切りで入力できます。',
|
||||
'addServer.findServer' => 'サーバーを検索',
|
||||
'addServer.searchingLocalServers' => 'ローカルのJellyfinサーバーを検索中…',
|
||||
'addServer.localServers' => 'ローカルのJellyfinサーバー',
|
||||
'addServer.username' => 'ユーザー名',
|
||||
'addServer.password' => 'パスワード',
|
||||
'addServer.signIn' => 'サインイン',
|
||||
@@ -3670,15 +3654,11 @@ extension on TranslationsJa {
|
||||
'addServer.addPlexTitle' => 'Plexでサインイン',
|
||||
'addServer.pinExpired' => 'サインイン前にPINの有効期限が切れました。もう一度お試しください。',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'アカウントの登録に失敗しました: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'JellyfinサーバーのURLを入力してください',
|
||||
'addServer.addConnectionTitle' => '接続を追加',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name}に追加',
|
||||
'addServer.signInWithPlexCard' => 'Plexでサインイン',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'このデバイスを承認します。共有サーバーが追加されます。',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Plexアカウントを承認します。Homeユーザーはプロフィールになります。',
|
||||
'addServer.connectToJellyfinCard' => 'Jellyfinに接続',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'サーバーURL、ユーザー名、パスワードを入力してください。',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfinサーバーにサインインします。${name}にひも付けられます。',
|
||||
'addServer.borrowFromAnotherProfile' => '別のプロフィールの接続を利用',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => '別のプロフィールの接続を再利用します。PINで保護されたプロフィールにはPINが必要です。',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$kk extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Растау күтілуде...\nБраузеріңізден кіріңіз.';
|
||||
@override String get useBrowser => 'Браузерді пайдалану';
|
||||
@override String get or => 'немесе';
|
||||
@override String get connectToJellyfin => 'Jellyfin-ге қосылу';
|
||||
@override String get useQuickConnect => 'Жылдам қосылуды пайдалану';
|
||||
@override String get quickConnectInstructions => 'Jellyfin-де Жылдам қосылуды ашып, осы кодты енгізіңіз.';
|
||||
@override String get quickConnectWaiting => 'Растау күтілуде…';
|
||||
@@ -922,8 +921,6 @@ class _Translations$connections$kk extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => '${name} үшін сеанс мерзімі өтті';
|
||||
@override String sessionExpiredMany({required Object count}) => '${count} сервер үшін сеанс мерзімі өтті';
|
||||
@override String get signInAgain => 'Қайтадан кіру';
|
||||
@override String get editJellyfinTitle => 'Jellyfin қосылымын өңдеу';
|
||||
@override String editJellyfinIntro({required Object serverName}) => '${serverName} үшін URL мекенжайын қосыңыз немесе өшіріңіз.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1814,12 +1811,9 @@ class _Translations$addServer$kk extends Translations$addServer$en {
|
||||
final TranslationsKk _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Jellyfin серверін қосу';
|
||||
@override String get serverUrls => 'Сервер URL-дері';
|
||||
@override String get serverUrlsHelper => 'Үтірмен бөлінген бірнеше URL мекенжайына рұқсат етіледі.';
|
||||
@override String get findServer => 'Серверді табу';
|
||||
@override String get searchingLocalServers => 'Жергілікті Jellyfin серверлері ізделуде...';
|
||||
@override String get localServers => 'Жергілікті Jellyfin серверлері';
|
||||
@override String get username => 'Пайдаланушы аты';
|
||||
@override String get password => 'Құпия сөз';
|
||||
@override String get signIn => 'Кіру';
|
||||
@@ -1831,15 +1825,11 @@ class _Translations$addServer$kk extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Plex арқылы кіру';
|
||||
@override String get pinExpired => 'PIN код мерзімі өтті.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Тіркелгіні тіркеу қатесі: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Jellyfin сервер URL-ін енгізіңіз';
|
||||
@override String get addConnectionTitle => 'Қосылым қосу';
|
||||
@override String addConnectionTitleScoped({required Object name}) => '${name} профиліне қосу';
|
||||
@override String get signInWithPlexCard => 'Plex арқылы кіру';
|
||||
@override String get signInWithPlexCardSubtitle => 'Осы құрылғыны авторизациялау.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Plex тіркелгісін авторизациялау.';
|
||||
@override String get connectToJellyfinCard => 'Jellyfin-ге қосылу';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Сервер URL-ін, пайдаланушы атын енгізіңіз.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfin серверіне кіру. ${name} профиліне жалғануда.';
|
||||
@override String get borrowFromAnotherProfile => 'Басқа профильден алу';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Басқа профильдің қосылымын қайта пайдалану.';
|
||||
}
|
||||
@@ -2219,7 +2209,6 @@ extension on TranslationsKk {
|
||||
'auth.waitingForAuth' => 'Растау күтілуде...\nБраузеріңізден кіріңіз.',
|
||||
'auth.useBrowser' => 'Браузерді пайдалану',
|
||||
'auth.or' => 'немесе',
|
||||
'auth.connectToJellyfin' => 'Jellyfin-ге қосылу',
|
||||
'auth.useQuickConnect' => 'Жылдам қосылуды пайдалану',
|
||||
'auth.quickConnectInstructions' => 'Jellyfin-де Жылдам қосылуды ашып, осы кодты енгізіңіз.',
|
||||
'auth.quickConnectWaiting' => 'Растау күтілуде…',
|
||||
@@ -2722,9 +2711,9 @@ extension on TranslationsKk {
|
||||
'videoControls.noChaptersAvailable' => 'Бөлімдер қолжетімсіз',
|
||||
'videoControls.queue' => 'Кезек',
|
||||
'videoControls.noQueueItems' => 'Кезекте элементтер жоқ',
|
||||
'videoControls.searchSubtitles' => 'Субтитр іздеу',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.searchSubtitles' => 'Субтитр іздеу',
|
||||
'videoControls.language' => 'Тіл',
|
||||
'videoControls.noSubtitlesFound' => 'Субтитр табылмады',
|
||||
'videoControls.subtitleDownloaded' => 'Субтитр жүктелді',
|
||||
@@ -2884,8 +2873,6 @@ extension on TranslationsKk {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => '${name} үшін сеанс мерзімі өтті',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => '${count} сервер үшін сеанс мерзімі өтті',
|
||||
'connections.signInAgain' => 'Қайтадан кіру',
|
||||
'connections.editJellyfinTitle' => 'Jellyfin қосылымын өңдеу',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName} үшін URL мекенжайын қосыңыз немесе өшіріңіз.',
|
||||
'discover.title' => 'Шолу',
|
||||
'discover.noContentAvailable' => 'Мазмұн қолжетімсіз',
|
||||
'discover.addMediaToLibraries' => 'Кітапханаларыңызға медиа қосыңыз',
|
||||
@@ -3236,11 +3223,11 @@ extension on TranslationsKk {
|
||||
'watchTogether.enterCodeHint' => '5 таңбалы кодты енгізіңіз',
|
||||
'watchTogether.pasteFromClipboard' => 'Алмасу буферінен қою',
|
||||
'watchTogether.pleaseEnterCode' => 'Сеанс кодын енгізіңіз',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.codeMustBe5Chars' => 'Сеанс коды 5 таңбадан тұруы керек',
|
||||
'watchTogether.joinInstructions' => 'Ұйымдастырушының сеанс кодын енгізіңіз.',
|
||||
'watchTogether.failedToCreate' => 'Сеансты жасау мүмкін болмады',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.failedToJoin' => 'Сеансқа қосылу мүмкін болмады',
|
||||
'watchTogether.sessionCodeCopied' => 'Сеанс коды көшірілді',
|
||||
'watchTogether.relayUnreachable' => 'Реле сервері қолжетімсіз.',
|
||||
@@ -3678,12 +3665,9 @@ extension on TranslationsKk {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Тек төменде таңдалған кітапханаларды синхрондау.',
|
||||
'services.libraryFilter.libraries' => 'Кітапханалар',
|
||||
'services.libraryFilter.noLibraries' => 'Кітапханалар жоқ',
|
||||
'addServer.addJellyfinTitle' => 'Jellyfin серверін қосу',
|
||||
'addServer.serverUrls' => 'Сервер URL-дері',
|
||||
'addServer.serverUrlsHelper' => 'Үтірмен бөлінген бірнеше URL мекенжайына рұқсат етіледі.',
|
||||
'addServer.findServer' => 'Серверді табу',
|
||||
'addServer.searchingLocalServers' => 'Жергілікті Jellyfin серверлері ізделуде...',
|
||||
'addServer.localServers' => 'Жергілікті Jellyfin серверлері',
|
||||
'addServer.username' => 'Пайдаланушы аты',
|
||||
'addServer.password' => 'Құпия сөз',
|
||||
'addServer.signIn' => 'Кіру',
|
||||
@@ -3695,15 +3679,11 @@ extension on TranslationsKk {
|
||||
'addServer.addPlexTitle' => 'Plex арқылы кіру',
|
||||
'addServer.pinExpired' => 'PIN код мерзімі өтті.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Тіркелгіні тіркеу қатесі: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Jellyfin сервер URL-ін енгізіңіз',
|
||||
'addServer.addConnectionTitle' => 'Қосылым қосу',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name} профиліне қосу',
|
||||
'addServer.signInWithPlexCard' => 'Plex арқылы кіру',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Осы құрылғыны авторизациялау.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Plex тіркелгісін авторизациялау.',
|
||||
'addServer.connectToJellyfinCard' => 'Jellyfin-ге қосылу',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Сервер URL-ін, пайдаланушы атын енгізіңіз.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfin серверіне кіру. ${name} профиліне жалғануда.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Басқа профильден алу',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Басқа профильдің қосылымын қайта пайдалану.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$ko extends Translations$auth$en {
|
||||
@override String get waitingForAuth => '인증 대기 중...\n브라우저에서 로그인하세요.';
|
||||
@override String get useBrowser => '브라우저 사용';
|
||||
@override String get or => '또는';
|
||||
@override String get connectToJellyfin => 'Jellyfin에 연결';
|
||||
@override String get useQuickConnect => 'Quick Connect 사용';
|
||||
@override String get quickConnectInstructions => 'Jellyfin에서 Quick Connect를 열고 이 코드를 입력하세요.';
|
||||
@override String get quickConnectWaiting => '승인 대기 중…';
|
||||
@@ -917,8 +916,6 @@ class _Translations$connections$ko extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => '${name}의 세션이 만료되었습니다';
|
||||
@override String sessionExpiredMany({required Object count}) => '${count}개 서버의 세션이 만료되었습니다';
|
||||
@override String get signInAgain => '다시 로그인';
|
||||
@override String get editJellyfinTitle => 'Jellyfin 연결 편집';
|
||||
@override String editJellyfinIntro({required Object serverName}) => '${serverName}의 URL을 추가하거나 제거하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1800,12 +1797,9 @@ class _Translations$addServer$ko extends Translations$addServer$en {
|
||||
final TranslationsKo _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Jellyfin 서버 추가';
|
||||
@override String get serverUrls => '서버 URL';
|
||||
@override String get serverUrlsHelper => '쉼표로 구분하여 여러 URL을 입력할 수 있습니다.';
|
||||
@override String get findServer => '서버 찾기';
|
||||
@override String get searchingLocalServers => '로컬 Jellyfin 서버 검색 중...';
|
||||
@override String get localServers => '로컬 Jellyfin 서버';
|
||||
@override String get username => '사용자 이름';
|
||||
@override String get password => '비밀번호';
|
||||
@override String get signIn => '로그인';
|
||||
@@ -1817,15 +1811,11 @@ class _Translations$addServer$ko extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Plex로 로그인';
|
||||
@override String get pinExpired => '로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.';
|
||||
@override String failedToRegisterAccount({required Object error}) => '계정 등록 실패: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Jellyfin 서버 URL을 입력하세요';
|
||||
@override String get addConnectionTitle => '연결 추가';
|
||||
@override String addConnectionTitleScoped({required Object name}) => '${name}에 추가';
|
||||
@override String get signInWithPlexCard => 'Plex로 로그인';
|
||||
@override String get signInWithPlexCardSubtitle => '이 기기를 승인합니다. 공유 서버가 추가됩니다.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Plex 계정을 승인합니다. Home 사용자는 프로필이 됩니다.';
|
||||
@override String get connectToJellyfinCard => 'Jellyfin에 연결';
|
||||
@override String get connectToJellyfinCardSubtitle => '서버 URL, 사용자 이름, 비밀번호를 입력하세요.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.';
|
||||
@override String get borrowFromAnotherProfile => '다른 프로필에서 빌리기';
|
||||
@override String get borrowFromAnotherProfileSubtitle => '다른 프로필의 연결을 재사용합니다. PIN으로 보호된 프로필에는 PIN이 필요합니다.';
|
||||
}
|
||||
@@ -2205,7 +2195,6 @@ extension on TranslationsKo {
|
||||
'auth.waitingForAuth' => '인증 대기 중...\n브라우저에서 로그인하세요.',
|
||||
'auth.useBrowser' => '브라우저 사용',
|
||||
'auth.or' => '또는',
|
||||
'auth.connectToJellyfin' => 'Jellyfin에 연결',
|
||||
'auth.useQuickConnect' => 'Quick Connect 사용',
|
||||
'auth.quickConnectInstructions' => 'Jellyfin에서 Quick Connect를 열고 이 코드를 입력하세요.',
|
||||
'auth.quickConnectWaiting' => '승인 대기 중…',
|
||||
@@ -2708,9 +2697,9 @@ extension on TranslationsKo {
|
||||
'videoControls.searchSubtitles' => '자막 검색',
|
||||
'videoControls.language' => '언어',
|
||||
'videoControls.noSubtitlesFound' => '자막을 찾을 수 없습니다',
|
||||
'videoControls.subtitleDownloaded' => '자막이 다운로드되었습니다',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => '자막이 다운로드되었습니다',
|
||||
'videoControls.subtitleDownloadedNotApplied' => '자막을 다운로드했지만 선택할 수 없습니다',
|
||||
'videoControls.subtitleDownloadFailed' => '자막 다운로드에 실패했습니다',
|
||||
'videoControls.searchLanguages' => '언어 검색...',
|
||||
@@ -2866,8 +2855,6 @@ extension on TranslationsKo {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => '${name}의 세션이 만료되었습니다',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => '${count}개 서버의 세션이 만료되었습니다',
|
||||
'connections.signInAgain' => '다시 로그인',
|
||||
'connections.editJellyfinTitle' => 'Jellyfin 연결 편집',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName}의 URL을 추가하거나 제거하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다.',
|
||||
'discover.title' => '둘러보기',
|
||||
'discover.noContentAvailable' => '사용 가능한 콘텐츠가 없습니다',
|
||||
'discover.addMediaToLibraries' => '미디어 라이브러리에 미디어를 추가해 주세요',
|
||||
@@ -3222,11 +3209,11 @@ extension on TranslationsKo {
|
||||
'watchTogether.failedToCreate' => '세션 생성 실패',
|
||||
'watchTogether.failedToJoin' => '세션 참여 실패',
|
||||
'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. ISP 차단으로 함께 보기를 사용하지 못할 수 있습니다.',
|
||||
'watchTogether.reconnectingToHost' => '호스트에 재연결 중...',
|
||||
'watchTogether.currentPlayback' => '현재 재생',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => '현재 재생 참여',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => '호스트가 현재 시청 중인 콘텐츠로 이동합니다',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => '현재 재생을 열 수 없습니다',
|
||||
@@ -3653,12 +3640,9 @@ extension on TranslationsKo {
|
||||
'services.libraryFilter.modeHintWhitelist' => '아래에 선택한 라이브러리만 동기화합니다.',
|
||||
'services.libraryFilter.libraries' => '라이브러리',
|
||||
'services.libraryFilter.noLibraries' => '사용 가능한 라이브러리가 없습니다',
|
||||
'addServer.addJellyfinTitle' => 'Jellyfin 서버 추가',
|
||||
'addServer.serverUrls' => '서버 URL',
|
||||
'addServer.serverUrlsHelper' => '쉼표로 구분하여 여러 URL을 입력할 수 있습니다.',
|
||||
'addServer.findServer' => '서버 찾기',
|
||||
'addServer.searchingLocalServers' => '로컬 Jellyfin 서버 검색 중...',
|
||||
'addServer.localServers' => '로컬 Jellyfin 서버',
|
||||
'addServer.username' => '사용자 이름',
|
||||
'addServer.password' => '비밀번호',
|
||||
'addServer.signIn' => '로그인',
|
||||
@@ -3670,15 +3654,11 @@ extension on TranslationsKo {
|
||||
'addServer.addPlexTitle' => 'Plex로 로그인',
|
||||
'addServer.pinExpired' => '로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => '계정 등록 실패: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Jellyfin 서버 URL을 입력하세요',
|
||||
'addServer.addConnectionTitle' => '연결 추가',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name}에 추가',
|
||||
'addServer.signInWithPlexCard' => 'Plex로 로그인',
|
||||
'addServer.signInWithPlexCardSubtitle' => '이 기기를 승인합니다. 공유 서버가 추가됩니다.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Plex 계정을 승인합니다. Home 사용자는 프로필이 됩니다.',
|
||||
'addServer.connectToJellyfinCard' => 'Jellyfin에 연결',
|
||||
'addServer.connectToJellyfinCardSubtitle' => '서버 URL, 사용자 이름, 비밀번호를 입력하세요.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.',
|
||||
'addServer.borrowFromAnotherProfile' => '다른 프로필에서 빌리기',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => '다른 프로필의 연결을 재사용합니다. PIN으로 보호된 프로필에는 PIN이 필요합니다.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$nb extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Venter på autentisering...\nLogg inn fra nettleseren.';
|
||||
@override String get useBrowser => 'Bruk nettleser';
|
||||
@override String get or => 'eller';
|
||||
@override String get connectToJellyfin => 'Koble til Jellyfin';
|
||||
@override String get useQuickConnect => 'Bruk Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Åpne Quick Connect i Jellyfin og skriv inn denne koden.';
|
||||
@override String get quickConnectWaiting => 'Venter på godkjenning…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$nb extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Økten er utløpt for ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Økten er utløpt for ${count} servere';
|
||||
@override String get signInAgain => 'Logg inn igjen';
|
||||
@override String get editJellyfinTitle => 'Rediger Jellyfin-tilkobling';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Legg til eller fjern URL-er for ${serverName}. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$nb extends Translations$addServer$en {
|
||||
final TranslationsNb _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Legg til Jellyfin-server';
|
||||
@override String get serverUrls => 'Server-URL-er';
|
||||
@override String get serverUrlsHelper => 'Flere URL-er er tillatt, atskilt med komma.';
|
||||
@override String get findServer => 'Finn server';
|
||||
@override String get searchingLocalServers => 'Søker etter lokale Jellyfin-servere...';
|
||||
@override String get localServers => 'Lokale Jellyfin-servere';
|
||||
@override String get username => 'Brukernavn';
|
||||
@override String get password => 'Passord';
|
||||
@override String get signIn => 'Logg inn';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$nb extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Logg inn med Plex';
|
||||
@override String get pinExpired => 'PIN-koden utløp før innloggingen var fullført. Prøv igjen.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Kunne ikke registrere kontoen: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Oppgi URL-en til Jellyfin-serveren din';
|
||||
@override String get addConnectionTitle => 'Legg til tilkobling';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Legg til for ${name}';
|
||||
@override String get signInWithPlexCard => 'Logg inn med Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Autoriser denne enheten. Delte servere legges til.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Autoriser en Plex-konto. Home-brukere blir profiler.';
|
||||
@override String get connectToJellyfinCard => 'Koble til Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Skriv inn server-URL, brukernavn og passord.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Logg på en Jellyfin-server. Knyttes til ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Lån fra en annen profil';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Gjenbruk en annen profils tilkobling. PIN-beskyttede profiler krever PIN.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsNb {
|
||||
'auth.waitingForAuth' => 'Venter på autentisering...\nLogg inn fra nettleseren.',
|
||||
'auth.useBrowser' => 'Bruk nettleser',
|
||||
'auth.or' => 'eller',
|
||||
'auth.connectToJellyfin' => 'Koble til Jellyfin',
|
||||
'auth.useQuickConnect' => 'Bruk Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Åpne Quick Connect i Jellyfin og skriv inn denne koden.',
|
||||
'auth.quickConnectWaiting' => 'Venter på godkjenning…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsNb {
|
||||
'videoControls.searchSubtitles' => 'Søk etter undertekster',
|
||||
'videoControls.language' => 'Språk',
|
||||
'videoControls.noSubtitlesFound' => 'Ingen undertekster funnet',
|
||||
'videoControls.subtitleDownloaded' => 'Undertekst lastet ned',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Undertekst lastet ned',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Underteksten ble lastet ned, men kunne ikke velges',
|
||||
'videoControls.subtitleDownloadFailed' => 'Kunne ikke laste ned undertekst',
|
||||
'videoControls.searchLanguages' => 'Søk etter språk...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsNb {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Økten er utløpt for ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Økten er utløpt for ${count} servere',
|
||||
'connections.signInAgain' => 'Logg inn igjen',
|
||||
'connections.editJellyfinTitle' => 'Rediger Jellyfin-tilkobling',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Legg til eller fjern URL-er for ${serverName}. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse.',
|
||||
'discover.title' => 'Oppdag',
|
||||
'discover.noContentAvailable' => 'Ikke noe innhold tilgjengelig',
|
||||
'discover.addMediaToLibraries' => 'Legg til medier i bibliotekene dine',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsNb {
|
||||
'watchTogether.failedToCreate' => 'Kunne ikke opprette økt',
|
||||
'watchTogether.failedToJoin' => 'Kunne ikke bli med i økt',
|
||||
'watchTogether.sessionCodeCopied' => 'Øktkode kopiert til utklippstavle',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Reléserveren kan ikke nås. Blokkering hos internettleverandøren kan hindre Se sammen.',
|
||||
'watchTogether.reconnectingToHost' => 'Kobler til verten på nytt...',
|
||||
'watchTogether.currentPlayback' => 'Gjeldende avspilling',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Bli med i gjeldende avspilling',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Hopp tilbake til det verten ser på nå',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Kunne ikke åpne gjeldende avspilling',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsNb {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Synkroniser kun bibliotekene du markerer nedenfor.',
|
||||
'services.libraryFilter.libraries' => 'Biblioteker',
|
||||
'services.libraryFilter.noLibraries' => 'Ingen biblioteker tilgjengelige',
|
||||
'addServer.addJellyfinTitle' => 'Legg til Jellyfin-server',
|
||||
'addServer.serverUrls' => 'Server-URL-er',
|
||||
'addServer.serverUrlsHelper' => 'Flere URL-er er tillatt, atskilt med komma.',
|
||||
'addServer.findServer' => 'Finn server',
|
||||
'addServer.searchingLocalServers' => 'Søker etter lokale Jellyfin-servere...',
|
||||
'addServer.localServers' => 'Lokale Jellyfin-servere',
|
||||
'addServer.username' => 'Brukernavn',
|
||||
'addServer.password' => 'Passord',
|
||||
'addServer.signIn' => 'Logg inn',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsNb {
|
||||
'addServer.addPlexTitle' => 'Logg inn med Plex',
|
||||
'addServer.pinExpired' => 'PIN-koden utløp før innloggingen var fullført. Prøv igjen.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Kunne ikke registrere kontoen: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Oppgi URL-en til Jellyfin-serveren din',
|
||||
'addServer.addConnectionTitle' => 'Legg til tilkobling',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Legg til for ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Logg inn med Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Autoriser denne enheten. Delte servere legges til.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Autoriser en Plex-konto. Home-brukere blir profiler.',
|
||||
'addServer.connectToJellyfinCard' => 'Koble til Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Skriv inn server-URL, brukernavn og passord.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Logg på en Jellyfin-server. Knyttes til ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Lån fra en annen profil',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Gjenbruk en annen profils tilkobling. PIN-beskyttede profiler krever PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$nl extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Wachten op authenticatie...\nMeld je aan via je browser.';
|
||||
@override String get useBrowser => 'Gebruik browser';
|
||||
@override String get or => 'of';
|
||||
@override String get connectToJellyfin => 'Verbinden met Jellyfin';
|
||||
@override String get useQuickConnect => 'Quick Connect gebruiken';
|
||||
@override String get quickConnectInstructions => 'Open Quick Connect in Jellyfin en voer deze code in.';
|
||||
@override String get quickConnectWaiting => 'Wachten op goedkeuring…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$nl extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sessie verlopen voor ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sessie verlopen voor ${count} servers';
|
||||
@override String get signInAgain => 'Opnieuw aanmelden';
|
||||
@override String get editJellyfinTitle => 'Jellyfin-verbinding bewerken';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Voeg URL\'s voor ${serverName} toe of verwijder ze. Plezy gebruikt de bereikbare URL met de laagste latentie.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$nl extends Translations$addServer$en {
|
||||
final TranslationsNl _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Jellyfin-server toevoegen';
|
||||
@override String get serverUrls => 'Server-URL\'s';
|
||||
@override String get serverUrlsHelper => 'Meerdere URL\'s toegestaan, gescheiden door komma\'s.';
|
||||
@override String get findServer => 'Server zoeken';
|
||||
@override String get searchingLocalServers => 'Lokale Jellyfin-servers zoeken...';
|
||||
@override String get localServers => 'Lokale Jellyfin-servers';
|
||||
@override String get username => 'Gebruikersnaam';
|
||||
@override String get password => 'Wachtwoord';
|
||||
@override String get signIn => 'Inloggen';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$nl extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Inloggen met Plex';
|
||||
@override String get pinExpired => 'De pincode verliep voordat je kon inloggen. Probeer het opnieuw.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Account registreren mislukt: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Voer de URL van je Jellyfin-server in';
|
||||
@override String get addConnectionTitle => 'Verbinding toevoegen';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Toevoegen aan ${name}';
|
||||
@override String get signInWithPlexCard => 'Inloggen met Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Autoriseer dit apparaat. Gedeelde servers worden toegevoegd.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Autoriseer een Plex-account. Home-gebruikers worden profielen.';
|
||||
@override String get connectToJellyfinCard => 'Verbinden met Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Voer je server-URL, gebruikersnaam en wachtwoord in.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Van een ander profiel lenen';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Hergebruik de verbinding van een ander profiel. Voor profielen met pincodebeveiliging is een pincode vereist.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsNl {
|
||||
'auth.waitingForAuth' => 'Wachten op authenticatie...\nMeld je aan via je browser.',
|
||||
'auth.useBrowser' => 'Gebruik browser',
|
||||
'auth.or' => 'of',
|
||||
'auth.connectToJellyfin' => 'Verbinden met Jellyfin',
|
||||
'auth.useQuickConnect' => 'Quick Connect gebruiken',
|
||||
'auth.quickConnectInstructions' => 'Open Quick Connect in Jellyfin en voer deze code in.',
|
||||
'auth.quickConnectWaiting' => 'Wachten op goedkeuring…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsNl {
|
||||
'videoControls.searchSubtitles' => 'Ondertitels zoeken',
|
||||
'videoControls.language' => 'Taal',
|
||||
'videoControls.noSubtitlesFound' => 'Geen ondertitels gevonden',
|
||||
'videoControls.subtitleDownloaded' => 'Ondertitel gedownload',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Ondertitel gedownload',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'De ondertiteling is gedownload, maar kon niet worden geselecteerd',
|
||||
'videoControls.subtitleDownloadFailed' => 'Ondertitel downloaden mislukt',
|
||||
'videoControls.searchLanguages' => 'Talen zoeken...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsNl {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sessie verlopen voor ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sessie verlopen voor ${count} servers',
|
||||
'connections.signInAgain' => 'Opnieuw aanmelden',
|
||||
'connections.editJellyfinTitle' => 'Jellyfin-verbinding bewerken',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Voeg URL\'s voor ${serverName} toe of verwijder ze. Plezy gebruikt de bereikbare URL met de laagste latentie.',
|
||||
'discover.title' => 'Ontdekken',
|
||||
'discover.noContentAvailable' => 'Geen inhoud beschikbaar',
|
||||
'discover.addMediaToLibraries' => 'Voeg wat media toe aan je bibliotheken',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsNl {
|
||||
'watchTogether.failedToCreate' => 'Sessie maken mislukt',
|
||||
'watchTogether.failedToJoin' => 'Deelnemen aan sessie mislukt',
|
||||
'watchTogether.sessionCodeCopied' => 'Sessiecode naar het klembord gekopieerd',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'De relayserver is onbereikbaar. Een blokkering door je internetprovider kan Samen kijken verhinderen.',
|
||||
'watchTogether.reconnectingToHost' => 'Opnieuw verbinden met host...',
|
||||
'watchTogether.currentPlayback' => 'Wat nu wordt afgespeeld',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Deelnemen aan huidige weergave',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Ga terug naar wat de host nu kijkt',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Wat nu wordt afgespeeld kon niet worden geopend',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsNl {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Synchroniseer alleen de hieronder aangevinkte bibliotheken.',
|
||||
'services.libraryFilter.libraries' => 'Bibliotheken',
|
||||
'services.libraryFilter.noLibraries' => 'Geen bibliotheken beschikbaar',
|
||||
'addServer.addJellyfinTitle' => 'Jellyfin-server toevoegen',
|
||||
'addServer.serverUrls' => 'Server-URL\'s',
|
||||
'addServer.serverUrlsHelper' => 'Meerdere URL\'s toegestaan, gescheiden door komma\'s.',
|
||||
'addServer.findServer' => 'Server zoeken',
|
||||
'addServer.searchingLocalServers' => 'Lokale Jellyfin-servers zoeken...',
|
||||
'addServer.localServers' => 'Lokale Jellyfin-servers',
|
||||
'addServer.username' => 'Gebruikersnaam',
|
||||
'addServer.password' => 'Wachtwoord',
|
||||
'addServer.signIn' => 'Inloggen',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsNl {
|
||||
'addServer.addPlexTitle' => 'Inloggen met Plex',
|
||||
'addServer.pinExpired' => 'De pincode verliep voordat je kon inloggen. Probeer het opnieuw.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Account registreren mislukt: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Voer de URL van je Jellyfin-server in',
|
||||
'addServer.addConnectionTitle' => 'Verbinding toevoegen',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Toevoegen aan ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Inloggen met Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Autoriseer dit apparaat. Gedeelde servers worden toegevoegd.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Autoriseer een Plex-account. Home-gebruikers worden profielen.',
|
||||
'addServer.connectToJellyfinCard' => 'Verbinden met Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Voer je server-URL, gebruikersnaam en wachtwoord in.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Van een ander profiel lenen',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Hergebruik de verbinding van een ander profiel. Voor profielen met pincodebeveiliging is een pincode vereist.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$pl extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Oczekiwanie na uwierzytelnienie...\nZaloguj się w przeglądarce.';
|
||||
@override String get useBrowser => 'Użyj przeglądarki';
|
||||
@override String get or => 'lub';
|
||||
@override String get connectToJellyfin => 'Połącz z Jellyfin';
|
||||
@override String get useQuickConnect => 'Użyj Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Otwórz Quick Connect w Jellyfin i wpisz ten kod.';
|
||||
@override String get quickConnectWaiting => 'Oczekiwanie na zatwierdzenie…';
|
||||
@@ -920,8 +919,6 @@ class _Translations$connections$pl extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sesja wygasła dla ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sesja wygasła dla ${count} serwerów';
|
||||
@override String get signInAgain => 'Zaloguj się ponownie';
|
||||
@override String get editJellyfinTitle => 'Edytuj połączenie Jellyfin';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Dodaj lub usuń adresy URL dla ${serverName}. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1809,12 +1806,9 @@ class _Translations$addServer$pl extends Translations$addServer$en {
|
||||
final TranslationsPl _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Dodaj serwer Jellyfin';
|
||||
@override String get serverUrls => 'Adresy URL serwera';
|
||||
@override String get serverUrlsHelper => 'Można podać wiele adresów URL rozdzielonych przecinkami.';
|
||||
@override String get findServer => 'Znajdź serwer';
|
||||
@override String get searchingLocalServers => 'Szukanie lokalnych serwerów Jellyfin...';
|
||||
@override String get localServers => 'Lokalne serwery Jellyfin';
|
||||
@override String get username => 'Nazwa użytkownika';
|
||||
@override String get password => 'Hasło';
|
||||
@override String get signIn => 'Zaloguj się';
|
||||
@@ -1826,15 +1820,11 @@ class _Translations$addServer$pl extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Zaloguj się przez Plex';
|
||||
@override String get pinExpired => 'PIN wygasł przed zalogowaniem. Spróbuj ponownie.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Nie udało się zarejestrować konta: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Podaj URL serwera Jellyfin';
|
||||
@override String get addConnectionTitle => 'Dodaj połączenie';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Dodaj do ${name}';
|
||||
@override String get signInWithPlexCard => 'Zaloguj się przez Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Autoryzuj to urządzenie. Serwery udostępnione zostaną dodane.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Autoryzuj konto Plex. Użytkownicy Home staną się profilami.';
|
||||
@override String get connectToJellyfinCard => 'Połącz z Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Wpisz URL serwera, nazwę użytkownika i hasło.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Zaloguj się do serwera Jellyfin. Powiązane z ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Pożycz z innego profilu';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Użyj połączenia innego profilu. Profile chronione PIN-em wymagają podania PIN-u.';
|
||||
}
|
||||
@@ -2214,7 +2204,6 @@ extension on TranslationsPl {
|
||||
'auth.waitingForAuth' => 'Oczekiwanie na uwierzytelnienie...\nZaloguj się w przeglądarce.',
|
||||
'auth.useBrowser' => 'Użyj przeglądarki',
|
||||
'auth.or' => 'lub',
|
||||
'auth.connectToJellyfin' => 'Połącz z Jellyfin',
|
||||
'auth.useQuickConnect' => 'Użyj Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Otwórz Quick Connect w Jellyfin i wpisz ten kod.',
|
||||
'auth.quickConnectWaiting' => 'Oczekiwanie na zatwierdzenie…',
|
||||
@@ -2717,9 +2706,9 @@ extension on TranslationsPl {
|
||||
'videoControls.searchSubtitles' => 'Szukaj napisów',
|
||||
'videoControls.language' => 'Język',
|
||||
'videoControls.noSubtitlesFound' => 'Nie znaleziono napisów',
|
||||
'videoControls.subtitleDownloaded' => 'Napisy pobrane',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Napisy pobrane',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Napisy zostały pobrane, ale nie można ich było wybrać',
|
||||
'videoControls.subtitleDownloadFailed' => 'Nie udało się pobrać napisów',
|
||||
'videoControls.searchLanguages' => 'Szukaj języków...',
|
||||
@@ -2875,8 +2864,6 @@ extension on TranslationsPl {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sesja wygasła dla ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sesja wygasła dla ${count} serwerów',
|
||||
'connections.signInAgain' => 'Zaloguj się ponownie',
|
||||
'connections.editJellyfinTitle' => 'Edytuj połączenie Jellyfin',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Dodaj lub usuń adresy URL dla ${serverName}. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu.',
|
||||
'discover.title' => 'Odkryj',
|
||||
'discover.noContentAvailable' => 'Brak dostępnych treści',
|
||||
'discover.addMediaToLibraries' => 'Dodaj multimedia do swoich bibliotek',
|
||||
@@ -3231,11 +3218,11 @@ extension on TranslationsPl {
|
||||
'watchTogether.failedToCreate' => 'Nie udało się utworzyć sesji',
|
||||
'watchTogether.failedToJoin' => 'Nie udało się dołączyć do sesji',
|
||||
'watchTogether.sessionCodeCopied' => 'Kod sesji skopiowany do schowka',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Serwer pośredniczący jest nieosiągalny. Blokada operatora internetowego może uniemożliwiać korzystanie z funkcji „Oglądaj razem”.',
|
||||
'watchTogether.reconnectingToHost' => 'Ponowne łączenie z gospodarzem...',
|
||||
'watchTogether.currentPlayback' => 'Bieżące odtwarzanie',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Dołącz do bieżącego odtwarzania',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Wróć do treści oglądanej obecnie przez gospodarza',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Nie udało się otworzyć bieżącego odtwarzania',
|
||||
@@ -3662,12 +3649,9 @@ extension on TranslationsPl {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Synchronizuj tylko biblioteki zaznaczone poniżej.',
|
||||
'services.libraryFilter.libraries' => 'Biblioteki',
|
||||
'services.libraryFilter.noLibraries' => 'Brak dostępnych bibliotek',
|
||||
'addServer.addJellyfinTitle' => 'Dodaj serwer Jellyfin',
|
||||
'addServer.serverUrls' => 'Adresy URL serwera',
|
||||
'addServer.serverUrlsHelper' => 'Można podać wiele adresów URL rozdzielonych przecinkami.',
|
||||
'addServer.findServer' => 'Znajdź serwer',
|
||||
'addServer.searchingLocalServers' => 'Szukanie lokalnych serwerów Jellyfin...',
|
||||
'addServer.localServers' => 'Lokalne serwery Jellyfin',
|
||||
'addServer.username' => 'Nazwa użytkownika',
|
||||
'addServer.password' => 'Hasło',
|
||||
'addServer.signIn' => 'Zaloguj się',
|
||||
@@ -3679,15 +3663,11 @@ extension on TranslationsPl {
|
||||
'addServer.addPlexTitle' => 'Zaloguj się przez Plex',
|
||||
'addServer.pinExpired' => 'PIN wygasł przed zalogowaniem. Spróbuj ponownie.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Nie udało się zarejestrować konta: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Podaj URL serwera Jellyfin',
|
||||
'addServer.addConnectionTitle' => 'Dodaj połączenie',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Dodaj do ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Zaloguj się przez Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Autoryzuj to urządzenie. Serwery udostępnione zostaną dodane.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Autoryzuj konto Plex. Użytkownicy Home staną się profilami.',
|
||||
'addServer.connectToJellyfinCard' => 'Połącz z Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Wpisz URL serwera, nazwę użytkownika i hasło.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Zaloguj się do serwera Jellyfin. Powiązane z ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Pożycz z innego profilu',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Użyj połączenia innego profilu. Profile chronione PIN-em wymagają podania PIN-u.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$pt extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Aguardando autenticação...\nEntre pelo navegador.';
|
||||
@override String get useBrowser => 'Usar navegador';
|
||||
@override String get or => 'ou';
|
||||
@override String get connectToJellyfin => 'Conectar ao Jellyfin';
|
||||
@override String get useQuickConnect => 'Usar Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Abra o Quick Connect no Jellyfin e insira este código.';
|
||||
@override String get quickConnectWaiting => 'Aguardando aprovação…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$pt extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sessão de ${name} expirada';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sessões expiradas em ${count} servidores';
|
||||
@override String get signInAgain => 'Entrar novamente';
|
||||
@override String get editJellyfinTitle => 'Editar conexão Jellyfin';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Adicione ou remova URLs de ${serverName}. O Plezy usará a URL acessível com a menor latência.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$pt extends Translations$addServer$en {
|
||||
final TranslationsPt _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Adicionar servidor Jellyfin';
|
||||
@override String get serverUrls => 'URLs do servidor';
|
||||
@override String get serverUrlsHelper => 'Várias URLs são permitidas, separadas por vírgulas.';
|
||||
@override String get findServer => 'Encontrar servidor';
|
||||
@override String get searchingLocalServers => 'Procurando servidores Jellyfin locais...';
|
||||
@override String get localServers => 'Servidores Jellyfin locais';
|
||||
@override String get username => 'Usuário';
|
||||
@override String get password => 'Senha';
|
||||
@override String get signIn => 'Entrar';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$pt extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Entrar com Plex';
|
||||
@override String get pinExpired => 'O PIN expirou antes de entrar. Tente novamente.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Falha ao registrar a conta: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Insira a URL do seu servidor Jellyfin';
|
||||
@override String get addConnectionTitle => 'Adicionar conexão';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Adicionar a ${name}';
|
||||
@override String get signInWithPlexCard => 'Entrar com Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Autorize este dispositivo. Servidores compartilhados são adicionados.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Autorize uma conta Plex. Os usuários do Plex Home se tornam perfis.';
|
||||
@override String get connectToJellyfinCard => 'Conectar ao Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Insira URL do servidor, usuário e senha.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Entre em um servidor Jellyfin. A conexão será vinculada a ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Pegar emprestado de outro perfil';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Reutilize a conexão de outro perfil. Perfis protegidos por PIN exigem PIN.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsPt {
|
||||
'auth.waitingForAuth' => 'Aguardando autenticação...\nEntre pelo navegador.',
|
||||
'auth.useBrowser' => 'Usar navegador',
|
||||
'auth.or' => 'ou',
|
||||
'auth.connectToJellyfin' => 'Conectar ao Jellyfin',
|
||||
'auth.useQuickConnect' => 'Usar Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Abra o Quick Connect no Jellyfin e insira este código.',
|
||||
'auth.quickConnectWaiting' => 'Aguardando aprovação…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsPt {
|
||||
'videoControls.searchSubtitles' => 'Pesquisar legendas',
|
||||
'videoControls.language' => 'Idioma',
|
||||
'videoControls.noSubtitlesFound' => 'Nenhuma legenda encontrada',
|
||||
'videoControls.subtitleDownloaded' => 'Legenda baixada',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Legenda baixada',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'A legenda foi baixada, mas não foi possível selecioná-la',
|
||||
'videoControls.subtitleDownloadFailed' => 'Falha ao baixar legenda',
|
||||
'videoControls.searchLanguages' => 'Pesquisar idiomas...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsPt {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sessão de ${name} expirada',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sessões expiradas em ${count} servidores',
|
||||
'connections.signInAgain' => 'Entrar novamente',
|
||||
'connections.editJellyfinTitle' => 'Editar conexão Jellyfin',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Adicione ou remova URLs de ${serverName}. O Plezy usará a URL acessível com a menor latência.',
|
||||
'discover.title' => 'Descobrir',
|
||||
'discover.noContentAvailable' => 'Nenhum conteúdo disponível',
|
||||
'discover.addMediaToLibraries' => 'Adicione mídias às suas bibliotecas',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsPt {
|
||||
'watchTogether.failedToCreate' => 'Falha ao criar sessão',
|
||||
'watchTogether.failedToJoin' => 'Falha ao entrar na sessão',
|
||||
'watchTogether.sessionCodeCopied' => 'Código da sessão copiado para a área de transferência',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Servidor de retransmissão inacessível. O bloqueio pelo provedor de internet pode impedir o uso do Assistir Juntos.',
|
||||
'watchTogether.reconnectingToHost' => 'Reconectando ao anfitrião...',
|
||||
'watchTogether.currentPlayback' => 'Reprodução atual',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Entrar na reprodução atual',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Voltar ao conteúdo que o anfitrião está assistindo agora',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Falha ao abrir a reprodução atual',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsPt {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Sincronizar apenas as bibliotecas marcadas abaixo.',
|
||||
'services.libraryFilter.libraries' => 'Bibliotecas',
|
||||
'services.libraryFilter.noLibraries' => 'Nenhuma biblioteca disponível',
|
||||
'addServer.addJellyfinTitle' => 'Adicionar servidor Jellyfin',
|
||||
'addServer.serverUrls' => 'URLs do servidor',
|
||||
'addServer.serverUrlsHelper' => 'Várias URLs são permitidas, separadas por vírgulas.',
|
||||
'addServer.findServer' => 'Encontrar servidor',
|
||||
'addServer.searchingLocalServers' => 'Procurando servidores Jellyfin locais...',
|
||||
'addServer.localServers' => 'Servidores Jellyfin locais',
|
||||
'addServer.username' => 'Usuário',
|
||||
'addServer.password' => 'Senha',
|
||||
'addServer.signIn' => 'Entrar',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsPt {
|
||||
'addServer.addPlexTitle' => 'Entrar com Plex',
|
||||
'addServer.pinExpired' => 'O PIN expirou antes de entrar. Tente novamente.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Falha ao registrar a conta: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Insira a URL do seu servidor Jellyfin',
|
||||
'addServer.addConnectionTitle' => 'Adicionar conexão',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Adicionar a ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Entrar com Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Autorize este dispositivo. Servidores compartilhados são adicionados.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Autorize uma conta Plex. Os usuários do Plex Home se tornam perfis.',
|
||||
'addServer.connectToJellyfinCard' => 'Conectar ao Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Insira URL do servidor, usuário e senha.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Entre em um servidor Jellyfin. A conexão será vinculada a ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Pegar emprestado de outro perfil',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Reutilize a conexão de outro perfil. Perfis protegidos por PIN exigem PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$ru extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Ожидание аутентификации...\nВыполните вход в браузере.';
|
||||
@override String get useBrowser => 'Использовать браузер';
|
||||
@override String get or => 'или';
|
||||
@override String get connectToJellyfin => 'Подключиться к Jellyfin';
|
||||
@override String get useQuickConnect => 'Использовать Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Откройте Quick Connect в Jellyfin и введите этот код.';
|
||||
@override String get quickConnectWaiting => 'Ожидание подтверждения…';
|
||||
@@ -920,8 +919,6 @@ class _Translations$connections$ru extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Сессия истекла для ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Сессия истекла для ${count} серверов';
|
||||
@override String get signInAgain => 'Войти снова';
|
||||
@override String get editJellyfinTitle => 'Изменить подключение Jellyfin';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Добавьте или удалите URL для ${serverName}. Plezy будет использовать доступный URL с минимальной задержкой.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1809,12 +1806,9 @@ class _Translations$addServer$ru extends Translations$addServer$en {
|
||||
final TranslationsRu _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Добавить сервер Jellyfin';
|
||||
@override String get serverUrls => 'URL-адреса сервера';
|
||||
@override String get serverUrlsHelper => 'Можно указать несколько URL через запятую.';
|
||||
@override String get findServer => 'Найти сервер';
|
||||
@override String get searchingLocalServers => 'Поиск локальных серверов Jellyfin...';
|
||||
@override String get localServers => 'Локальные серверы Jellyfin';
|
||||
@override String get username => 'Имя пользователя';
|
||||
@override String get password => 'Пароль';
|
||||
@override String get signIn => 'Войти';
|
||||
@@ -1826,15 +1820,11 @@ class _Translations$addServer$ru extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Войти через Plex';
|
||||
@override String get pinExpired => 'Срок действия PIN истёк до входа. Попробуйте снова.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Не удалось зарегистрировать учётную запись: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Введите URL вашего сервера Jellyfin';
|
||||
@override String get addConnectionTitle => 'Добавить подключение';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Добавить в ${name}';
|
||||
@override String get signInWithPlexCard => 'Войти через Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Авторизуйте это устройство. Общие серверы будут добавлены.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Авторизуйте аккаунт Plex. Пользователи Home станут профилями.';
|
||||
@override String get connectToJellyfinCard => 'Подключиться к Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Введите URL сервера, имя пользователя и пароль.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Войдите на сервер Jellyfin. Привязывается к ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Использовать подключение другого профиля';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Повторно используйте подключение другого профиля. Для защищённых профилей потребуется PIN.';
|
||||
}
|
||||
@@ -2214,7 +2204,6 @@ extension on TranslationsRu {
|
||||
'auth.waitingForAuth' => 'Ожидание аутентификации...\nВыполните вход в браузере.',
|
||||
'auth.useBrowser' => 'Использовать браузер',
|
||||
'auth.or' => 'или',
|
||||
'auth.connectToJellyfin' => 'Подключиться к Jellyfin',
|
||||
'auth.useQuickConnect' => 'Использовать Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Откройте Quick Connect в Jellyfin и введите этот код.',
|
||||
'auth.quickConnectWaiting' => 'Ожидание подтверждения…',
|
||||
@@ -2717,9 +2706,9 @@ extension on TranslationsRu {
|
||||
'videoControls.searchSubtitles' => 'Поиск субтитров',
|
||||
'videoControls.language' => 'Язык',
|
||||
'videoControls.noSubtitlesFound' => 'Субтитры не найдены',
|
||||
'videoControls.subtitleDownloaded' => 'Субтитры загружены',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Субтитры загружены',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Субтитры загружены, но их не удалось выбрать',
|
||||
'videoControls.subtitleDownloadFailed' => 'Не удалось загрузить субтитры',
|
||||
'videoControls.searchLanguages' => 'Поиск языков...',
|
||||
@@ -2875,8 +2864,6 @@ extension on TranslationsRu {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Сессия истекла для ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Сессия истекла для ${count} серверов',
|
||||
'connections.signInAgain' => 'Войти снова',
|
||||
'connections.editJellyfinTitle' => 'Изменить подключение Jellyfin',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Добавьте или удалите URL для ${serverName}. Plezy будет использовать доступный URL с минимальной задержкой.',
|
||||
'discover.title' => 'Обзор',
|
||||
'discover.noContentAvailable' => 'Контент недоступен',
|
||||
'discover.addMediaToLibraries' => 'Добавьте медиафайлы в ваши библиотеки',
|
||||
@@ -3231,11 +3218,11 @@ extension on TranslationsRu {
|
||||
'watchTogether.failedToCreate' => 'Не удалось создать сессию',
|
||||
'watchTogether.failedToJoin' => 'Не удалось присоединиться к сессии',
|
||||
'watchTogether.sessionCodeCopied' => 'Код сессии скопирован в буфер обмена',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Сервер ретрансляции недоступен. Блокировка интернет-провайдером может помешать совместному просмотру.',
|
||||
'watchTogether.reconnectingToHost' => 'Повторное подключение к организатору...',
|
||||
'watchTogether.currentPlayback' => 'Текущее воспроизведение',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Присоединиться к текущему воспроизведению',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Вернуться к материалу, который сейчас смотрит организатор',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Не удалось открыть текущее воспроизведение',
|
||||
@@ -3662,12 +3649,9 @@ extension on TranslationsRu {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Синхронизировать только библиотеки, отмеченные ниже.',
|
||||
'services.libraryFilter.libraries' => 'Библиотеки',
|
||||
'services.libraryFilter.noLibraries' => 'Библиотеки недоступны',
|
||||
'addServer.addJellyfinTitle' => 'Добавить сервер Jellyfin',
|
||||
'addServer.serverUrls' => 'URL-адреса сервера',
|
||||
'addServer.serverUrlsHelper' => 'Можно указать несколько URL через запятую.',
|
||||
'addServer.findServer' => 'Найти сервер',
|
||||
'addServer.searchingLocalServers' => 'Поиск локальных серверов Jellyfin...',
|
||||
'addServer.localServers' => 'Локальные серверы Jellyfin',
|
||||
'addServer.username' => 'Имя пользователя',
|
||||
'addServer.password' => 'Пароль',
|
||||
'addServer.signIn' => 'Войти',
|
||||
@@ -3679,15 +3663,11 @@ extension on TranslationsRu {
|
||||
'addServer.addPlexTitle' => 'Войти через Plex',
|
||||
'addServer.pinExpired' => 'Срок действия PIN истёк до входа. Попробуйте снова.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Не удалось зарегистрировать учётную запись: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Введите URL вашего сервера Jellyfin',
|
||||
'addServer.addConnectionTitle' => 'Добавить подключение',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Добавить в ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Войти через Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Авторизуйте это устройство. Общие серверы будут добавлены.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Авторизуйте аккаунт Plex. Пользователи Home станут профилями.',
|
||||
'addServer.connectToJellyfinCard' => 'Подключиться к Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Введите URL сервера, имя пользователя и пароль.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Войдите на сервер Jellyfin. Привязывается к ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Использовать подключение другого профиля',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Повторно используйте подключение другого профиля. Для защищённых профилей потребуется PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$sv extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Väntar på autentisering...\nLogga in från din webbläsare.';
|
||||
@override String get useBrowser => 'Använd webbläsare';
|
||||
@override String get or => 'eller';
|
||||
@override String get connectToJellyfin => 'Anslut till Jellyfin';
|
||||
@override String get useQuickConnect => 'Använd Quick Connect';
|
||||
@override String get quickConnectInstructions => 'Öppna Quick Connect i Jellyfin och ange den här koden.';
|
||||
@override String get quickConnectWaiting => 'Väntar på godkännande…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$sv extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sessionen har gått ut för ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sessionen har gått ut för ${count} servrar';
|
||||
@override String get signInAgain => 'Logga in igen';
|
||||
@override String get editJellyfinTitle => 'Redigera Jellyfin-anslutning';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Lägg till eller ta bort URL:er för ${serverName}. Plezy använder den nåbara URL som har lägst latens.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1803,12 +1800,9 @@ class _Translations$addServer$sv extends Translations$addServer$en {
|
||||
final TranslationsSv _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Lägg till Jellyfin-server';
|
||||
@override String get serverUrls => 'Server-URL:er';
|
||||
@override String get serverUrlsHelper => 'Du kan ange flera URL:er avgränsade med kommatecken.';
|
||||
@override String get findServer => 'Hitta server';
|
||||
@override String get searchingLocalServers => 'Söker efter lokala Jellyfin-servrar...';
|
||||
@override String get localServers => 'Lokala Jellyfin-servrar';
|
||||
@override String get username => 'Användarnamn';
|
||||
@override String get password => 'Lösenord';
|
||||
@override String get signIn => 'Logga in';
|
||||
@@ -1820,15 +1814,11 @@ class _Translations$addServer$sv extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Logga in med Plex';
|
||||
@override String get pinExpired => 'PIN-koden gick ut innan inloggning. Försök igen.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Kunde inte registrera kontot: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Ange URL till din Jellyfin-server';
|
||||
@override String get addConnectionTitle => 'Lägg till anslutning';
|
||||
@override String addConnectionTitleScoped({required Object name}) => 'Lägg till i ${name}';
|
||||
@override String get signInWithPlexCard => 'Logga in med Plex';
|
||||
@override String get signInWithPlexCardSubtitle => 'Auktorisera den här enheten. Delade servrar läggs till.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Auktorisera ett Plex-konto. Home-användare blir profiler.';
|
||||
@override String get connectToJellyfinCard => 'Anslut till Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Ange server-URL, användarnamn och lösenord.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Logga in på en Jellyfin-server. Kopplas till ${name}.';
|
||||
@override String get borrowFromAnotherProfile => 'Låna från en annan profil';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Återanvänd en annan profils anslutning. PIN-skyddade profiler kräver en PIN.';
|
||||
}
|
||||
@@ -2208,7 +2198,6 @@ extension on TranslationsSv {
|
||||
'auth.waitingForAuth' => 'Väntar på autentisering...\nLogga in från din webbläsare.',
|
||||
'auth.useBrowser' => 'Använd webbläsare',
|
||||
'auth.or' => 'eller',
|
||||
'auth.connectToJellyfin' => 'Anslut till Jellyfin',
|
||||
'auth.useQuickConnect' => 'Använd Quick Connect',
|
||||
'auth.quickConnectInstructions' => 'Öppna Quick Connect i Jellyfin och ange den här koden.',
|
||||
'auth.quickConnectWaiting' => 'Väntar på godkännande…',
|
||||
@@ -2711,9 +2700,9 @@ extension on TranslationsSv {
|
||||
'videoControls.searchSubtitles' => 'Sök undertexter',
|
||||
'videoControls.language' => 'Språk',
|
||||
'videoControls.noSubtitlesFound' => 'Inga undertexter hittades',
|
||||
'videoControls.subtitleDownloaded' => 'Undertexten har laddats ned',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => 'Undertexten har laddats ned',
|
||||
'videoControls.subtitleDownloadedNotApplied' => 'Undertexten laddades ned men kunde inte väljas',
|
||||
'videoControls.subtitleDownloadFailed' => 'Det gick inte att ladda ned undertexten',
|
||||
'videoControls.searchLanguages' => 'Sök språk...',
|
||||
@@ -2869,8 +2858,6 @@ extension on TranslationsSv {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sessionen har gått ut för ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sessionen har gått ut för ${count} servrar',
|
||||
'connections.signInAgain' => 'Logga in igen',
|
||||
'connections.editJellyfinTitle' => 'Redigera Jellyfin-anslutning',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Lägg till eller ta bort URL:er för ${serverName}. Plezy använder den nåbara URL som har lägst latens.',
|
||||
'discover.title' => 'Upptäck',
|
||||
'discover.noContentAvailable' => 'Inget innehåll tillgängligt',
|
||||
'discover.addMediaToLibraries' => 'Lägg till medieinnehåll i dina bibliotek',
|
||||
@@ -3225,11 +3212,11 @@ extension on TranslationsSv {
|
||||
'watchTogether.failedToCreate' => 'Det gick inte att skapa sessionen',
|
||||
'watchTogether.failedToJoin' => 'Det gick inte att gå med i sessionen',
|
||||
'watchTogether.sessionCodeCopied' => 'Sessionskoden har kopierats till urklipp',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => 'Reläservern kan inte nås. Din internetleverantör kan blockera Titta tillsammans.',
|
||||
'watchTogether.reconnectingToHost' => 'Återansluter till värden...',
|
||||
'watchTogether.currentPlayback' => 'Aktuell uppspelning',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => 'Gå med i aktuell uppspelning',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Hoppa tillbaka till det värden tittar på just nu',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Kunde inte öppna aktuell uppspelning',
|
||||
@@ -3656,12 +3643,9 @@ extension on TranslationsSv {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Synkronisera endast de bibliotek som markeras nedan.',
|
||||
'services.libraryFilter.libraries' => 'Bibliotek',
|
||||
'services.libraryFilter.noLibraries' => 'Inga bibliotek tillgängliga',
|
||||
'addServer.addJellyfinTitle' => 'Lägg till Jellyfin-server',
|
||||
'addServer.serverUrls' => 'Server-URL:er',
|
||||
'addServer.serverUrlsHelper' => 'Du kan ange flera URL:er avgränsade med kommatecken.',
|
||||
'addServer.findServer' => 'Hitta server',
|
||||
'addServer.searchingLocalServers' => 'Söker efter lokala Jellyfin-servrar...',
|
||||
'addServer.localServers' => 'Lokala Jellyfin-servrar',
|
||||
'addServer.username' => 'Användarnamn',
|
||||
'addServer.password' => 'Lösenord',
|
||||
'addServer.signIn' => 'Logga in',
|
||||
@@ -3673,15 +3657,11 @@ extension on TranslationsSv {
|
||||
'addServer.addPlexTitle' => 'Logga in med Plex',
|
||||
'addServer.pinExpired' => 'PIN-koden gick ut innan inloggning. Försök igen.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Kunde inte registrera kontot: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Ange URL till din Jellyfin-server',
|
||||
'addServer.addConnectionTitle' => 'Lägg till anslutning',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Lägg till i ${name}',
|
||||
'addServer.signInWithPlexCard' => 'Logga in med Plex',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Auktorisera den här enheten. Delade servrar läggs till.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Auktorisera ett Plex-konto. Home-användare blir profiler.',
|
||||
'addServer.connectToJellyfinCard' => 'Anslut till Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Ange server-URL, användarnamn och lösenord.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Logga in på en Jellyfin-server. Kopplas till ${name}.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Låna från en annan profil',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Återanvänd en annan profils anslutning. PIN-skyddade profiler kräver en PIN.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$tr extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Doğrulama bekleniyor...\nTarayıcınızdan giriş yapın.';
|
||||
@override String get useBrowser => 'Tarayıcı kullan';
|
||||
@override String get or => 'veya';
|
||||
@override String get connectToJellyfin => 'Jellyfin\'e Bağlan';
|
||||
@override String get useQuickConnect => 'Hızlı Bağlantıyı Kullan';
|
||||
@override String get quickConnectInstructions => 'Jellyfin\'de Hızlı Bağlantı\'yı açın ve bu kodu girin.';
|
||||
@override String get quickConnectWaiting => 'Onay bekleniyor…';
|
||||
@@ -922,8 +921,6 @@ class _Translations$connections$tr extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => '${name} için oturum süresi doldu';
|
||||
@override String sessionExpiredMany({required Object count}) => '${count} sunucu için oturum süresi doldu';
|
||||
@override String get signInAgain => 'Tekrar giriş yap';
|
||||
@override String get editJellyfinTitle => 'Jellyfin bağlantısını düzenle';
|
||||
@override String editJellyfinIntro({required Object serverName}) => '${serverName} için URL\'ler ekleyin veya kaldırın. Plezy, en düşük gecikmeye sahip ulaşılabilir URL\'yi kullanacaktır.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1814,12 +1811,9 @@ class _Translations$addServer$tr extends Translations$addServer$en {
|
||||
final TranslationsTr _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Jellyfin sunucusu ekle';
|
||||
@override String get serverUrls => 'Sunucu URL\'leri';
|
||||
@override String get serverUrlsHelper => 'Virgülle ayrılmış birden fazla URL\'ye izin verilir.';
|
||||
@override String get findServer => 'Sunucu bul';
|
||||
@override String get searchingLocalServers => 'Yerel Jellyfin sunucuları aranıyor...';
|
||||
@override String get localServers => 'Yerel Jellyfin sunucuları';
|
||||
@override String get username => 'Kullanıcı adı';
|
||||
@override String get password => 'Şifre';
|
||||
@override String get signIn => 'Giriş Yap';
|
||||
@@ -1831,15 +1825,11 @@ class _Translations$addServer$tr extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Plex ile Giriş Yap';
|
||||
@override String get pinExpired => 'Giriş yapmadan önce PIN süresi doldu. Lütfen tekrar deneyin.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Hesap kaydı başarısız oldu: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Jellyfin sunucu URL\'nizi girin';
|
||||
@override String get addConnectionTitle => 'Bağlantı ekle';
|
||||
@override String addConnectionTitleScoped({required Object name}) => '${name} profiline ekle';
|
||||
@override String get signInWithPlexCard => 'Plex ile Giriş Yap';
|
||||
@override String get signInWithPlexCardSubtitle => 'Bu cihazı yetkilendirin. Paylaşılan sunucular eklenir.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Bir Plex hesabını yetkilendirin. Ev kullanıcıları profile dönüşür.';
|
||||
@override String get connectToJellyfinCard => 'Jellyfin\'e Bağlan';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Sunucu URL\'nizi, kullanıcı adınızı ve şifrenizi girin.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Bir Jellyfin sunucusuna giriş yapın. ${name} profiline bağlanır.';
|
||||
@override String get borrowFromAnotherProfile => 'Başka bir profilden ödünç al';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Başka bir profilin bağlantısını yeniden kullanın. PIN korumalı profiller bir PIN gerektirir.';
|
||||
}
|
||||
@@ -2219,7 +2209,6 @@ extension on TranslationsTr {
|
||||
'auth.waitingForAuth' => 'Doğrulama bekleniyor...\nTarayıcınızdan giriş yapın.',
|
||||
'auth.useBrowser' => 'Tarayıcı kullan',
|
||||
'auth.or' => 'veya',
|
||||
'auth.connectToJellyfin' => 'Jellyfin\'e Bağlan',
|
||||
'auth.useQuickConnect' => 'Hızlı Bağlantıyı Kullan',
|
||||
'auth.quickConnectInstructions' => 'Jellyfin\'de Hızlı Bağlantı\'yı açın ve bu kodu girin.',
|
||||
'auth.quickConnectWaiting' => 'Onay bekleniyor…',
|
||||
@@ -2722,9 +2711,9 @@ extension on TranslationsTr {
|
||||
'videoControls.noChaptersAvailable' => 'Kısım bulunmuyor',
|
||||
'videoControls.queue' => 'Kuyruk',
|
||||
'videoControls.noQueueItems' => 'Kuyrukta öge yok',
|
||||
'videoControls.searchSubtitles' => 'Altyazı Ara',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.searchSubtitles' => 'Altyazı Ara',
|
||||
'videoControls.language' => 'Dil',
|
||||
'videoControls.noSubtitlesFound' => 'Altyazı bulunamadı',
|
||||
'videoControls.subtitleDownloaded' => 'Altyazı indirildi',
|
||||
@@ -2884,8 +2873,6 @@ extension on TranslationsTr {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => '${name} için oturum süresi doldu',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => '${count} sunucu için oturum süresi doldu',
|
||||
'connections.signInAgain' => 'Tekrar giriş yap',
|
||||
'connections.editJellyfinTitle' => 'Jellyfin bağlantısını düzenle',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName} için URL\'ler ekleyin veya kaldırın. Plezy, en düşük gecikmeye sahip ulaşılabilir URL\'yi kullanacaktır.',
|
||||
'discover.title' => 'Keşfet',
|
||||
'discover.noContentAvailable' => 'İçerik bulunmuyor',
|
||||
'discover.addMediaToLibraries' => 'Kitaplıklarınıza biraz medya ekleyin',
|
||||
@@ -3236,11 +3223,11 @@ extension on TranslationsTr {
|
||||
'watchTogether.enterCodeHint' => '5 karakterlik kodu girin',
|
||||
'watchTogether.pasteFromClipboard' => 'Panodan yapıştır',
|
||||
'watchTogether.pleaseEnterCode' => 'Lütfen bir oturum kodu girin',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.codeMustBe5Chars' => 'Oturum kodu 5 karakter olmalıdır',
|
||||
'watchTogether.joinInstructions' => 'Katılmak için kurucunun oturum kodunu girin.',
|
||||
'watchTogether.failedToCreate' => 'Oturum oluşturulamadı',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.failedToJoin' => 'Oturuma katılınamadı',
|
||||
'watchTogether.sessionCodeCopied' => 'Oturum kodu panoya kopyalandı',
|
||||
'watchTogether.relayUnreachable' => 'Aktarıcı sunucusuna ulaşılamıyor. İSS engellemesi Birlikte İzle\'yi önleyebilir.',
|
||||
@@ -3678,12 +3665,9 @@ extension on TranslationsTr {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Yalnızca aşağıda işaretlenen kitaplıkları eşitle.',
|
||||
'services.libraryFilter.libraries' => 'Kitaplıklar',
|
||||
'services.libraryFilter.noLibraries' => 'Kitaplık bulunmuyor',
|
||||
'addServer.addJellyfinTitle' => 'Jellyfin sunucusu ekle',
|
||||
'addServer.serverUrls' => 'Sunucu URL\'leri',
|
||||
'addServer.serverUrlsHelper' => 'Virgülle ayrılmış birden fazla URL\'ye izin verilir.',
|
||||
'addServer.findServer' => 'Sunucu bul',
|
||||
'addServer.searchingLocalServers' => 'Yerel Jellyfin sunucuları aranıyor...',
|
||||
'addServer.localServers' => 'Yerel Jellyfin sunucuları',
|
||||
'addServer.username' => 'Kullanıcı adı',
|
||||
'addServer.password' => 'Şifre',
|
||||
'addServer.signIn' => 'Giriş Yap',
|
||||
@@ -3695,15 +3679,11 @@ extension on TranslationsTr {
|
||||
'addServer.addPlexTitle' => 'Plex ile Giriş Yap',
|
||||
'addServer.pinExpired' => 'Giriş yapmadan önce PIN süresi doldu. Lütfen tekrar deneyin.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Hesap kaydı başarısız oldu: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Jellyfin sunucu URL\'nizi girin',
|
||||
'addServer.addConnectionTitle' => 'Bağlantı ekle',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name} profiline ekle',
|
||||
'addServer.signInWithPlexCard' => 'Plex ile Giriş Yap',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Bu cihazı yetkilendirin. Paylaşılan sunucular eklenir.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Bir Plex hesabını yetkilendirin. Ev kullanıcıları profile dönüşür.',
|
||||
'addServer.connectToJellyfinCard' => 'Jellyfin\'e Bağlan',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Sunucu URL\'nizi, kullanıcı adınızı ve şifrenizi girin.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Bir Jellyfin sunucusuna giriş yapın. ${name} profiline bağlanır.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Başka bir profilden ödünç al',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Başka bir profilin bağlantısını yeniden kullanın. PIN korumalı profiller bir PIN gerektirir.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class _Translations$auth$uz extends Translations$auth$en {
|
||||
@override String get waitingForAuth => 'Tasdiqlanish kutilmoqda...\nBrauzeringizdan kiring.';
|
||||
@override String get useBrowser => 'Brauzerdan foydalanish';
|
||||
@override String get or => 'yoki';
|
||||
@override String get connectToJellyfin => 'Jellyfin-ga ulanish';
|
||||
@override String get useQuickConnect => 'Tezkor ulanishdan foydalanish';
|
||||
@override String get quickConnectInstructions => 'Jellyfin-da Tezkor ulanishni oching va ushbu kodni kiriting.';
|
||||
@override String get quickConnectWaiting => 'Tasdiq kutilmoqda…';
|
||||
@@ -922,8 +921,6 @@ class _Translations$connections$uz extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => '${name} uchun seans vaqti tugadi';
|
||||
@override String sessionExpiredMany({required Object count}) => '${count} server uchun seans vaqti tugadi';
|
||||
@override String get signInAgain => 'Qaytadan kirish';
|
||||
@override String get editJellyfinTitle => 'Jellyfin ulanishini tahrirlash';
|
||||
@override String editJellyfinIntro({required Object serverName}) => '${serverName} uchun URL manzilini qoʻshing yoki oʻchiring.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1814,12 +1811,9 @@ class _Translations$addServer$uz extends Translations$addServer$en {
|
||||
final TranslationsUz _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Jellyfin serverini qoʻshish';
|
||||
@override String get serverUrls => 'Server URL-lari';
|
||||
@override String get serverUrlsHelper => 'Vergul bilan ajratilgan bir nechta URL manziliga ruxsat beriladi.';
|
||||
@override String get findServer => 'Serverni topish';
|
||||
@override String get searchingLocalServers => 'Mahalliy Jellyfin serverlari qidirilmoqda...';
|
||||
@override String get localServers => 'Mahalliy Jellyfin serverlari';
|
||||
@override String get username => 'Foydalanuvchi nomi';
|
||||
@override String get password => 'Parol';
|
||||
@override String get signIn => 'Kirish';
|
||||
@@ -1831,15 +1825,11 @@ class _Translations$addServer$uz extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => 'Plex orqali kirish';
|
||||
@override String get pinExpired => 'PIN kod vaqti tugadi.';
|
||||
@override String failedToRegisterAccount({required Object error}) => 'Hisobni roʻyxatdan oʻtkazishda xatolik: ${error}';
|
||||
@override String get enterJellyfinUrlError => 'Jellyfin server URL-ini kiriting';
|
||||
@override String get addConnectionTitle => 'Ulanish qoʻshish';
|
||||
@override String addConnectionTitleScoped({required Object name}) => '${name} profiliga qoʻshish';
|
||||
@override String get signInWithPlexCard => 'Plex orqali kirish';
|
||||
@override String get signInWithPlexCardSubtitle => 'Ushbu qurilmani avtorizatsiya qiling.';
|
||||
@override String get signInWithPlexCardSubtitleScoped => 'Plex hisobini avtorizatsiya qiling.';
|
||||
@override String get connectToJellyfinCard => 'Jellyfin-ga ulanish';
|
||||
@override String get connectToJellyfinCardSubtitle => 'Server URL, foydalanuvchi nomi va parolingizni kiriting.';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfin serveriga kiring. ${name} profiliga ulanmoqda.';
|
||||
@override String get borrowFromAnotherProfile => 'Boshqa profildan olish';
|
||||
@override String get borrowFromAnotherProfileSubtitle => 'Boshqa profilning ulanishidan qayta foydalaning.';
|
||||
}
|
||||
@@ -2219,7 +2209,6 @@ extension on TranslationsUz {
|
||||
'auth.waitingForAuth' => 'Tasdiqlanish kutilmoqda...\nBrauzeringizdan kiring.',
|
||||
'auth.useBrowser' => 'Brauzerdan foydalanish',
|
||||
'auth.or' => 'yoki',
|
||||
'auth.connectToJellyfin' => 'Jellyfin-ga ulanish',
|
||||
'auth.useQuickConnect' => 'Tezkor ulanishdan foydalanish',
|
||||
'auth.quickConnectInstructions' => 'Jellyfin-da Tezkor ulanishni oching va ushbu kodni kiriting.',
|
||||
'auth.quickConnectWaiting' => 'Tasdiq kutilmoqda…',
|
||||
@@ -2722,9 +2711,9 @@ extension on TranslationsUz {
|
||||
'videoControls.noChaptersAvailable' => 'Boʻlimlar mavjud emas',
|
||||
'videoControls.queue' => 'Navbat',
|
||||
'videoControls.noQueueItems' => 'Navbatda elementlar yoʻq',
|
||||
'videoControls.searchSubtitles' => 'Subtitr qidirish',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.searchSubtitles' => 'Subtitr qidirish',
|
||||
'videoControls.language' => 'Til',
|
||||
'videoControls.noSubtitlesFound' => 'Subtitr topilmadi',
|
||||
'videoControls.subtitleDownloaded' => 'Subtitr yuklab olindi',
|
||||
@@ -2884,8 +2873,6 @@ extension on TranslationsUz {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => '${name} uchun seans vaqti tugadi',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => '${count} server uchun seans vaqti tugadi',
|
||||
'connections.signInAgain' => 'Qaytadan kirish',
|
||||
'connections.editJellyfinTitle' => 'Jellyfin ulanishini tahrirlash',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName} uchun URL manzilini qoʻshing yoki oʻchiring.',
|
||||
'discover.title' => 'Kashf qilish',
|
||||
'discover.noContentAvailable' => 'Kontent mavjud emas',
|
||||
'discover.addMediaToLibraries' => 'Kutubxonalaringizga media qoʻshing',
|
||||
@@ -3236,11 +3223,11 @@ extension on TranslationsUz {
|
||||
'watchTogether.enterCodeHint' => '5 xonali kodni kiriting',
|
||||
'watchTogether.pasteFromClipboard' => 'Xotiradan joylash',
|
||||
'watchTogether.pleaseEnterCode' => 'Seans kodini kiriting',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.codeMustBe5Chars' => 'Seans kodi 5 ta belgidan iborat boʻlishi kerak',
|
||||
'watchTogether.joinInstructions' => 'Tashkilotchining seans kodini kiriting.',
|
||||
'watchTogether.failedToCreate' => 'Seansni yaratib boʻlmadi',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.failedToJoin' => 'Seansga qoʻshilib boʻlmadi',
|
||||
'watchTogether.sessionCodeCopied' => 'Seans kodi nusxalandi',
|
||||
'watchTogether.relayUnreachable' => 'Rele serveriga ulanib boʻlmadi.',
|
||||
@@ -3678,12 +3665,9 @@ extension on TranslationsUz {
|
||||
'services.libraryFilter.modeHintWhitelist' => 'Faqat quyida tanlangan kutubxonalarni sinxronlash.',
|
||||
'services.libraryFilter.libraries' => 'Kutubxonalar',
|
||||
'services.libraryFilter.noLibraries' => 'Kutubxonalar yoʻq',
|
||||
'addServer.addJellyfinTitle' => 'Jellyfin serverini qoʻshish',
|
||||
'addServer.serverUrls' => 'Server URL-lari',
|
||||
'addServer.serverUrlsHelper' => 'Vergul bilan ajratilgan bir nechta URL manziliga ruxsat beriladi.',
|
||||
'addServer.findServer' => 'Serverni topish',
|
||||
'addServer.searchingLocalServers' => 'Mahalliy Jellyfin serverlari qidirilmoqda...',
|
||||
'addServer.localServers' => 'Mahalliy Jellyfin serverlari',
|
||||
'addServer.username' => 'Foydalanuvchi nomi',
|
||||
'addServer.password' => 'Parol',
|
||||
'addServer.signIn' => 'Kirish',
|
||||
@@ -3695,15 +3679,11 @@ extension on TranslationsUz {
|
||||
'addServer.addPlexTitle' => 'Plex orqali kirish',
|
||||
'addServer.pinExpired' => 'PIN kod vaqti tugadi.',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => 'Hisobni roʻyxatdan oʻtkazishda xatolik: ${error}',
|
||||
'addServer.enterJellyfinUrlError' => 'Jellyfin server URL-ini kiriting',
|
||||
'addServer.addConnectionTitle' => 'Ulanish qoʻshish',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name} profiliga qoʻshish',
|
||||
'addServer.signInWithPlexCard' => 'Plex orqali kirish',
|
||||
'addServer.signInWithPlexCardSubtitle' => 'Ushbu qurilmani avtorizatsiya qiling.',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => 'Plex hisobini avtorizatsiya qiling.',
|
||||
'addServer.connectToJellyfinCard' => 'Jellyfin-ga ulanish',
|
||||
'addServer.connectToJellyfinCardSubtitle' => 'Server URL, foydalanuvchi nomi va parolingizni kiriting.',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfin serveriga kiring. ${name} profiliga ulanmoqda.',
|
||||
'addServer.borrowFromAnotherProfile' => 'Boshqa profildan olish',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => 'Boshqa profilning ulanishidan qayta foydalaning.',
|
||||
_ => null,
|
||||
|
||||
@@ -115,7 +115,6 @@ class Translations$auth$zh extends Translations$auth$en {
|
||||
@override String get waitingForAuth => '正在等待身份验证…\n请在浏览器中登录。';
|
||||
@override String get useBrowser => '使用浏览器';
|
||||
@override String get or => '或';
|
||||
@override String get connectToJellyfin => '连接到 Jellyfin';
|
||||
@override String get useQuickConnect => '使用 Quick Connect';
|
||||
@override String get quickConnectInstructions => '在 Jellyfin 中打开 Quick Connect 并输入此代码。';
|
||||
@override String get quickConnectWaiting => '等待批准…';
|
||||
@@ -917,8 +916,6 @@ class Translations$connections$zh extends Translations$connections$en {
|
||||
@override String sessionExpiredOne({required Object name}) => '${name} 的会话已过期';
|
||||
@override String sessionExpiredMany({required Object count}) => '${count} 个服务器的会话已过期';
|
||||
@override String get signInAgain => '重新登录';
|
||||
@override String get editJellyfinTitle => '编辑 Jellyfin 连接';
|
||||
@override String editJellyfinIntro({required Object serverName}) => '添加或移除 ${serverName} 的 URL。Plezy 会使用可访问且延迟最低的地址。';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1800,12 +1797,9 @@ class Translations$addServer$zh extends Translations$addServer$en {
|
||||
final TranslationsZh _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => '添加 Jellyfin 服务器';
|
||||
@override String get serverUrls => '服务器 URL';
|
||||
@override String get serverUrlsHelper => '可输入多个 URL,并用逗号分隔。';
|
||||
@override String get findServer => '查找服务器';
|
||||
@override String get searchingLocalServers => '正在查找本地 Jellyfin 服务器…';
|
||||
@override String get localServers => '本地 Jellyfin 服务器';
|
||||
@override String get username => '用户名';
|
||||
@override String get password => '密码';
|
||||
@override String get signIn => '登录';
|
||||
@@ -1817,15 +1811,11 @@ class Translations$addServer$zh extends Translations$addServer$en {
|
||||
@override String get addPlexTitle => '使用 Plex 登录';
|
||||
@override String get pinExpired => 'PIN 在登录前已过期。请重试。';
|
||||
@override String failedToRegisterAccount({required Object error}) => '注册账户失败:${error}';
|
||||
@override String get enterJellyfinUrlError => '请输入 Jellyfin 服务器 URL';
|
||||
@override String get addConnectionTitle => '添加连接';
|
||||
@override String addConnectionTitleScoped({required Object name}) => '添加到 ${name}';
|
||||
@override String get signInWithPlexCard => '使用 Plex 登录';
|
||||
@override String get signInWithPlexCardSubtitle => '授权此设备。共享服务器会被添加。';
|
||||
@override String get signInWithPlexCardSubtitleScoped => '授权一个 Plex 账户。Plex Home 用户将成为 Plezy 用户资料。';
|
||||
@override String get connectToJellyfinCard => '连接到 Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => '输入服务器 URL、用户名和密码。';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => '登录到 Jellyfin 服务器。绑定到 ${name}。';
|
||||
@override String get borrowFromAnotherProfile => '使用其他用户资料的连接';
|
||||
@override String get borrowFromAnotherProfileSubtitle => '复用另一个用户资料的连接。受 PIN 保护的用户资料需要输入 PIN。';
|
||||
}
|
||||
@@ -2205,7 +2195,6 @@ extension on TranslationsZh {
|
||||
'auth.waitingForAuth' => '正在等待身份验证…\n请在浏览器中登录。',
|
||||
'auth.useBrowser' => '使用浏览器',
|
||||
'auth.or' => '或',
|
||||
'auth.connectToJellyfin' => '连接到 Jellyfin',
|
||||
'auth.useQuickConnect' => '使用 Quick Connect',
|
||||
'auth.quickConnectInstructions' => '在 Jellyfin 中打开 Quick Connect 并输入此代码。',
|
||||
'auth.quickConnectWaiting' => '等待批准…',
|
||||
@@ -2708,9 +2697,9 @@ extension on TranslationsZh {
|
||||
'videoControls.searchSubtitles' => '搜索字幕',
|
||||
'videoControls.language' => '语言',
|
||||
'videoControls.noSubtitlesFound' => '未找到字幕',
|
||||
'videoControls.subtitleDownloaded' => '字幕已下载',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => '字幕已下载',
|
||||
'videoControls.subtitleDownloadedNotApplied' => '字幕已下载,但无法选择',
|
||||
'videoControls.subtitleDownloadFailed' => '字幕下载失败',
|
||||
'videoControls.searchLanguages' => '搜索语言…',
|
||||
@@ -2866,8 +2855,6 @@ extension on TranslationsZh {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => '${name} 的会话已过期',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => '${count} 个服务器的会话已过期',
|
||||
'connections.signInAgain' => '重新登录',
|
||||
'connections.editJellyfinTitle' => '编辑 Jellyfin 连接',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => '添加或移除 ${serverName} 的 URL。Plezy 会使用可访问且延迟最低的地址。',
|
||||
'discover.title' => '发现',
|
||||
'discover.noContentAvailable' => '没有可用内容',
|
||||
'discover.addMediaToLibraries' => '请向你的媒体库添加一些媒体',
|
||||
@@ -3222,11 +3209,11 @@ extension on TranslationsZh {
|
||||
'watchTogether.failedToCreate' => '创建会话失败',
|
||||
'watchTogether.failedToJoin' => '加入会话失败',
|
||||
'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => '无法访问中继服务器。网络运营商的屏蔽可能导致“一起看”不可用。',
|
||||
'watchTogether.reconnectingToHost' => '正在重新连接到主持人…',
|
||||
'watchTogether.currentPlayback' => '当前播放',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => '加入当前播放',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => '加入主持人当前正在观看的内容',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => '无法打开当前播放',
|
||||
@@ -3653,12 +3640,9 @@ extension on TranslationsZh {
|
||||
'services.libraryFilter.modeHintWhitelist' => '仅同步下方勾选的媒体库。',
|
||||
'services.libraryFilter.libraries' => '媒体库',
|
||||
'services.libraryFilter.noLibraries' => '没有可用的媒体库',
|
||||
'addServer.addJellyfinTitle' => '添加 Jellyfin 服务器',
|
||||
'addServer.serverUrls' => '服务器 URL',
|
||||
'addServer.serverUrlsHelper' => '可输入多个 URL,并用逗号分隔。',
|
||||
'addServer.findServer' => '查找服务器',
|
||||
'addServer.searchingLocalServers' => '正在查找本地 Jellyfin 服务器…',
|
||||
'addServer.localServers' => '本地 Jellyfin 服务器',
|
||||
'addServer.username' => '用户名',
|
||||
'addServer.password' => '密码',
|
||||
'addServer.signIn' => '登录',
|
||||
@@ -3670,15 +3654,11 @@ extension on TranslationsZh {
|
||||
'addServer.addPlexTitle' => '使用 Plex 登录',
|
||||
'addServer.pinExpired' => 'PIN 在登录前已过期。请重试。',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => '注册账户失败:${error}',
|
||||
'addServer.enterJellyfinUrlError' => '请输入 Jellyfin 服务器 URL',
|
||||
'addServer.addConnectionTitle' => '添加连接',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => '添加到 ${name}',
|
||||
'addServer.signInWithPlexCard' => '使用 Plex 登录',
|
||||
'addServer.signInWithPlexCardSubtitle' => '授权此设备。共享服务器会被添加。',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => '授权一个 Plex 账户。Plex Home 用户将成为 Plezy 用户资料。',
|
||||
'addServer.connectToJellyfinCard' => '连接到 Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => '输入服务器 URL、用户名和密码。',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => '登录到 Jellyfin 服务器。绑定到 ${name}。',
|
||||
'addServer.borrowFromAnotherProfile' => '使用其他用户资料的连接',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => '复用另一个用户资料的连接。受 PIN 保护的用户资料需要输入 PIN。',
|
||||
_ => null,
|
||||
|
||||
@@ -116,7 +116,6 @@ class _Translations$auth$zh_Hant extends Translations$auth$zh {
|
||||
@override String get waitingForAuth => '正在等待驗證…\n請在瀏覽器中登入。';
|
||||
@override String get useBrowser => '使用瀏覽器';
|
||||
@override String get or => '或';
|
||||
@override String get connectToJellyfin => '連線至 Jellyfin';
|
||||
@override String get useQuickConnect => '使用快速連線(Quick Connect)';
|
||||
@override String get quickConnectInstructions => '在 Jellyfin 中開啟快速連線並輸入此代碼。';
|
||||
@override String get quickConnectWaiting => '等待核准…';
|
||||
@@ -918,8 +917,6 @@ class _Translations$connections$zh_Hant extends Translations$connections$zh {
|
||||
@override String sessionExpiredOne({required Object name}) => '${name} 的工作階段已過期';
|
||||
@override String sessionExpiredMany({required Object count}) => '${count} 個伺服器的工作階段已過期';
|
||||
@override String get signInAgain => '重新登入';
|
||||
@override String get editJellyfinTitle => '編輯 Jellyfin 連線';
|
||||
@override String editJellyfinIntro({required Object serverName}) => '新增或移除 ${serverName} 的 URL。Plezy 會自動選擇可連線且延遲最低的網址。';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1801,12 +1798,9 @@ class _Translations$addServer$zh_Hant extends Translations$addServer$zh {
|
||||
final TranslationsZhHant _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => '新增 Jellyfin 伺服器';
|
||||
@override String get serverUrls => '伺服器 URL';
|
||||
@override String get serverUrlsHelper => '可輸入多個連線網址,以逗號區隔。';
|
||||
@override String get findServer => '尋找伺服器';
|
||||
@override String get searchingLocalServers => '正在尋找本地 Jellyfin 伺服器…';
|
||||
@override String get localServers => '本地 Jellyfin 伺服器';
|
||||
@override String get username => '使用者名稱';
|
||||
@override String get password => '密碼';
|
||||
@override String get signIn => '登入';
|
||||
@@ -1818,15 +1812,11 @@ class _Translations$addServer$zh_Hant extends Translations$addServer$zh {
|
||||
@override String get addPlexTitle => '使用 Plex 登入';
|
||||
@override String get pinExpired => 'PIN 碼在登入前已過期。請重試。';
|
||||
@override String failedToRegisterAccount({required Object error}) => '註冊帳戶失敗:${error}';
|
||||
@override String get enterJellyfinUrlError => '請輸入您的 Jellyfin 伺服器 URL';
|
||||
@override String get addConnectionTitle => '新增連線';
|
||||
@override String addConnectionTitleScoped({required Object name}) => '新增連線至 ${name}';
|
||||
@override String get signInWithPlexCard => '使用 Plex 登入';
|
||||
@override String get signInWithPlexCardSubtitle => '授權此裝置。將會新增共享的伺服器連線。';
|
||||
@override String get signInWithPlexCardSubtitleScoped => '授權 Plex 帳戶。Home 使用者會建立為個別的使用者設定檔。';
|
||||
@override String get connectToJellyfinCard => '連線至 Jellyfin';
|
||||
@override String get connectToJellyfinCardSubtitle => '輸入伺服器 URL、使用者名稱與密碼。';
|
||||
@override String connectToJellyfinCardSubtitleScoped({required Object name}) => '登入 Jellyfin 伺服器,並綁定至 ${name} 使用者設定檔。';
|
||||
@override String get borrowFromAnotherProfile => '從另一個使用者設定檔共用';
|
||||
@override String get borrowFromAnotherProfileSubtitle => '重複使用另一個使用者設定檔的連線資訊。受 PIN 碼保護的使用者設定檔需輸入 PIN 碼。';
|
||||
}
|
||||
@@ -2206,7 +2196,6 @@ extension on TranslationsZhHant {
|
||||
'auth.waitingForAuth' => '正在等待驗證…\n請在瀏覽器中登入。',
|
||||
'auth.useBrowser' => '使用瀏覽器',
|
||||
'auth.or' => '或',
|
||||
'auth.connectToJellyfin' => '連線至 Jellyfin',
|
||||
'auth.useQuickConnect' => '使用快速連線(Quick Connect)',
|
||||
'auth.quickConnectInstructions' => '在 Jellyfin 中開啟快速連線並輸入此代碼。',
|
||||
'auth.quickConnectWaiting' => '等待核准…',
|
||||
@@ -2709,9 +2698,9 @@ extension on TranslationsZhHant {
|
||||
'videoControls.searchSubtitles' => '搜尋字幕',
|
||||
'videoControls.language' => '語言',
|
||||
'videoControls.noSubtitlesFound' => '找不到字幕',
|
||||
'videoControls.subtitleDownloaded' => '字幕下載成功',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'videoControls.subtitleDownloaded' => '字幕下載成功',
|
||||
'videoControls.subtitleDownloadedNotApplied' => '字幕已下載,但無法套用',
|
||||
'videoControls.subtitleDownloadFailed' => '字幕下載失敗',
|
||||
'videoControls.searchLanguages' => '搜尋語言…',
|
||||
@@ -2867,8 +2856,6 @@ extension on TranslationsZhHant {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => '${name} 的工作階段已過期',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => '${count} 個伺服器的工作階段已過期',
|
||||
'connections.signInAgain' => '重新登入',
|
||||
'connections.editJellyfinTitle' => '編輯 Jellyfin 連線',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => '新增或移除 ${serverName} 的 URL。Plezy 會自動選擇可連線且延遲最低的網址。',
|
||||
'discover.title' => '發現',
|
||||
'discover.noContentAvailable' => '沒有可用內容',
|
||||
'discover.addMediaToLibraries' => '請向您的媒體庫新增一些媒體內容',
|
||||
@@ -3223,11 +3210,11 @@ extension on TranslationsZhHant {
|
||||
'watchTogether.failedToCreate' => '建立工作階段失敗',
|
||||
'watchTogether.failedToJoin' => '加入工作階段失敗',
|
||||
'watchTogether.sessionCodeCopied' => '工作階段代碼已複製到剪貼簿',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.relayUnreachable' => '無法連線至中繼伺服器。ISP 封鎖可能會導致「一起看」無法使用。',
|
||||
'watchTogether.reconnectingToHost' => '正在重新連線至主持人…',
|
||||
'watchTogether.currentPlayback' => '目前播放內容',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.joinCurrentPlayback' => '加入目前播放點',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => '同步至主持人目前的觀看進度',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => '無法開啟目前播放點',
|
||||
@@ -3654,12 +3641,9 @@ extension on TranslationsZhHant {
|
||||
'services.libraryFilter.modeHintWhitelist' => '僅同步下方已勾選的媒體庫。',
|
||||
'services.libraryFilter.libraries' => '媒體庫',
|
||||
'services.libraryFilter.noLibraries' => '沒有可用的媒體庫',
|
||||
'addServer.addJellyfinTitle' => '新增 Jellyfin 伺服器',
|
||||
'addServer.serverUrls' => '伺服器 URL',
|
||||
'addServer.serverUrlsHelper' => '可輸入多個連線網址,以逗號區隔。',
|
||||
'addServer.findServer' => '尋找伺服器',
|
||||
'addServer.searchingLocalServers' => '正在尋找本地 Jellyfin 伺服器…',
|
||||
'addServer.localServers' => '本地 Jellyfin 伺服器',
|
||||
'addServer.username' => '使用者名稱',
|
||||
'addServer.password' => '密碼',
|
||||
'addServer.signIn' => '登入',
|
||||
@@ -3671,15 +3655,11 @@ extension on TranslationsZhHant {
|
||||
'addServer.addPlexTitle' => '使用 Plex 登入',
|
||||
'addServer.pinExpired' => 'PIN 碼在登入前已過期。請重試。',
|
||||
'addServer.failedToRegisterAccount' => ({required Object error}) => '註冊帳戶失敗:${error}',
|
||||
'addServer.enterJellyfinUrlError' => '請輸入您的 Jellyfin 伺服器 URL',
|
||||
'addServer.addConnectionTitle' => '新增連線',
|
||||
'addServer.addConnectionTitleScoped' => ({required Object name}) => '新增連線至 ${name}',
|
||||
'addServer.signInWithPlexCard' => '使用 Plex 登入',
|
||||
'addServer.signInWithPlexCardSubtitle' => '授權此裝置。將會新增共享的伺服器連線。',
|
||||
'addServer.signInWithPlexCardSubtitleScoped' => '授權 Plex 帳戶。Home 使用者會建立為個別的使用者設定檔。',
|
||||
'addServer.connectToJellyfinCard' => '連線至 Jellyfin',
|
||||
'addServer.connectToJellyfinCardSubtitle' => '輸入伺服器 URL、使用者名稱與密碼。',
|
||||
'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => '登入 Jellyfin 伺服器,並綁定至 ${name} 使用者設定檔。',
|
||||
'addServer.borrowFromAnotherProfile' => '從另一個使用者設定檔共用',
|
||||
'addServer.borrowFromAnotherProfileSubtitle' => '重複使用另一個使用者設定檔的連線資訊。受 PIN 碼保護的使用者設定檔需輸入 PIN 碼。',
|
||||
_ => null,
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Väntar på autentisering...\nLogga in från din webbläsare.",
|
||||
"useBrowser": "Använd webbläsare",
|
||||
"or": "eller",
|
||||
"connectToJellyfin": "Anslut till Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Använd Quick Connect",
|
||||
"quickConnectInstructions": "Öppna Quick Connect i Jellyfin och ange den här koden.",
|
||||
"quickConnectWaiting": "Väntar på godkännande…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "Sessionen har gått ut för ${name}",
|
||||
"sessionExpiredMany": "Sessionen har gått ut för ${count} servrar",
|
||||
"signInAgain": "Logga in igen",
|
||||
"editJellyfinTitle": "Redigera Jellyfin-anslutning",
|
||||
"editJellyfinIntro": "Lägg till eller ta bort URL:er för ${serverName}. Plezy använder den nåbara URL som har lägst latens."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Upptäck",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Lägg till Jellyfin-server",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Server-URL:er",
|
||||
"serverUrlsHelper": "Du kan ange flera URL:er avgränsade med kommatecken.",
|
||||
"findServer": "Hitta server",
|
||||
"searchingLocalServers": "Söker efter lokala Jellyfin-servrar...",
|
||||
"localServers": "Lokala Jellyfin-servrar",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"signIn": "Logga in",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Logga in med Plex",
|
||||
"pinExpired": "PIN-koden gick ut innan inloggning. Försök igen.",
|
||||
"failedToRegisterAccount": "Kunde inte registrera kontot: ${error}",
|
||||
"enterJellyfinUrlError": "Ange URL till din Jellyfin-server",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Lägg till anslutning",
|
||||
"addConnectionTitleScoped": "Lägg till i ${name}",
|
||||
"signInWithPlexCard": "Logga in med Plex",
|
||||
"signInWithPlexCardSubtitle": "Auktorisera den här enheten. Delade servrar läggs till.",
|
||||
"signInWithPlexCardSubtitleScoped": "Auktorisera ett Plex-konto. Home-användare blir profiler.",
|
||||
"connectToJellyfinCard": "Anslut till Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Ange server-URL, användarnamn och lösenord.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Logga in på en Jellyfin-server. Kopplas till ${name}.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Låna från en annan profil",
|
||||
"borrowFromAnotherProfileSubtitle": "Återanvänd en annan profils anslutning. PIN-skyddade profiler kräver en PIN."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Doğrulama bekleniyor...\nTarayıcınızdan giriş yapın.",
|
||||
"useBrowser": "Tarayıcı kullan",
|
||||
"or": "veya",
|
||||
"connectToJellyfin": "Jellyfin'e Bağlan",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Hızlı Bağlantıyı Kullan",
|
||||
"quickConnectInstructions": "Jellyfin'de Hızlı Bağlantı'yı açın ve bu kodu girin.",
|
||||
"quickConnectWaiting": "Onay bekleniyor…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "${name} için oturum süresi doldu",
|
||||
"sessionExpiredMany": "${count} sunucu için oturum süresi doldu",
|
||||
"signInAgain": "Tekrar giriş yap",
|
||||
"editJellyfinTitle": "Jellyfin bağlantısını düzenle",
|
||||
"editJellyfinIntro": "${serverName} için URL'ler ekleyin veya kaldırın. Plezy, en düşük gecikmeye sahip ulaşılabilir URL'yi kullanacaktır."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Keşfet",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin sunucusu ekle",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Sunucu URL'leri",
|
||||
"serverUrlsHelper": "Virgülle ayrılmış birden fazla URL'ye izin verilir.",
|
||||
"findServer": "Sunucu bul",
|
||||
"searchingLocalServers": "Yerel Jellyfin sunucuları aranıyor...",
|
||||
"localServers": "Yerel Jellyfin sunucuları",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Kullanıcı adı",
|
||||
"password": "Şifre",
|
||||
"signIn": "Giriş Yap",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Plex ile Giriş Yap",
|
||||
"pinExpired": "Giriş yapmadan önce PIN süresi doldu. Lütfen tekrar deneyin.",
|
||||
"failedToRegisterAccount": "Hesap kaydı başarısız oldu: ${error}",
|
||||
"enterJellyfinUrlError": "Jellyfin sunucu URL'nizi girin",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Bağlantı ekle",
|
||||
"addConnectionTitleScoped": "${name} profiline ekle",
|
||||
"signInWithPlexCard": "Plex ile Giriş Yap",
|
||||
"signInWithPlexCardSubtitle": "Bu cihazı yetkilendirin. Paylaşılan sunucular eklenir.",
|
||||
"signInWithPlexCardSubtitleScoped": "Bir Plex hesabını yetkilendirin. Ev kullanıcıları profile dönüşür.",
|
||||
"connectToJellyfinCard": "Jellyfin'e Bağlan",
|
||||
"connectToJellyfinCardSubtitle": "Sunucu URL'nizi, kullanıcı adınızı ve şifrenizi girin.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Bir Jellyfin sunucusuna giriş yapın. ${name} profiline bağlanır.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Başka bir profilden ödünç al",
|
||||
"borrowFromAnotherProfileSubtitle": "Başka bir profilin bağlantısını yeniden kullanın. PIN korumalı profiller bir PIN gerektirir."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "Tasdiqlanish kutilmoqda...\nBrauzeringizdan kiring.",
|
||||
"useBrowser": "Brauzerdan foydalanish",
|
||||
"or": "yoki",
|
||||
"connectToJellyfin": "Jellyfin-ga ulanish",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "Tezkor ulanishdan foydalanish",
|
||||
"quickConnectInstructions": "Jellyfin-da Tezkor ulanishni oching va ushbu kodni kiriting.",
|
||||
"quickConnectWaiting": "Tasdiq kutilmoqda…",
|
||||
@@ -827,8 +827,8 @@
|
||||
"sessionExpiredOne": "${name} uchun seans vaqti tugadi",
|
||||
"sessionExpiredMany": "${count} server uchun seans vaqti tugadi",
|
||||
"signInAgain": "Qaytadan kirish",
|
||||
"editJellyfinTitle": "Jellyfin ulanishini tahrirlash",
|
||||
"editJellyfinIntro": "${serverName} uchun URL manzilini qoʻshing yoki oʻchiring."
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "Kashf qilish",
|
||||
@@ -1873,12 +1873,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin serverini qoʻshish",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "Server URL-lari",
|
||||
"serverUrlsHelper": "Vergul bilan ajratilgan bir nechta URL manziliga ruxsat beriladi.",
|
||||
"findServer": "Serverni topish",
|
||||
"searchingLocalServers": "Mahalliy Jellyfin serverlari qidirilmoqda...",
|
||||
"localServers": "Mahalliy Jellyfin serverlari",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "Foydalanuvchi nomi",
|
||||
"password": "Parol",
|
||||
"signIn": "Kirish",
|
||||
@@ -1890,15 +1890,15 @@
|
||||
"addPlexTitle": "Plex orqali kirish",
|
||||
"pinExpired": "PIN kod vaqti tugadi.",
|
||||
"failedToRegisterAccount": "Hisobni roʻyxatdan oʻtkazishda xatolik: ${error}",
|
||||
"enterJellyfinUrlError": "Jellyfin server URL-ini kiriting",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "Ulanish qoʻshish",
|
||||
"addConnectionTitleScoped": "${name} profiliga qoʻshish",
|
||||
"signInWithPlexCard": "Plex orqali kirish",
|
||||
"signInWithPlexCardSubtitle": "Ushbu qurilmani avtorizatsiya qiling.",
|
||||
"signInWithPlexCardSubtitleScoped": "Plex hisobini avtorizatsiya qiling.",
|
||||
"connectToJellyfinCard": "Jellyfin-ga ulanish",
|
||||
"connectToJellyfinCardSubtitle": "Server URL, foydalanuvchi nomi va parolingizni kiriting.",
|
||||
"connectToJellyfinCardSubtitleScoped": "Jellyfin serveriga kiring. ${name} profiliga ulanmoqda.",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "Boshqa profildan olish",
|
||||
"borrowFromAnotherProfileSubtitle": "Boshqa profilning ulanishidan qayta foydalaning."
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "正在等待驗證…\n請在瀏覽器中登入。",
|
||||
"useBrowser": "使用瀏覽器",
|
||||
"or": "或",
|
||||
"connectToJellyfin": "連線至 Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "使用快速連線(Quick Connect)",
|
||||
"quickConnectInstructions": "在 Jellyfin 中開啟快速連線並輸入此代碼。",
|
||||
"quickConnectWaiting": "等待核准…",
|
||||
@@ -826,8 +826,8 @@
|
||||
"sessionExpiredOne": "${name} 的工作階段已過期",
|
||||
"sessionExpiredMany": "${count} 個伺服器的工作階段已過期",
|
||||
"signInAgain": "重新登入",
|
||||
"editJellyfinTitle": "編輯 Jellyfin 連線",
|
||||
"editJellyfinIntro": "新增或移除 ${serverName} 的 URL。Plezy 會自動選擇可連線且延遲最低的網址。"
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "發現",
|
||||
@@ -1867,12 +1867,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "新增 Jellyfin 伺服器",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "伺服器 URL",
|
||||
"serverUrlsHelper": "可輸入多個連線網址,以逗號區隔。",
|
||||
"findServer": "尋找伺服器",
|
||||
"searchingLocalServers": "正在尋找本地 Jellyfin 伺服器…",
|
||||
"localServers": "本地 Jellyfin 伺服器",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "使用者名稱",
|
||||
"password": "密碼",
|
||||
"signIn": "登入",
|
||||
@@ -1884,15 +1884,15 @@
|
||||
"addPlexTitle": "使用 Plex 登入",
|
||||
"pinExpired": "PIN 碼在登入前已過期。請重試。",
|
||||
"failedToRegisterAccount": "註冊帳戶失敗:${error}",
|
||||
"enterJellyfinUrlError": "請輸入您的 Jellyfin 伺服器 URL",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "新增連線",
|
||||
"addConnectionTitleScoped": "新增連線至 ${name}",
|
||||
"signInWithPlexCard": "使用 Plex 登入",
|
||||
"signInWithPlexCardSubtitle": "授權此裝置。將會新增共享的伺服器連線。",
|
||||
"signInWithPlexCardSubtitleScoped": "授權 Plex 帳戶。Home 使用者會建立為個別的使用者設定檔。",
|
||||
"connectToJellyfinCard": "連線至 Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "輸入伺服器 URL、使用者名稱與密碼。",
|
||||
"connectToJellyfinCardSubtitleScoped": "登入 Jellyfin 伺服器,並綁定至 ${name} 使用者設定檔。",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "從另一個使用者設定檔共用",
|
||||
"borrowFromAnotherProfileSubtitle": "重複使用另一個使用者設定檔的連線資訊。受 PIN 碼保護的使用者設定檔需輸入 PIN 碼。"
|
||||
}
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@
|
||||
"waitingForAuth": "正在等待身份验证…\n请在浏览器中登录。",
|
||||
"useBrowser": "使用浏览器",
|
||||
"or": "或",
|
||||
"connectToJellyfin": "连接到 Jellyfin",
|
||||
"connectToMediaBrowser": "",
|
||||
"useQuickConnect": "使用 Quick Connect",
|
||||
"quickConnectInstructions": "在 Jellyfin 中打开 Quick Connect 并输入此代码。",
|
||||
"quickConnectWaiting": "等待批准…",
|
||||
@@ -826,8 +826,8 @@
|
||||
"sessionExpiredOne": "${name} 的会话已过期",
|
||||
"sessionExpiredMany": "${count} 个服务器的会话已过期",
|
||||
"signInAgain": "重新登录",
|
||||
"editJellyfinTitle": "编辑 Jellyfin 连接",
|
||||
"editJellyfinIntro": "添加或移除 ${serverName} 的 URL。Plezy 会使用可访问且延迟最低的地址。"
|
||||
"editMediaBrowserTitle": "",
|
||||
"editMediaBrowserIntro": ""
|
||||
},
|
||||
"discover": {
|
||||
"title": "发现",
|
||||
@@ -1867,12 +1867,12 @@
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "添加 Jellyfin 服务器",
|
||||
"addMediaBrowserTitle": "",
|
||||
"serverUrls": "服务器 URL",
|
||||
"serverUrlsHelper": "可输入多个 URL,并用逗号分隔。",
|
||||
"findServer": "查找服务器",
|
||||
"searchingLocalServers": "正在查找本地 Jellyfin 服务器…",
|
||||
"localServers": "本地 Jellyfin 服务器",
|
||||
"searchingLocalMediaBrowserServers": "",
|
||||
"localMediaBrowserServers": "",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"signIn": "登录",
|
||||
@@ -1884,15 +1884,15 @@
|
||||
"addPlexTitle": "使用 Plex 登录",
|
||||
"pinExpired": "PIN 在登录前已过期。请重试。",
|
||||
"failedToRegisterAccount": "注册账户失败:${error}",
|
||||
"enterJellyfinUrlError": "请输入 Jellyfin 服务器 URL",
|
||||
"enterMediaBrowserUrlError": "",
|
||||
"addConnectionTitle": "添加连接",
|
||||
"addConnectionTitleScoped": "添加到 ${name}",
|
||||
"signInWithPlexCard": "使用 Plex 登录",
|
||||
"signInWithPlexCardSubtitle": "授权此设备。共享服务器会被添加。",
|
||||
"signInWithPlexCardSubtitleScoped": "授权一个 Plex 账户。Plex Home 用户将成为 Plezy 用户资料。",
|
||||
"connectToJellyfinCard": "连接到 Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "输入服务器 URL、用户名和密码。",
|
||||
"connectToJellyfinCardSubtitleScoped": "登录到 Jellyfin 服务器。绑定到 ${name}。",
|
||||
"connectToMediaBrowserCard": "",
|
||||
"connectToMediaBrowserCardSubtitle": "",
|
||||
"connectToMediaBrowserCardSubtitleScoped": "",
|
||||
"borrowFromAnotherProfile": "使用其他用户资料的连接",
|
||||
"borrowFromAnotherProfileSubtitle": "复用另一个用户资料的连接。受 PIN 保护的用户资料需要输入 PIN。"
|
||||
}
|
||||
|
||||
+5
-5
@@ -1857,7 +1857,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
serverManager: serverManager,
|
||||
).pruneUnreferencedJellyfinConnections();
|
||||
if (pruned > 0) {
|
||||
appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}');
|
||||
appLogger.i('Setup: pruned $pruned unreferenced MediaBrowser connection${pruned == 1 ? '' : 's'}');
|
||||
}
|
||||
// Provider initialization starts before this screen runs the legacy
|
||||
// migration. Reload after bootstrap so copied Plex Home users and the
|
||||
@@ -1943,11 +1943,11 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
}
|
||||
|
||||
final plexCount = allConnections.whereType<PlexAccountConnection>().fold<int>(0, (n, c) => n + c.servers.length);
|
||||
final jellyfinCount = allConnections.whereType<JellyfinConnection>().length;
|
||||
final mediaBrowserCount = allConnections.whereType<JellyfinConnection>().length;
|
||||
unawaited(
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb(
|
||||
message: 'Handing off to MainScreen with $plexCount Plex server(s) + $jellyfinCount Jellyfin',
|
||||
message: 'Handing off to MainScreen with $plexCount Plex + $mediaBrowserCount MediaBrowser server(s)',
|
||||
category: 'setup',
|
||||
),
|
||||
),
|
||||
@@ -2003,7 +2003,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
if (!mounted) return;
|
||||
bindingSucceeded = activeProfile.active != null && activeProfile.lastBindingSucceeded;
|
||||
} else {
|
||||
// Now wait for the binder to settle. This is the Plex/Jellyfin server
|
||||
// Now wait for the binder to settle. This is the media-server client
|
||||
// race: per-server status flips on the splash list as each client comes
|
||||
// online, and we don't push MainScreen until they're all done (success
|
||||
// or fail). Eliminates the "Failed to load discover content: No servers
|
||||
@@ -2023,7 +2023,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
}
|
||||
|
||||
// Repopulate metadata for downloaded items now that per-backend caches
|
||||
// are resolvable (the Connections row + live JellyfinClient are in
|
||||
// are resolvable (the Connections row + live MediaBrowser client are in
|
||||
// place). Without this the downloads list and sync-rule titles render
|
||||
// empty until something forces a later refresh.
|
||||
await downloadProvider.refreshMetadataFromCache();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../utils/app_logger.dart';
|
||||
import 'media_browser_dialect.dart';
|
||||
|
||||
/// Backend identifier for a media item, library, or server.
|
||||
///
|
||||
@@ -7,16 +8,19 @@ import '../utils/app_logger.dart';
|
||||
/// in v1) and so persisted records can round-trip the source of an item.
|
||||
enum MediaBackend {
|
||||
plex,
|
||||
jellyfin;
|
||||
jellyfin,
|
||||
emby;
|
||||
|
||||
String get id => switch (this) {
|
||||
MediaBackend.plex => 'plex',
|
||||
MediaBackend.jellyfin => 'jellyfin',
|
||||
MediaBackend.emby => 'emby',
|
||||
};
|
||||
|
||||
static MediaBackend fromId(String id) => switch (id) {
|
||||
'plex' => MediaBackend.plex,
|
||||
'jellyfin' => MediaBackend.jellyfin,
|
||||
'emby' => MediaBackend.emby,
|
||||
_ => throw ArgumentError('Unknown MediaBackend id: $id'),
|
||||
};
|
||||
|
||||
@@ -27,12 +31,26 @@ enum MediaBackend {
|
||||
/// surfaces corrupted cache rows or schema drift instead of silently
|
||||
/// misclassifying Jellyfin items as Plex.
|
||||
static MediaBackend fromString(String? id) {
|
||||
if (id != null && id != 'plex' && id != 'jellyfin') {
|
||||
if (id != null && id != 'plex' && id != 'jellyfin' && id != 'emby') {
|
||||
appLogger.w('Unknown MediaBackend id "$id"; defaulting to plex');
|
||||
}
|
||||
return switch (id) {
|
||||
'jellyfin' => MediaBackend.jellyfin,
|
||||
'emby' => MediaBackend.emby,
|
||||
_ => MediaBackend.plex,
|
||||
};
|
||||
}
|
||||
|
||||
/// True for backends served by the MediaBrowser HTTP API — Jellyfin and its
|
||||
/// Emby ancestor. They share one client stack, one query grammar and one set
|
||||
/// of DTO shapes, so behaviour keyed to "not Plex" should test this instead
|
||||
/// of comparing against [MediaBackend.jellyfin].
|
||||
bool get usesMediaBrowserApi => dialect != null;
|
||||
|
||||
/// The MediaBrowser dialect this backend speaks, or `null` for Plex.
|
||||
MediaBrowserDialect? get dialect => switch (this) {
|
||||
MediaBackend.plex => null,
|
||||
MediaBackend.jellyfin => MediaBrowserDialect.jellyfin,
|
||||
MediaBackend.emby => MediaBrowserDialect.emby,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'media_backend.dart';
|
||||
|
||||
/// Which flavour of the MediaBrowser HTTP API a server speaks.
|
||||
///
|
||||
/// Jellyfin forked from Emby 3.5.2, so the two still share almost their entire
|
||||
/// wire contract: identical `BaseItemDto` shapes, the same `/Items` query
|
||||
/// grammar, the `MediaBrowser` Authorization scheme, the `X-Emby-Token` header
|
||||
/// and `api_key=` query fallback. Plezy therefore drives both through one
|
||||
/// client stack ([JellyfinClient]) and keeps every delta in this one type.
|
||||
///
|
||||
/// Verified against Jellyfin 10.10.7/10.11 and Emby 4.9.5:
|
||||
/// - Jellyfin 10.9 renamed a batch of user-scoped write routes to unprefixed
|
||||
/// forms and added `/Users/Me`. Emby only has the original user-scoped
|
||||
/// spellings — see [MediaBrowserPaths].
|
||||
/// - Trickplay, `/MediaSegments`, `/Audio/{id}/Lyrics`, `/Items/Filters` and
|
||||
/// Quick Connect do not exist on Emby. `/Audio/{id}/Lyrics` is actively
|
||||
/// harmful there: Emby parses `Lyrics` as a container name and starts an
|
||||
/// ffmpeg transcode.
|
||||
/// - Emby tolerates unknown `Fields`/`SortBy` values, so the shared field sets
|
||||
/// need no per-dialect pruning.
|
||||
enum MediaBrowserDialect {
|
||||
jellyfin,
|
||||
emby;
|
||||
|
||||
/// Stable wire/persistence id. Matches the [MediaBackend] and
|
||||
/// `ConnectionKind` ids for the same server kind.
|
||||
String get id => switch (this) {
|
||||
MediaBrowserDialect.jellyfin => 'jellyfin',
|
||||
MediaBrowserDialect.emby => 'emby',
|
||||
};
|
||||
|
||||
static MediaBrowserDialect fromId(String id) => switch (id) {
|
||||
'jellyfin' => MediaBrowserDialect.jellyfin,
|
||||
'emby' => MediaBrowserDialect.emby,
|
||||
_ => throw ArgumentError('Unknown MediaBrowserDialect id: $id'),
|
||||
};
|
||||
|
||||
/// Like [fromId] but tolerates legacy/missing values by defaulting to
|
||||
/// Jellyfin. Persisted connection rows written before Emby support carry no
|
||||
/// `dialect` key at all, and those are Jellyfin by construction.
|
||||
static MediaBrowserDialect fromIdOrJellyfin(Object? id) => switch (id) {
|
||||
'emby' => MediaBrowserDialect.emby,
|
||||
_ => MediaBrowserDialect.jellyfin,
|
||||
};
|
||||
|
||||
MediaBackend get backend => switch (this) {
|
||||
MediaBrowserDialect.jellyfin => MediaBackend.jellyfin,
|
||||
MediaBrowserDialect.emby => MediaBackend.emby,
|
||||
};
|
||||
|
||||
/// Product name, used verbatim in UI that names the backend. These are
|
||||
/// trademarks, so they are not localized.
|
||||
String get productName => switch (this) {
|
||||
MediaBrowserDialect.jellyfin => 'Jellyfin',
|
||||
MediaBrowserDialect.emby => 'Emby',
|
||||
};
|
||||
|
||||
/// Placeholder host shown in the "server URL" field.
|
||||
String get exampleBaseUrl => switch (this) {
|
||||
MediaBrowserDialect.jellyfin => 'https://jellyfin.example.com',
|
||||
MediaBrowserDialect.emby => 'https://emby.example.com',
|
||||
};
|
||||
|
||||
/// UDP payload the server answers on port 7359. Emby ignores Jellyfin's
|
||||
/// string and vice versa, which makes the datagram itself a reliable
|
||||
/// dialect discriminator during LAN discovery.
|
||||
String get lanDiscoveryMessage => switch (this) {
|
||||
MediaBrowserDialect.jellyfin => 'who is JellyfinServer?',
|
||||
MediaBrowserDialect.emby => 'who is EmbyServer?',
|
||||
};
|
||||
|
||||
/// Ports appended when the user types a bare host, most-likely first.
|
||||
/// Both ship 8096 for HTTP; Emby's default HTTPS port is 8920.
|
||||
List<int> get httpsPortGuesses => switch (this) {
|
||||
MediaBrowserDialect.jellyfin => const [8096],
|
||||
MediaBrowserDialect.emby => const [8920, 8096],
|
||||
};
|
||||
|
||||
/// `/QuickConnect/*` plus `POST /Users/AuthenticateWithQuickConnect`.
|
||||
bool get supportsQuickConnect => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// `/Videos/{id}/Trickplay/{width}/{n}.jpg` sprite sheets and the
|
||||
/// `Trickplay` item field (Jellyfin 10.9+). Emby 404s on the route and never
|
||||
/// fills the field; its own preview transports are unwired — see
|
||||
/// [ServerCapabilities.emby].
|
||||
bool get supportsTrickplay => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// `/MediaSegments/{itemId}` intro/outro/credit markers (Jellyfin 10.10+).
|
||||
/// Emby 404s; chapter-name fallback still applies.
|
||||
bool get supportsMediaSegments => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// `GET /Audio/{id}/Lyrics` (Jellyfin 10.9+). Never call this on Emby: the
|
||||
/// route resolves to audio streaming with `Lyrics` as the container and
|
||||
/// spawns an ffmpeg process that fails with a 500.
|
||||
bool get supportsLyrics => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// `GET /Items/Filters`, the single call that returns a library's distinct
|
||||
/// genres, official ratings, tags and years. Emby has no aggregate route; the
|
||||
/// client reassembles the same payload from `/Genres`, `/OfficialRatings`,
|
||||
/// `/Tags` and `/Years`.
|
||||
bool get supportsAggregateItemFilters => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// `POST /Users/{uid}/Items/{id}/HideFromResume` hides an item from Continue
|
||||
/// Watching without clearing its resume position.
|
||||
///
|
||||
/// Emby-only, and the one capability where Emby is ahead of Jellyfin:
|
||||
/// measured 200 on Emby 4.9.5 (the row leaves `/Users/{uid}/Items/Resume`
|
||||
/// while `UserData.PlaybackPositionTicks` survives), and 404 on Jellyfin
|
||||
/// 10.11 for both that spelling and `/UserItems/{id}/HideFromResume`.
|
||||
bool get supportsContinueWatchingRemoval => this == MediaBrowserDialect.emby;
|
||||
|
||||
/// `POST /Items/{id}` persists genre and tag edits from the `GenreItems` /
|
||||
/// `TagItems` name-pair arrays rather than the plain `Genres` / `Tags` string
|
||||
/// lists.
|
||||
///
|
||||
/// Measured on Emby 4.9.5: sending `Genres: ['Action']` alone round-trips as
|
||||
/// an empty list and the `/Genres` facet stays empty, while
|
||||
/// `GenreItems: [{'Name': 'Action'}]` sticks and is immediately indexed. The
|
||||
/// sibling fields (`Studios`, `People`, `ProductionLocations`, `Taglines`,
|
||||
/// `Overview`, `OriginalTitle`) all persist from their ordinary shapes on
|
||||
/// both dialects.
|
||||
bool get metadataWritesUseNamePairLists => this == MediaBrowserDialect.emby;
|
||||
|
||||
/// `GET /Shows/NextUp` answers an unscoped, library-wide query.
|
||||
///
|
||||
/// Jellyfin-only. Measured on Emby 4.9.5 with one played episode: the
|
||||
/// unscoped query returns `TotalRecordCount: 0` under every parameter
|
||||
/// combination tried (`ParentId` on the view or the series, `SeriesId=`,
|
||||
/// `Recursive`, `GroupItems`, `EnableResumable`, `SortBy`), while the same
|
||||
/// query with `SeriesId=<series>` returns the series' 23 remaining episodes.
|
||||
/// Emby therefore only computes Next Up per series, and the library-wide
|
||||
/// shelf has to be reconstructed client-side from recently played episodes.
|
||||
bool get supportsGlobalNextUp => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// The resume route returns only items with a saved playback position.
|
||||
///
|
||||
/// Jellyfin-only. Measured with one in-progress movie and 30 started series:
|
||||
/// Jellyfin's `/UserItems/Resume` returned exactly the movie, while Emby's
|
||||
/// `/Users/{uid}/Items/Resume` returned 30 rows — the movie plus 29
|
||||
/// zero-position *next* episodes — and `Filters=IsResumable` did not remove
|
||||
/// them. Emby's own UI merges both into one shelf; Plezy models Continue
|
||||
/// Watching and Next Up as separate rows, so the Emby resume leg is filtered
|
||||
/// to genuine progress and the next-up rows come from the Next Up path
|
||||
/// instead. Without the filter a started series occupies both rows and can
|
||||
/// push the real in-progress item out of a limited one.
|
||||
bool get resumeReturnsOnlyStartedItems => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
|
||||
/// `PlaySessionId`.
|
||||
///
|
||||
/// Measured on Emby 4.9.5: both return HTTP 400 `Value cannot be null.
|
||||
/// (Parameter 'key')` when the field is absent, while
|
||||
/// `/Sessions/Playing/Stopped` tolerates it. Jellyfin accepts all three
|
||||
/// without one. Callers that never negotiated a PlaybackInfo session — the
|
||||
/// offline watch-progress sync is the live example — therefore need a
|
||||
/// synthesized id on Emby or their progress is silently dropped.
|
||||
bool get requiresPlaySessionId => this == MediaBrowserDialect.emby;
|
||||
|
||||
/// `/Items?IncludeItemTypes=Playlist` honours a `MediaTypes` filter.
|
||||
///
|
||||
/// Measured on Emby 4.9.5: passing *any* `MediaTypes` value makes the server
|
||||
/// discard `IncludeItemTypes` and return the whole index — 14554 rows of
|
||||
/// `Genre`/`Person`/`Studio`/`Movie` instead of the one playlist. Emby also
|
||||
/// never populates `MediaType` on a playlist DTO, not even for a playlist
|
||||
/// created with `MediaType=Audio`, so there is nothing to filter on either
|
||||
/// side and every playlist is returned regardless of the requested type.
|
||||
bool get playlistsFilterByMediaType => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// True when the dialect only accepts the pre-10.9 `/Users/{userId}/…`
|
||||
/// spelling of the user-scoped item routes.
|
||||
bool get requiresUserScopedItemRoutes => this == MediaBrowserDialect.emby;
|
||||
|
||||
/// Best-effort dialect detection from a `/System/Info/Public` body.
|
||||
///
|
||||
/// Jellyfin reports `ProductName: "Jellyfin Server"`. Emby 4.9 omits
|
||||
/// `ProductName` entirely but is the only one of the two that returns the
|
||||
/// `RemoteAddresses` array. Returns `null` when neither signal is present so
|
||||
/// callers keep whichever dialect the user picked.
|
||||
static MediaBrowserDialect? detectFromPublicSystemInfo(Map<String, Object?> json) {
|
||||
final productName = json['ProductName'];
|
||||
if (productName is String && productName.isNotEmpty) {
|
||||
final normalized = productName.toLowerCase();
|
||||
if (normalized.contains('jellyfin')) return MediaBrowserDialect.jellyfin;
|
||||
if (normalized.contains('emby')) return MediaBrowserDialect.emby;
|
||||
}
|
||||
if (json.containsKey('RemoteAddresses')) return MediaBrowserDialect.emby;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../services/settings_service.dart' show EpisodePosterMode;
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import 'media_backend.dart';
|
||||
import 'media_browser_dialect.dart';
|
||||
import 'media_kind.dart';
|
||||
import 'media_role.dart';
|
||||
import 'media_version.dart';
|
||||
@@ -23,6 +24,10 @@ const double _squareHeroAspectRatio = 1.39;
|
||||
/// Backend-neutral media item shape used by UI, providers, persistence, and
|
||||
/// playback. Concrete variants retain backend-only fields without forcing the
|
||||
/// rest of the app to traffic in Plex/Jellyfin DTOs.
|
||||
///
|
||||
/// [JellyfinMediaItem] backs both MediaBrowser-family backends: Jellyfin and
|
||||
/// Emby return field-identical `BaseItemDto`s, so they share one variant and
|
||||
/// carry [JellyfinMediaItem.dialect] to tell them apart.
|
||||
@Freezed(unionKey: 'backend', unionValueCase: FreezedUnionCase.none, equal: false, makeCollectionsUnmodifiable: false)
|
||||
sealed class MediaItem with _$MediaItem {
|
||||
const MediaItem._();
|
||||
@@ -154,7 +159,8 @@ sealed class MediaItem with _$MediaItem {
|
||||
backendFolderKey: backendFolderKey,
|
||||
raw: raw,
|
||||
),
|
||||
MediaBackend.jellyfin => JellyfinMediaItem(
|
||||
MediaBackend.jellyfin || MediaBackend.emby => JellyfinMediaItem(
|
||||
dialect: backend.dialect!,
|
||||
id: id,
|
||||
kind: kind,
|
||||
guid: guid,
|
||||
@@ -300,10 +306,20 @@ sealed class MediaItem with _$MediaItem {
|
||||
@JsonKey(fromJson: _mediaItemRawFromJson) Map<String, Object?>? raw,
|
||||
}) = PlexMediaItem;
|
||||
|
||||
/// Backend-tagged concrete subclass for items sourced from a Jellyfin server.
|
||||
/// Backend-tagged concrete subclass for items sourced from a MediaBrowser
|
||||
/// server — Jellyfin or Emby, discriminated by [JellyfinMediaItem.dialect].
|
||||
@FreezedUnionValue('jellyfin')
|
||||
@JsonSerializable(includeIfNull: false, explicitToJson: true)
|
||||
const factory MediaItem.jellyfin({
|
||||
/// Which MediaBrowser dialect produced this item.
|
||||
///
|
||||
/// Not serialized on its own: [MediaItem.toJson] already writes the
|
||||
/// resolved [backend] id under the union key, and [MediaItem.fromJson]
|
||||
/// restores the dialect from it. Keeping one discriminator on the wire
|
||||
/// avoids the two disagreeing on a stale cache row.
|
||||
@JsonKey(includeToJson: false, includeFromJson: false)
|
||||
@Default(MediaBrowserDialect.jellyfin)
|
||||
MediaBrowserDialect dialect,
|
||||
@JsonKey(readValue: readStringField, defaultValue: '') required String id,
|
||||
@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required MediaKind kind,
|
||||
String? guid,
|
||||
@@ -375,7 +391,7 @@ sealed class MediaItem with _$MediaItem {
|
||||
|
||||
MediaBackend get backend => switch (this) {
|
||||
PlexMediaItem() => MediaBackend.plex,
|
||||
JellyfinMediaItem() => MediaBackend.jellyfin,
|
||||
JellyfinMediaItem(:final dialect) => dialect.backend,
|
||||
};
|
||||
|
||||
/// Restore a [MediaItem] from a [toJson] payload. Missing/unknown backend
|
||||
@@ -385,13 +401,14 @@ sealed class MediaItem with _$MediaItem {
|
||||
return switch (MediaBackend.fromString(json['backend'] as String?)) {
|
||||
MediaBackend.plex => _$PlexMediaItemFromJson(json),
|
||||
MediaBackend.jellyfin => _$JellyfinMediaItemFromJson(json),
|
||||
MediaBackend.emby => _$JellyfinMediaItemFromJson(json).copyWith(dialect: MediaBrowserDialect.emby),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return switch (this) {
|
||||
final PlexMediaItem item => {'backend': MediaBackend.plex.id, ..._$PlexMediaItemToJson(item)},
|
||||
final JellyfinMediaItem item => {'backend': MediaBackend.jellyfin.id, ..._$JellyfinMediaItemToJson(item)},
|
||||
final JellyfinMediaItem item => {'backend': item.dialect.backend.id, ..._$JellyfinMediaItemToJson(item)},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -115,6 +115,43 @@ class ServerCapabilities {
|
||||
instantMix: true,
|
||||
);
|
||||
|
||||
/// Defaults for an Emby server.
|
||||
///
|
||||
/// `continueWatchingRemoval` is the one flag where Emby is ahead of Jellyfin:
|
||||
/// `POST /Users/{uid}/Items/{id}/HideFromResume` drops an item from Continue
|
||||
/// Watching while keeping its resume position, and Jellyfin 10.11 has no
|
||||
/// equivalent route.
|
||||
///
|
||||
/// Otherwise identical to [jellyfin] except [scrubThumbnails]: seek-bar
|
||||
/// previews come
|
||||
/// from Jellyfin's `/Videos/{id}/Trickplay` sprite sheets, which Emby (the
|
||||
/// pre-fork ancestor) never gained — it 404s and never populates the
|
||||
/// `Trickplay` item field. With the flag off the player never attempts the
|
||||
/// load; see [MediaBrowserDialect.supportsTrickplay].
|
||||
///
|
||||
/// Emby does expose two *other* preview transports, `/Videos/{id}/index.bif`
|
||||
/// (the same BIF format Plex uses, so [BifThumbnailService] could parse it)
|
||||
/// and `/Items/{id}/ThumbnailSet`. Neither is wired: on Emby 4.9.5 both
|
||||
/// answer 200 with an empty payload — a 72-byte header-only BIF and
|
||||
/// `{"Thumbnails": []}` — even after a full metadata+image refresh, because
|
||||
/// Emby only fills them once its own extraction task has run. Wiring them
|
||||
/// needs a server that has actually generated the frames, so the flag stays
|
||||
/// `false` rather than shipping a path that cannot be verified.
|
||||
static const ServerCapabilities emby = ServerCapabilities(
|
||||
liveTv: true,
|
||||
liveTvDvr: false,
|
||||
videoTranscoding: true,
|
||||
richHubs: false,
|
||||
numericUserRating: false,
|
||||
userFavorites: true,
|
||||
continueWatchingRemoval: true,
|
||||
externalSubtitleSearch: false,
|
||||
richMetadataEdit: true,
|
||||
scrubThumbnails: false,
|
||||
folderGrouping: true,
|
||||
instantMix: true,
|
||||
);
|
||||
|
||||
/// Every flag here is fixed per backend *kind* except [videoTranscoding],
|
||||
/// which Plex probes per server (`PlexClient.capabilities`) — so that is the
|
||||
/// only override this type needs. Widen the parameter list if another flag
|
||||
|
||||
@@ -13,7 +13,7 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
JellyfinMetadataEditAdapter(this.client);
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.jellyfin;
|
||||
MediaBackend get backend => client.backend;
|
||||
|
||||
@override
|
||||
MediaServerClient get mediaClient => client;
|
||||
@@ -26,7 +26,7 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
Future<MetadataEditDraft> load(MediaItem item) async {
|
||||
final raw = await client.fetchEditableMetadataItem(item.id);
|
||||
if (raw == null) {
|
||||
throw StateError('Editable Jellyfin metadata item is unavailable');
|
||||
throw StateError('Editable MediaBrowser metadata item is unavailable');
|
||||
}
|
||||
final values = <String, Object?>{};
|
||||
_writeCommonValues(values, raw, item);
|
||||
@@ -56,8 +56,8 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
final dto = Map<String, dynamic>.from(raw);
|
||||
|
||||
dto['ProviderIds'] = _stringMap(dto['ProviderIds']);
|
||||
dto['Tags'] = metadataStringList(dto['Tags']);
|
||||
dto['Genres'] = metadataStringList(dto['Genres']);
|
||||
dto['Tags'] = _namedStringList(dto, 'Tags', 'TagItems');
|
||||
dto['Genres'] = _namedStringList(dto, 'Genres', 'GenreItems');
|
||||
dto['People'] = _mapList(dto['People']);
|
||||
dto['Studios'] = _mapList(dto['Studios']);
|
||||
dto['LockedFields'] = metadataStringList(dto['LockedFields']);
|
||||
@@ -102,6 +102,17 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
}
|
||||
if (peopleChanged) dto['People'] = people;
|
||||
|
||||
// Emby ignores the plain `Genres`/`Tags` string lists on `POST /Items/{id}`
|
||||
// and reads the `GenreItems`/`TagItems` name-pair arrays instead. The
|
||||
// fetched DTO carries the *old* pairs, so mirroring unconditionally (not
|
||||
// only when the field changed) is what keeps a save from silently
|
||||
// reinstating the previous genres. See
|
||||
// [MediaBrowserDialect.metadataWritesUseNamePairLists].
|
||||
if (client.dialect.metadataWritesUseNamePairLists) {
|
||||
dto['GenreItems'] = _toNamePairs(dto['Genres']);
|
||||
dto['TagItems'] = _toNamePairs(dto['Tags']);
|
||||
}
|
||||
|
||||
final success = await client.updateMetadataItem(draft.sourceItem.id, dto);
|
||||
if (success) {
|
||||
draft.extras['raw'] = dto;
|
||||
@@ -178,12 +189,12 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
? metadataFirstString(raw['Taglines'])
|
||||
: item.tagline ?? '';
|
||||
values['summary'] = raw['Overview'] as String? ?? item.summary ?? '';
|
||||
values['genre'] = metadataStringList(raw['Genres']);
|
||||
values['genre'] = _namedStringList(raw, 'Genres', 'GenreItems');
|
||||
values['director'] = _peopleByType(raw['People'], 'Director');
|
||||
values['writer'] = _peopleByType(raw['People'], 'Writer');
|
||||
values['producer'] = _peopleByType(raw['People'], 'Producer');
|
||||
values['country'] = metadataStringList(raw['ProductionLocations']);
|
||||
values['label'] = metadataStringList(raw['Tags']);
|
||||
values['label'] = _namedStringList(raw, 'Tags', 'TagItems');
|
||||
}
|
||||
|
||||
void _writeArtworkValues(Map<String, Object?> values, MediaItem item) {
|
||||
@@ -242,6 +253,18 @@ List<String> _nameList(Object? value) {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Effective value of a `Genres`/`Tags` style field, preferring the plain string
|
||||
/// array and falling back to its `…Items` name-pair sibling.
|
||||
///
|
||||
/// Emby never returns the plain `Tags` array at all — only `TagItems`, whatever
|
||||
/// `Fields` the request asks for (measured on Emby 4.9.5). Reading the plain key
|
||||
/// alone would show an empty tag editor for an item that has tags and, worse,
|
||||
/// write that emptiness back on the next save.
|
||||
List<String> _namedStringList(Map<String, dynamic> dto, String key, String pairKey) {
|
||||
final plain = metadataStringList(dto[key]);
|
||||
return plain.isNotEmpty ? plain : _nameList(dto[pairKey]);
|
||||
}
|
||||
|
||||
List<String> _peopleByType(Object? value, String type) {
|
||||
return _mapList(value)
|
||||
.where((person) => (person['Type'] as String?)?.toLowerCase() == type.toLowerCase())
|
||||
@@ -267,6 +290,13 @@ List<Map<String, dynamic>> _replaceNamePairs(List<Map<String, dynamic>> existing
|
||||
return names.map((name) => _preserveNamedMap(existing, used, name)).toList();
|
||||
}
|
||||
|
||||
/// Project a `Genres`/`Tags` string list into the `[{'Name': …}]` shape Emby
|
||||
/// requires on write. The server assigns the `Id` for a new entry, so omitting
|
||||
/// it is correct — it resolves an existing tag by name and creates one when
|
||||
/// there is no match.
|
||||
List<Map<String, dynamic>> _toNamePairs(Object? names) =>
|
||||
metadataStringList(names).map((name) => <String, dynamic>{'Name': name}).toList();
|
||||
|
||||
Map<String, dynamic> _preserveNamedMap(
|
||||
List<Map<String, dynamic>> existing,
|
||||
Set<int> used,
|
||||
|
||||
@@ -304,7 +304,7 @@ class ActiveProfileBinder {
|
||||
|
||||
// Bind the implicit Plex Home parent and borrowed/extra join rows in
|
||||
// parallel. A slow/offline Plex parent should not add its timeout budget
|
||||
// on top of an otherwise reachable Jellyfin or borrowed-server bind.
|
||||
// on top of an otherwise reachable MediaBrowser or borrowed-server bind.
|
||||
final results = await Future.wait([
|
||||
if (profile.isPlexHome)
|
||||
_bindPlexHome(
|
||||
@@ -315,8 +315,8 @@ class ActiveProfileBinder {
|
||||
generation: generation,
|
||||
),
|
||||
// Both kinds also bind borrowed/extra connections via the join table.
|
||||
// For plex_home this handles a Jellyfin server (or extra Plex account)
|
||||
// that was attached to the profile via the borrow flow — the parent
|
||||
// For plex_home this handles a MediaBrowser server (or extra Plex
|
||||
// account) that was attached to the profile via the borrow flow — the
|
||||
// account is bound by `_bindPlexHome` above and isn't represented in
|
||||
// the join table.
|
||||
_bindJoinRows(
|
||||
@@ -548,7 +548,7 @@ class ActiveProfileBinder {
|
||||
);
|
||||
case JellyfinConnection():
|
||||
expected.add(conn.serverMachineId);
|
||||
futures.add(_bindJellyfin(conn, profileId: profile.id, generation: generation));
|
||||
futures.add(_bindMediaBrowser(conn, profileId: profile.id, generation: generation));
|
||||
}
|
||||
}
|
||||
final results = await Future.wait(futures);
|
||||
@@ -1001,7 +1001,7 @@ class ActiveProfileBinder {
|
||||
);
|
||||
}
|
||||
|
||||
Future<_ProfileBindResult> _bindJellyfin(
|
||||
Future<_ProfileBindResult> _bindMediaBrowser(
|
||||
JellyfinConnection conn, {
|
||||
required String profileId,
|
||||
required int generation,
|
||||
|
||||
@@ -80,6 +80,9 @@ bool _isEarlier(Connection candidate, Connection incumbent) {
|
||||
/// borrows a *specific* Home user (`userIdentifier`), and that user's live
|
||||
/// [PlexHomeUser.thumb] is the picture Plex shows for it. Reading the live
|
||||
/// cache also means the avatar tracks Plex's hourly refresh for free.
|
||||
///
|
||||
/// Jellyfin and Emby use the same `/Users/{uid}/Images/Primary` route, so the
|
||||
/// shared [JellyfinConnection] arm is dialect-agnostic.
|
||||
String? connectionAvatarUrl({
|
||||
required Connection connection,
|
||||
required ProfileConnection link,
|
||||
|
||||
@@ -11,8 +11,9 @@ part 'profile_connection.freezed.dart';
|
||||
/// `ActiveProfileBinder` performs the switch on first activation and
|
||||
/// caches the resulting token back into this row.
|
||||
///
|
||||
/// For Jellyfin: [userToken] mirrors the Connection's accessToken (one
|
||||
/// user per connection); [userIdentifier] is the Jellyfin user id.
|
||||
/// For the MediaBrowser backends (Jellyfin and Emby): [userToken] mirrors the
|
||||
/// Connection's accessToken (one user per connection); [userIdentifier] is the
|
||||
/// server-side user id.
|
||||
@freezed
|
||||
sealed class ProfileConnection with _$ProfileConnection {
|
||||
const ProfileConnection._();
|
||||
|
||||
@@ -89,7 +89,7 @@ class ProfileConnectionCleanup {
|
||||
|
||||
/// Sign out of a Plex account: remove the account [Connection], every join
|
||||
/// row referencing it, and everything owned by its virtual Plex Home
|
||||
/// profiles — including borrowed Jellyfin connections left unreferenced,
|
||||
/// profiles — including borrowed MediaBrowser connections left unreferenced,
|
||||
/// which previously survived as orphans and wedged the session (#1423).
|
||||
///
|
||||
/// Pass a read-only [plannedRemoval] from
|
||||
@@ -131,9 +131,9 @@ class ProfileConnectionCleanup {
|
||||
|
||||
/// In-session mirror of the boot guard (`main.dart`: "stored connections
|
||||
/// exist but no profiles resolved — returning to auth"): prune orphaned
|
||||
/// Jellyfin connections, then decide whether any selectable profile remains.
|
||||
/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
|
||||
/// accounts are harmless because the connection map is re-read here.
|
||||
/// MediaBrowser connections, then decide whether any selectable profile
|
||||
/// remains. [plexHomeUsers] is [PlexHomeService.current]; stale entries for
|
||||
/// removed accounts are harmless because the connection map is re-read here.
|
||||
Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
|
||||
required ProfileRegistry profileRegistry,
|
||||
required Map<String, List<PlexHomeUser>> plexHomeUsers,
|
||||
@@ -152,6 +152,8 @@ class ProfileConnectionCleanup {
|
||||
return (route: PostRemovalRoute.staySignedIn, profiles: merged);
|
||||
}
|
||||
|
||||
/// The Jellyfin-era method name is retained for existing callers;
|
||||
/// [JellyfinConnection] represents both Jellyfin and Emby.
|
||||
Future<int> pruneUnreferencedJellyfinConnections() async {
|
||||
final all = await connections.list();
|
||||
final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet();
|
||||
|
||||
@@ -306,7 +306,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
),
|
||||
);
|
||||
case JellyfinConnection():
|
||||
addContext(await _createJellyfinAuthContext(connection: connection));
|
||||
addContext(await _createMediaBrowserAuthContext(connection: connection));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
|
||||
return RemoteAuthContext(
|
||||
id: auth.computeAuthContextId(homeSecret),
|
||||
backend: 'plex',
|
||||
backend: account.kind.id,
|
||||
connectionId: account.id,
|
||||
homeSecret: homeSecret,
|
||||
discoveryKey: await auth.deriveDiscoveryKey(homeSecret),
|
||||
@@ -365,9 +365,9 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
);
|
||||
}
|
||||
|
||||
Future<RemoteAuthContext?> _createJellyfinAuthContext({required JellyfinConnection connection}) async {
|
||||
Future<RemoteAuthContext?> _createMediaBrowserAuthContext({required JellyfinConnection connection}) async {
|
||||
if (connection.accessToken.isEmpty || connection.userId.isEmpty || connection.serverMachineId.isEmpty) {
|
||||
appLogger.w('CompanionRemote: Skipping Jellyfin remote identity — incomplete connection ${connection.id}');
|
||||
appLogger.w('CompanionRemote: Skipping MediaBrowser remote identity — incomplete connection ${connection.id}');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
);
|
||||
return RemoteAuthContext(
|
||||
id: auth.computeAuthContextId(homeSecret),
|
||||
backend: 'jellyfin',
|
||||
backend: connection.kind.id,
|
||||
connectionId: connection.id,
|
||||
homeSecret: homeSecret,
|
||||
discoveryKey: await auth.deriveDiscoveryKey(homeSecret),
|
||||
|
||||
@@ -403,7 +403,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// Load all downloads from database
|
||||
final downloads = await _downloadManager.getAllDownloads();
|
||||
|
||||
// Bulk-load all pinned metadata across both backends in a single pass
|
||||
// Bulk-load all pinned metadata across every backend in a single pass
|
||||
// instead of per-item DB calls.
|
||||
final allMetadata = await _downloadManager.getAllPinnedMetadata(
|
||||
preferActiveScope: true,
|
||||
@@ -637,10 +637,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// Fallback: synthesize from episode metadata (missing year, summary)
|
||||
// Only Plex consumers read `raw['key']` (library-section + folder
|
||||
// navigation), so we synthesize the Plex URI for Plex shows and
|
||||
// emit a Jellyfin-shaped item for Jellyfin (Id + Type=Series).
|
||||
// emit a MediaBrowser-shaped item for Jellyfin or Emby
|
||||
// (`Id` + `Type=Series`).
|
||||
final synthesizedRaw = switch (meta.backend) {
|
||||
MediaBackend.plex => <String, dynamic>{'key': '/library/metadata/$showRatingKey'},
|
||||
MediaBackend.jellyfin => <String, dynamic>{'Id': showRatingKey, 'Type': 'Series'},
|
||||
MediaBackend.jellyfin || MediaBackend.emby => <String, dynamic>{'Id': showRatingKey, 'Type': 'Series'},
|
||||
};
|
||||
shows[showRatingKey] = MediaItem(
|
||||
id: showRatingKey,
|
||||
@@ -1376,7 +1377,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
/// Queue every track under an album/artist. Expansion is one
|
||||
/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants]) on
|
||||
/// both backends — Plex branches album→/children, Jellyfin retries
|
||||
/// every backend — Plex branches album→/children, while MediaBrowser retries
|
||||
/// tag-only artists by album-artist credit.
|
||||
Future<int> _queueMusicContainerDownload(
|
||||
MediaItem container,
|
||||
|
||||
@@ -222,7 +222,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
|
||||
/// Whether at least one online server is a Plex server. Used to gate
|
||||
/// Plex-only chrome (server-activities popover, conflict-resolution
|
||||
/// helpers) so they don't render against a Jellyfin-only profile.
|
||||
/// helpers) so it doesn't render against a MediaBrowser-only profile.
|
||||
bool get hasOnlinePlexServers => onlineServerIds.any((id) => _serverManager.getPlexClient(ServerId(id)) != null);
|
||||
|
||||
/// Visibility-filtered server ids whose latest health probe was rejected
|
||||
@@ -263,10 +263,9 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
|
||||
/// Check all online servers for DVR/Live TV availability. Plex servers
|
||||
/// expose `/livetv/dvrs` (one entry per configured DVR with its own
|
||||
/// lineup); Jellyfin servers expose `/LiveTv/Channels` with a single
|
||||
/// lineup); MediaBrowser servers expose `/LiveTv/Channels` with a single
|
||||
/// flat channel list per server (synthesized into one [LiveTvServerInfo]
|
||||
/// with `dvrKey: 'jellyfin'` so the rest of the UI's per-DVR loop works
|
||||
/// uniformly).
|
||||
/// whose backend-derived `dvrKey` keeps the UI's per-DVR identity stable).
|
||||
Future<void> checkLiveTvAvailability() async {
|
||||
if (isDisposed) return;
|
||||
final generation = ++_liveTvCheckGeneration;
|
||||
@@ -285,9 +284,11 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: dvr.key, lineup: dvr.lineup, dvrs: dvrs));
|
||||
}
|
||||
} else if (await liveTv.isAvailable()) {
|
||||
// Jellyfin: no per-DVR partitioning; synthesize a single entry so
|
||||
// the rest of the UI's per-DVR loop works uniformly.
|
||||
newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: 'jellyfin', lineup: null, dvrs: const []));
|
||||
// MediaBrowser: no per-DVR partitioning; synthesize a single entry
|
||||
// so the rest of the UI's per-DVR loop works uniformly.
|
||||
newLiveTvServers.add(
|
||||
LiveTvServerInfo(serverId: serverId, dvrKey: genericClient.backend.id, lineup: null, dvrs: const []),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('LiveTV check failed for server $serverId', error: e);
|
||||
|
||||
@@ -19,8 +19,8 @@ import '../utils/app_logger.dart';
|
||||
|
||||
/// Holds the *current user's playback preferences* (audio/subtitle language
|
||||
/// defaults) for the active profile. Plex profiles fetch from
|
||||
/// `https://clients.plex.tv/api/v2/user`; Jellyfin profiles fetch from
|
||||
/// `/Users/Me` on the bound Jellyfin server.
|
||||
/// `https://clients.plex.tv/api/v2/user`; MediaBrowser profiles use their
|
||||
/// dialect's current-user route on the bound server.
|
||||
///
|
||||
/// Profile *identity* and *switching* are owned by [ActiveProfileProvider]
|
||||
/// and [ActiveProfileBinder]. This provider is just the settings cache so
|
||||
@@ -172,12 +172,12 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
final settingsConnection = await _resolveActiveSettingsConnection();
|
||||
final connection = settingsConnection?.connection;
|
||||
if (connection is JellyfinConnection) {
|
||||
final jellyfinClient = _resolveJellyfinClient(connection);
|
||||
if (jellyfinClient == null) {
|
||||
appLogger.d('UserProfileProvider: default Jellyfin client unavailable, skipping settings refresh');
|
||||
final mediaBrowserClient = _resolveMediaBrowserClient(connection);
|
||||
if (mediaBrowserClient == null) {
|
||||
appLogger.d('UserProfileProvider: default MediaBrowser client unavailable, skipping settings refresh');
|
||||
return;
|
||||
}
|
||||
final profile = await jellyfinClient.fetchUserProfile();
|
||||
final profile = await mediaBrowserClient.fetchUserProfile();
|
||||
if (profile != null && !stale()) {
|
||||
_profileSettings = profile;
|
||||
safeNotifyListeners();
|
||||
@@ -202,7 +202,7 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
}
|
||||
}
|
||||
|
||||
JellyfinClient? _resolveJellyfinClient(JellyfinConnection conn) {
|
||||
JellyfinClient? _resolveMediaBrowserClient(JellyfinConnection conn) {
|
||||
final manager = _serverManager;
|
||||
if (manager == null) return null;
|
||||
final client = manager.getClient(ServerId(conn.serverMachineId));
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../navigation/profile_session_screen.dart';
|
||||
import '../utils/navigation_transitions.dart';
|
||||
import '../widgets/backend_badge.dart';
|
||||
@@ -231,10 +232,13 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
_showDebugTokenDialog();
|
||||
}
|
||||
|
||||
Future<void> _connectToJellyfin() async {
|
||||
Future<void> _connectToMediaBrowser(MediaBrowserDialect dialect) async {
|
||||
if (!await _prepareDatabaseRecoveryForSignIn()) return;
|
||||
if (!mounted) return;
|
||||
final added = await Navigator.push<bool>(context, MaterialPageRoute(builder: (_) => const AddJellyfinScreen()));
|
||||
final added = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => AddJellyfinScreen(dialect: dialect)),
|
||||
);
|
||||
if (!mounted || added != true) return;
|
||||
// The connection persisted and the manager registered the client; move
|
||||
// straight to the main screen. [MainScreen] reads the active client
|
||||
@@ -350,6 +354,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
final isAppleTV = PlatformDetector.isAppleTV();
|
||||
void startBrowserAfterRecovery() => unawaited(_startPlexAfterRecovery(startBrowser));
|
||||
void startQrAfterRecovery() => unawaited(_startPlexAfterRecovery(startQr));
|
||||
const jellyfinDialect = MediaBrowserDialect.jellyfin;
|
||||
const embyDialect = MediaBrowserDialect.emby;
|
||||
void connectToJellyfin() => unawaited(_connectToMediaBrowser(jellyfinDialect));
|
||||
void connectToEmby() => unawaited(_connectToMediaBrowser(embyDialect));
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .stretch,
|
||||
@@ -423,12 +431,22 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FocusableButton(
|
||||
onPressed: _connectToJellyfin,
|
||||
onPressed: connectToJellyfin,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _connectToJellyfin,
|
||||
onPressed: connectToJellyfin,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18),
|
||||
label: Text(t.auth.connectToJellyfin),
|
||||
label: Text(t.auth.connectToMediaBrowser(product: jellyfinDialect.productName)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FocusableButton(
|
||||
onPressed: connectToEmby,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: connectToEmby,
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
icon: const BackendBadge(backend: MediaBackend.emby, size: 18),
|
||||
label: Text(t.auth.connectToMediaBrowser(product: embyDialect.productName)),
|
||||
),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
|
||||
@@ -549,14 +549,14 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
}
|
||||
|
||||
Widget _buildLibraryMatchTile(MediaItem match, int index) {
|
||||
// Plex matches carry their library title; Jellyfin's search-based lookup
|
||||
// Plex matches carry their library title; MediaBrowser search-based lookup
|
||||
// only does when the ancestors call succeeded, so fall back to the server
|
||||
// name alone. The subtitle carries whatever else tells two copies apart.
|
||||
final details = [?_libraryMatchQuality(match), ?(match.libraryTitle == null ? null : match.serverName)];
|
||||
return FocusableListTile(
|
||||
focusNode: _libraryMatchFocusNodes[index],
|
||||
leading: BackendBadge(backend: match.backend, size: 24),
|
||||
title: Text(match.libraryTitle ?? match.serverName ?? match.backend.name),
|
||||
title: Text(match.libraryTitle ?? match.serverName ?? match.backend.dialect?.productName ?? 'Plex'),
|
||||
subtitle: details.isEmpty ? null : Text(details.join(' • ')),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => unawaited(navigateToMediaItemDetails(context, match)),
|
||||
|
||||
@@ -5,7 +5,6 @@ import '../media/ids.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../media/library_query.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
@@ -276,7 +275,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
}
|
||||
|
||||
bool _shouldUsePaginatedLoader(MediaServerClient client) =>
|
||||
client.backend == MediaBackend.jellyfin && widget.hub.id.endsWith('.recent');
|
||||
client.backend.usesMediaBrowserApi && widget.hub.id.endsWith('.recent');
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) async {
|
||||
@@ -358,7 +357,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
|
||||
_applySort();
|
||||
if (!usesCustomLoader && !_usesPaginatedLoader && client != null && loadedCount < totalCount) {
|
||||
_replaceContinuationItems = client.backend == MediaBackend.plex;
|
||||
_replaceContinuationItems = !client.backend.usesMediaBrowserApi;
|
||||
if (_replaceContinuationItems) {
|
||||
_continuation.setContinuation(startIndex: 0, totalCount: 1);
|
||||
} else {
|
||||
|
||||
@@ -52,7 +52,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
|
||||
/// Folders/items returned by the backend's folder API and mapped to neutral
|
||||
/// [MediaItem]s. Plex folder URLs survive in [MediaItem.raw]['key'];
|
||||
/// Jellyfin folders use the item id as their recursive parent id.
|
||||
/// MediaBrowser folders use the item id as their recursive parent id.
|
||||
List<MediaItem> _rootFolders = [];
|
||||
final Map<String, List<MediaItem>> _childrenCache = {};
|
||||
final Set<String> _expandedFolders = {};
|
||||
@@ -60,9 +60,9 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
bool _isLoadingRoot = false;
|
||||
String? _errorMessage;
|
||||
|
||||
/// Generation counter for in-flight loads. Jellyfin folder fetches render
|
||||
/// page-by-page via `onPage`; a root reload or deletion refresh bumps the
|
||||
/// epoch so superseded pagination callbacks are dropped.
|
||||
/// Generation counter for in-flight loads. MediaBrowser folder fetches
|
||||
/// render page-by-page via `onPage`; a root reload or deletion refresh
|
||||
/// bumps the epoch so superseded pagination callbacks are dropped.
|
||||
int _loadEpoch = 0;
|
||||
|
||||
/// Stable expand/cache key for an expandable row: the backend folder key
|
||||
@@ -250,7 +250,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
/// [widget.serverId], not `forItem`'s fall-back-to-any-online resolution.
|
||||
Future<void> _launchFolder(MediaItem folder, {required bool shuffle}) async {
|
||||
final MediaListPlaybackLauncher launcher;
|
||||
if (folder.backend == MediaBackend.jellyfin) {
|
||||
if (folder.backend.usesMediaBrowserApi) {
|
||||
launcher = JellyfinSequentialLauncher(context: context);
|
||||
} else {
|
||||
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
|
||||
@@ -259,22 +259,22 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
await launcher.launchFromFolder(folder: folder, shuffle: shuffle);
|
||||
}
|
||||
|
||||
/// Expandable rows: directory rows plus Jellyfin media containers whose
|
||||
/// Expandable rows: directory rows plus MediaBrowser media containers whose
|
||||
/// direct children form the folder tree. Music libraries expose folder-
|
||||
/// backed artists and albums as MusicArtist/MusicAlbum rather than generic
|
||||
/// Folder DTOs, so those rows must expand instead of opening empty details.
|
||||
bool _isExpandable(MediaItem item) {
|
||||
return item.kind == MediaKind.folder || (item.backend == MediaBackend.jellyfin && _isJellyfinMediaContainer(item));
|
||||
return item.kind == MediaKind.folder || (item.backend.usesMediaBrowserApi && _isMediaBrowserMediaContainer(item));
|
||||
}
|
||||
|
||||
bool _isJellyfinMediaContainer(MediaItem item) {
|
||||
bool _isMediaBrowserMediaContainer(MediaItem item) {
|
||||
if (item.kind == MediaKind.show || item.kind == MediaKind.season) return true;
|
||||
return widget.libraryKind?.isMusic == true && (item.kind == MediaKind.artist || item.kind == MediaKind.album);
|
||||
}
|
||||
|
||||
bool _canPlayFolder(MediaItem item) {
|
||||
if (item.backend == MediaBackend.plex) return true;
|
||||
if (item.backend == MediaBackend.jellyfin) return widget.libraryKind?.isMusic != true;
|
||||
if (item.backend.usesMediaBrowserApi) return widget.libraryKind?.isMusic != true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import 'alpha_jump_helper.dart';
|
||||
/// driven — tapping a letter scrolls to that letter's cumulative offset and
|
||||
/// the highlighted letter follows the visible row.
|
||||
///
|
||||
/// Jellyfin libraries have no per-letter count endpoint. The bar synthesises
|
||||
/// the 27-letter alphabet (`#`, `A`–`Z`) and acts as a name-prefix filter
|
||||
/// that refetches the page when the user picks a letter (matches the JF web
|
||||
/// client's UX).
|
||||
/// MediaBrowser libraries have no per-letter count endpoint. The bar
|
||||
/// synthesises the 27-letter alphabet (`#`, `A`–`Z`) and acts as a name-prefix
|
||||
/// filter that refetches the page when the user picks a letter (matching the
|
||||
/// server web clients' UX).
|
||||
abstract class LibraryAlphaBarStrategy {
|
||||
/// Whether the bar should be rendered at all. Implementations consider
|
||||
/// total item count, sort key, and current filter state.
|
||||
@@ -22,7 +22,7 @@ abstract class LibraryAlphaBarStrategy {
|
||||
required int loadedCharacterCount,
|
||||
required String? sortKey,
|
||||
required bool isFolderGrouping,
|
||||
required String? jellyfinAlphaPrefix,
|
||||
required String? mediaBrowserAlphaPrefix,
|
||||
required bool isPhone,
|
||||
});
|
||||
|
||||
@@ -36,22 +36,22 @@ abstract class LibraryAlphaBarStrategy {
|
||||
});
|
||||
|
||||
/// Letter to highlight given the current scroll-derived index. Plex maps
|
||||
/// the index back through the cumulative offsets; Jellyfin echoes back
|
||||
/// whatever filter is active.
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix});
|
||||
/// the index back through the cumulative offsets; MediaBrowser backends echo
|
||||
/// back whatever filter is active.
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix});
|
||||
|
||||
/// Handle a tap on the letter at [targetIndex]. Plex strategies invoke
|
||||
/// [onPlexJump] with the cumulative item index for in-grid scrolling;
|
||||
/// Jellyfin strategies invoke [onJellyfinPrefixChange] with the next
|
||||
/// MediaBrowser strategies invoke [onMediaBrowserPrefixChange] with the next
|
||||
/// `NameStartsWith` prefix (or `null` to clear the filter when the user
|
||||
/// re-taps the active letter). Each strategy ignores the callback that
|
||||
/// doesn't apply to its UX, so callers can wire both unconditionally.
|
||||
void onLetterPressed(
|
||||
int targetIndex,
|
||||
AlphaJumpHelper helper, {
|
||||
required String? currentJellyfinPrefix,
|
||||
required String? currentMediaBrowserPrefix,
|
||||
required void Function(int index) onPlexJump,
|
||||
required void Function(String? nextPrefix) onJellyfinPrefixChange,
|
||||
required void Function(String? nextPrefix) onMediaBrowserPrefixChange,
|
||||
});
|
||||
|
||||
/// Construct the right strategy for [backend].
|
||||
@@ -67,7 +67,7 @@ abstract class LibraryAlphaBarStrategy {
|
||||
libraryKey: libraryKey,
|
||||
isShared: isShared,
|
||||
),
|
||||
MediaBackend.jellyfin => const JellyfinAlphaBarStrategy(),
|
||||
MediaBackend.jellyfin || MediaBackend.emby => const MediaBrowserAlphaBarStrategy(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
required int loadedCharacterCount,
|
||||
required String? sortKey,
|
||||
required bool isFolderGrouping,
|
||||
required String? jellyfinAlphaPrefix,
|
||||
required String? mediaBrowserAlphaPrefix,
|
||||
required bool isPhone,
|
||||
}) {
|
||||
if (isFolderGrouping) return false;
|
||||
@@ -115,7 +115,8 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
}
|
||||
|
||||
@override
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix}) => helper.currentLetter(index);
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix}) =>
|
||||
helper.currentLetter(index);
|
||||
|
||||
/// Plex jumps the grid to the cumulative offset for the tapped letter —
|
||||
/// the helper's letter list already encodes the per-letter ranges from
|
||||
@@ -124,17 +125,17 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
void onLetterPressed(
|
||||
int targetIndex,
|
||||
AlphaJumpHelper helper, {
|
||||
required String? currentJellyfinPrefix,
|
||||
required String? currentMediaBrowserPrefix,
|
||||
required void Function(int index) onPlexJump,
|
||||
required void Function(String? nextPrefix) onJellyfinPrefixChange,
|
||||
required void Function(String? nextPrefix) onMediaBrowserPrefixChange,
|
||||
}) {
|
||||
onPlexJump(targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jellyfin strategy — synthesises the 27-letter alphabet locally and uses
|
||||
/// the bar as a `NameStartsWith` filter.
|
||||
class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
/// MediaBrowser strategy — synthesises the 27-letter alphabet locally and
|
||||
/// uses the bar as a `NameStartsWith` filter.
|
||||
class MediaBrowserAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
static const _letters = [
|
||||
'#',
|
||||
'A',
|
||||
@@ -165,7 +166,7 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
'Z',
|
||||
];
|
||||
|
||||
const JellyfinAlphaBarStrategy();
|
||||
const MediaBrowserAlphaBarStrategy();
|
||||
|
||||
@override
|
||||
bool shouldShow({
|
||||
@@ -173,13 +174,13 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
required int loadedCharacterCount,
|
||||
required String? sortKey,
|
||||
required bool isFolderGrouping,
|
||||
required String? jellyfinAlphaPrefix,
|
||||
required String? mediaBrowserAlphaPrefix,
|
||||
required bool isPhone,
|
||||
}) {
|
||||
if (isPhone) return false;
|
||||
if (isFolderGrouping) return false;
|
||||
if (loadedCharacterCount == 0) return false;
|
||||
return totalItemCount >= 80 || jellyfinAlphaPrefix != null;
|
||||
return totalItemCount >= 80 || mediaBrowserAlphaPrefix != null;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -193,23 +194,24 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
}
|
||||
|
||||
@override
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix}) => jellyfinAlphaPrefix ?? '';
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix}) =>
|
||||
mediaBrowserAlphaPrefix ?? '';
|
||||
|
||||
/// Jellyfin reuses the alpha bar as a `NameStartsWith` filter. We map the
|
||||
/// bar offset back to a letter (the synthesised `size: 1` entries make
|
||||
/// offset == position in [helper.letters]) and toggle the filter — re-tap
|
||||
/// the active letter to clear, otherwise set the new prefix.
|
||||
/// MediaBrowser backends reuse the alpha bar as a `NameStartsWith` filter.
|
||||
/// We map the bar offset back to a letter (the synthesised `size: 1` entries
|
||||
/// make offset == position in [helper.letters]) and toggle the filter —
|
||||
/// re-tap the active letter to clear, otherwise set the new prefix.
|
||||
@override
|
||||
void onLetterPressed(
|
||||
int targetIndex,
|
||||
AlphaJumpHelper helper, {
|
||||
required String? currentJellyfinPrefix,
|
||||
required String? currentMediaBrowserPrefix,
|
||||
required void Function(int index) onPlexJump,
|
||||
required void Function(String? nextPrefix) onJellyfinPrefixChange,
|
||||
required void Function(String? nextPrefix) onMediaBrowserPrefixChange,
|
||||
}) {
|
||||
if (targetIndex < 0 || targetIndex >= helper.letters.length) return;
|
||||
final letter = helper.letters[targetIndex];
|
||||
final next = (currentJellyfinPrefix == letter) ? null : letter;
|
||||
onJellyfinPrefixChange(next);
|
||||
final next = (currentMediaBrowserPrefix == letter) ? null : letter;
|
||||
onMediaBrowserPrefixChange(next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../media/library_first_character.dart';
|
||||
import '../../../media/library_query.dart';
|
||||
import '../../../media/media_backend.dart';
|
||||
import '../../../media/media_item.dart';
|
||||
import '../../../media/media_kind.dart';
|
||||
import '../../../media/media_library.dart';
|
||||
@@ -211,15 +210,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
AlphaJumpHelper _alphaHelper = AlphaJumpHelper(const []);
|
||||
late LibraryAlphaBarStrategy _alphaStrategy = _createAlphaStrategy();
|
||||
|
||||
/// On Jellyfin libraries the alpha bar acts as a filter (matches the
|
||||
/// JF web client's UX). Holds the active letter (`#`, `A`–`Z`) or null
|
||||
/// when no filter is applied.
|
||||
String? _jellyfinAlphaPrefix;
|
||||
/// On MediaBrowser libraries the alpha bar acts as a filter. Holds the
|
||||
/// active letter (`#`, `A`–`Z`) or null when no filter is applied.
|
||||
String? _mediaBrowserAlphaPrefix;
|
||||
|
||||
/// Pre-fetched filter values for Jellyfin libraries — populated by
|
||||
/// Pre-fetched filter values for MediaBrowser libraries — populated by
|
||||
/// `_loadContent` and consumed by the FiltersBottomSheet so the sheet
|
||||
/// doesn't need to call back into a Plex client for value listings.
|
||||
Map<String, List<MediaFilterValue>> _jellyfinFilterValues = const {};
|
||||
Map<String, List<MediaFilterValue>> _mediaBrowserFilterValues = const {};
|
||||
final ValueNotifier<int> _currentFirstVisibleIndex = ValueNotifier<int>(0);
|
||||
LibraryAlphaScrollMetrics _scrollMetrics = LibraryAlphaScrollMetrics.empty;
|
||||
|
||||
@@ -257,7 +255,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
int _firstCharactersRequestId = 0;
|
||||
static const int _fetchSize = 200;
|
||||
static const int _jellyfinFetchSize = 72;
|
||||
static const int _mediaBrowserFetchSize = 72;
|
||||
Timer? _scrollIdleTimer;
|
||||
bool _rangeLoadScheduled = false;
|
||||
bool _topScrollResetScheduled = false;
|
||||
@@ -306,8 +304,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isJellyfinLibrary => widget.library.backend == MediaBackend.jellyfin;
|
||||
int get _activeFetchSize => _isJellyfinLibrary ? _jellyfinFetchSize : _fetchSize;
|
||||
bool get _isMediaBrowserLibrary => widget.library.backend.usesMediaBrowserApi;
|
||||
int get _activeFetchSize => _isMediaBrowserLibrary ? _mediaBrowserFetchSize : _fetchSize;
|
||||
|
||||
// Focus nodes for filter chips
|
||||
final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip');
|
||||
@@ -518,9 +516,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_currentFirstVisibleIndex.value = 0;
|
||||
|
||||
// Plex returns categories from `/library/sections/{id}/filters` +
|
||||
// `/sorts`; Jellyfin maps `/Items/Filters` into the same shape with
|
||||
// values pre-cached and a hardcoded client-side sort list. Both flow
|
||||
// through the unified [MediaServerClient.fetchLibraryFiltersWithValues].
|
||||
// `/sorts`; MediaBrowser clients map their filter endpoints into the same
|
||||
// shape with values pre-cached and a hardcoded client-side sort list. Both
|
||||
// flow through [MediaServerClient.fetchLibraryFiltersWithValues].
|
||||
try {
|
||||
final client = context.getMediaClientForLibrary(library);
|
||||
final loader = LibraryFilterSortLoader(clientFor: (_) => client);
|
||||
@@ -536,10 +534,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
final sortLibraryType = _sortOptionsLibraryType(restoredGrouping);
|
||||
|
||||
final LoadedFiltersAndSorts loaded;
|
||||
if (library.backend == MediaBackend.jellyfin) {
|
||||
// `/Items/Filters` can be much slower than the paged `/Items` browse
|
||||
// request on large Jellyfin libraries. Load only the local sort list
|
||||
// before page 1, then fill filter values in the background.
|
||||
if (library.backend.usesMediaBrowserApi) {
|
||||
// MediaBrowser filter discovery can be much slower than the paged
|
||||
// `/Items` browse request on large libraries. Load only the local sort
|
||||
// list before page 1, then fill filter values in the background.
|
||||
final sorts = await client.fetchSortOptions(library.id, libraryType: sortLibraryType);
|
||||
loaded = LoadedFiltersAndSorts(filters: const [], sorts: sorts);
|
||||
} else {
|
||||
@@ -554,8 +552,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_filters = loaded.filters;
|
||||
_sortOptions = loaded.sorts;
|
||||
// Plex returns no cached values (filters fetched lazily per-category);
|
||||
// assigning the empty map is a no-op for Plex and a real payload for Jellyfin.
|
||||
_jellyfinFilterValues = loaded.cachedValues;
|
||||
// assigning the empty map is a no-op for Plex and a real payload for MediaBrowser libraries.
|
||||
_mediaBrowserFilterValues = loaded.cachedValues;
|
||||
_selectedFilters = Map.from(savedFilters);
|
||||
_selectedGrouping = restoredGrouping;
|
||||
|
||||
@@ -573,8 +571,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
});
|
||||
_notifyFiltersActive();
|
||||
|
||||
if (library.backend == MediaBackend.jellyfin) {
|
||||
_loadJellyfinFiltersInBackground(generation, libraryGlobalKey, library);
|
||||
if (library.backend.usesMediaBrowserApi) {
|
||||
_loadMediaBrowserFiltersInBackground(generation, libraryGlobalKey, library);
|
||||
}
|
||||
|
||||
// Load items and first characters in parallel.
|
||||
@@ -593,7 +591,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
}
|
||||
}
|
||||
|
||||
void _loadJellyfinFiltersInBackground(int generation, String libraryGlobalKey, MediaLibrary library) {
|
||||
void _loadMediaBrowserFiltersInBackground(int generation, String libraryGlobalKey, MediaLibrary library) {
|
||||
final client = context.tryGetMediaClientForServer(serverIdOrNull(library.serverId));
|
||||
if (client == null) return;
|
||||
unawaited(
|
||||
@@ -603,12 +601,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
|
||||
setState(() {
|
||||
_filters = result.filters;
|
||||
_jellyfinFilterValues = result.cachedValues;
|
||||
_mediaBrowserFilterValues = result.cachedValues;
|
||||
});
|
||||
})
|
||||
.catchError((Object e, StackTrace st) {
|
||||
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
|
||||
appLogger.w('Jellyfin library filters failed; browse content remains available', error: e, stackTrace: st);
|
||||
appLogger.w(
|
||||
'MediaBrowser library filters failed; browse content remains available',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -627,7 +629,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
});
|
||||
}
|
||||
|
||||
/// Initial UI state both Plex and Jellyfin paths need before fetching:
|
||||
/// Initial UI state both Plex and MediaBrowser paths need before fetching:
|
||||
/// loading flag set, lists cleared, filter/sort caches reset.
|
||||
void _resetTopOfPageState() {
|
||||
setState(() {
|
||||
@@ -637,8 +639,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
resetPaginationState();
|
||||
_filters = [];
|
||||
_sortOptions = [];
|
||||
_jellyfinFilterValues = const {};
|
||||
_jellyfinAlphaPrefix = null;
|
||||
_mediaBrowserFilterValues = const {};
|
||||
_mediaBrowserAlphaPrefix = null;
|
||||
_selectedFilters = {};
|
||||
_selectedSort = null;
|
||||
_isSortDescending = false;
|
||||
@@ -673,10 +675,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
filterParams['includeCollections'] = '1';
|
||||
|
||||
// Jellyfin alpha-bar filter — picked up by DataAggregationService and
|
||||
// converted to NameStartsWith / NameLessThan on the wire.
|
||||
if (_jellyfinAlphaPrefix != null) {
|
||||
filterParams['alphaPrefix'] = _jellyfinAlphaPrefix!;
|
||||
// MediaBrowser alpha-bar filter — converted to NameStartsWith /
|
||||
// NameLessThan on the wire.
|
||||
if (_mediaBrowserAlphaPrefix != null) {
|
||||
filterParams['alphaPrefix'] = _mediaBrowserAlphaPrefix!;
|
||||
}
|
||||
|
||||
return filterParams;
|
||||
@@ -1008,10 +1010,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
libraryKey: widget.library.globalKey,
|
||||
loadFilterValues: _loadFilterValues,
|
||||
onBack: onBack,
|
||||
// Pre-populated values arrive only from backends that bundle them
|
||||
// with the category listing (Jellyfin's `/Items/Filters`). The empty
|
||||
// map for Plex libraries falls through to lazy `getFilterValues`.
|
||||
cachedValues: _jellyfinFilterValues.isEmpty ? null : _jellyfinFilterValues,
|
||||
// Pre-populated values arrive from MediaBrowser filter discovery. The
|
||||
// empty map for Plex libraries falls through to lazy `getFilterValues`.
|
||||
cachedValues: _mediaBrowserFilterValues.isEmpty ? null : _mediaBrowserFilterValues,
|
||||
onFiltersChanged: _applyFilters,
|
||||
);
|
||||
}
|
||||
@@ -1039,9 +1040,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
final client = context.tryGetPlexClientForServer(serverIdOrNull(widget.library.serverId));
|
||||
if (client != null) return client.getFilterValues(filter.key);
|
||||
|
||||
// Jellyfin's canonical filter values come from the cached `/Items/Filters`
|
||||
// payload. If that payload missed a category, there is no neutral endpoint
|
||||
// to query yet, so return an empty list instead of routing to a Plex-only API.
|
||||
// MediaBrowser canonical filter values come from the cached filter
|
||||
// discovery payload. If that payload missed a category, there is no
|
||||
// neutral endpoint to query, so don't route to a Plex-only API.
|
||||
return const [];
|
||||
}
|
||||
|
||||
@@ -1217,7 +1218,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
/// how many items we've scrolled past relative to the API's cumulative
|
||||
/// firstCharacter counts.
|
||||
String _alphaLetterFor(int index) =>
|
||||
_alphaStrategy.currentLetter(index, _alphaHelper, jellyfinAlphaPrefix: _jellyfinAlphaPrefix);
|
||||
_alphaStrategy.currentLetter(index, _alphaHelper, mediaBrowserAlphaPrefix: _mediaBrowserAlphaPrefix);
|
||||
|
||||
/// Whether the alpha jump bar should be shown.
|
||||
/// Only shown when sorting by title (titleSort) and not in folders mode.
|
||||
@@ -1233,7 +1234,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
loadedCharacterCount: _firstCharacters.length,
|
||||
sortKey: _selectedSort?.key,
|
||||
isFolderGrouping: _selectedGrouping == 'folders',
|
||||
jellyfinAlphaPrefix: _jellyfinAlphaPrefix,
|
||||
mediaBrowserAlphaPrefix: _mediaBrowserAlphaPrefix,
|
||||
isPhone: _isPhone(context),
|
||||
);
|
||||
|
||||
@@ -1351,15 +1352,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
/// Handle a tap on the letter at [targetIndex] in the alpha bar. The
|
||||
/// active [LibraryAlphaBarStrategy] owns the per-backend behaviour and
|
||||
/// invokes one of the two callbacks — Plex scrolls the grid to the
|
||||
/// cumulative item offset, Jellyfin toggles a `NameStartsWith` filter
|
||||
/// (matches the JF web client UX).
|
||||
/// cumulative item offset, while MediaBrowser backends toggle a
|
||||
/// `NameStartsWith` filter.
|
||||
void _jumpToIndex(int targetIndex) {
|
||||
_alphaStrategy.onLetterPressed(
|
||||
targetIndex,
|
||||
_alphaHelper,
|
||||
currentJellyfinPrefix: _jellyfinAlphaPrefix,
|
||||
currentMediaBrowserPrefix: _mediaBrowserAlphaPrefix,
|
||||
onPlexJump: _scrollGridToIndex,
|
||||
onJellyfinPrefixChange: _applyJellyfinAlphaPrefix,
|
||||
onMediaBrowserPrefixChange: _applyMediaBrowserAlphaPrefix,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1376,12 +1377,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_scrollToItemIndex(clamped);
|
||||
}
|
||||
|
||||
/// Apply the new Jellyfin `NameStartsWith` prefix from the alpha bar and
|
||||
/// Apply a MediaBrowser `NameStartsWith` prefix from the alpha bar and
|
||||
/// refetch from the top of the now-filtered dataset. Used by
|
||||
/// [JellyfinAlphaBarStrategy] via [_jumpToIndex].
|
||||
void _applyJellyfinAlphaPrefix(String? nextPrefix) {
|
||||
/// [MediaBrowserAlphaBarStrategy] via [_jumpToIndex].
|
||||
void _applyMediaBrowserAlphaPrefix(String? nextPrefix) {
|
||||
setState(() {
|
||||
_jellyfinAlphaPrefix = nextPrefix;
|
||||
_mediaBrowserAlphaPrefix = nextPrefix;
|
||||
// Clear loaded items + total so the grid blanks while the new filtered
|
||||
// page loads. PaginatedItemLoader internals will repopulate from
|
||||
// offset 0 once the next fetchPage call returns.
|
||||
@@ -1595,8 +1596,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
if (rowHeight <= 0) return _activeFetchSize;
|
||||
final visibleRows = (screenSize.height / rowHeight).ceil() + 1;
|
||||
final visibleCount = visibleRows * columnCount;
|
||||
if (_isJellyfinLibrary) {
|
||||
return (visibleCount * 2).clamp(36, _jellyfinFetchSize).toInt();
|
||||
if (_isMediaBrowserLibrary) {
|
||||
return (visibleCount * 2).clamp(36, _mediaBrowserFetchSize).toInt();
|
||||
}
|
||||
return (visibleCount * 3).clamp(100, 500).toInt();
|
||||
} catch (_) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../media/media_backend.dart';
|
||||
import '../../media/media_browser_dialect.dart';
|
||||
import '../../theme/mono_tokens.dart';
|
||||
import '../../profiles/profile.dart';
|
||||
import '../../widgets/backend_badge.dart';
|
||||
@@ -18,8 +19,8 @@ import 'add_plex_account_screen.dart';
|
||||
/// When [targetProfile] is provided, also offers a "Borrow from another
|
||||
/// profile" option that opens [BorrowConnectionScreen] for the target. The
|
||||
/// global Connections screen invokes this without a target — Plex auto-
|
||||
/// surfaces its Home users as new profiles, Jellyfin binds to the active
|
||||
/// profile via [AddJellyfinScreen].
|
||||
/// surfaces its Home users as new profiles, while MediaBrowser servers bind
|
||||
/// to the active profile via [AddJellyfinScreen].
|
||||
///
|
||||
/// Pops with `true` after the underlying flow succeeds so the parent list
|
||||
/// refreshes; pops with `null` (the default) when the user backs out.
|
||||
@@ -31,6 +32,8 @@ class AddConnectionScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scoped = targetProfile != null;
|
||||
const jellyfinDialect = MediaBrowserDialect.jellyfin;
|
||||
const embyDialect = MediaBrowserDialect.emby;
|
||||
final options = <_BackendOption>[
|
||||
_BackendOption(
|
||||
backend: MediaBackend.plex,
|
||||
@@ -40,11 +43,25 @@ class AddConnectionScreen extends StatelessWidget {
|
||||
),
|
||||
_BackendOption(
|
||||
backend: MediaBackend.jellyfin,
|
||||
title: t.addServer.connectToJellyfinCard,
|
||||
title: t.addServer.connectToMediaBrowserCard(product: jellyfinDialect.productName),
|
||||
subtitle: scoped
|
||||
? t.addServer.connectToJellyfinCardSubtitleScoped(name: targetProfile!.displayName)
|
||||
: t.addServer.connectToJellyfinCardSubtitle,
|
||||
builder: (_) => AddJellyfinScreen(targetProfile: targetProfile),
|
||||
? t.addServer.connectToMediaBrowserCardSubtitleScoped(
|
||||
product: jellyfinDialect.productName,
|
||||
name: targetProfile!.displayName,
|
||||
)
|
||||
: t.addServer.connectToMediaBrowserCardSubtitle,
|
||||
builder: (_) => AddJellyfinScreen(targetProfile: targetProfile, dialect: jellyfinDialect),
|
||||
),
|
||||
_BackendOption(
|
||||
backend: MediaBackend.emby,
|
||||
title: t.addServer.connectToMediaBrowserCard(product: embyDialect.productName),
|
||||
subtitle: scoped
|
||||
? t.addServer.connectToMediaBrowserCardSubtitleScoped(
|
||||
product: embyDialect.productName,
|
||||
name: targetProfile!.displayName,
|
||||
)
|
||||
: t.addServer.connectToMediaBrowserCardSubtitle,
|
||||
builder: (_) => AddJellyfinScreen(targetProfile: targetProfile, dialect: embyDialect),
|
||||
),
|
||||
if (scoped)
|
||||
_BackendOption(
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../focus/focusable_button.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../media/media_browser_dialect.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../profiles/active_profile_binder.dart';
|
||||
import '../../profiles/active_profile_provider.dart';
|
||||
@@ -69,26 +70,28 @@ bool shouldPromptForJellyfinProfileSelection({
|
||||
return targetProfile == null && activeProfile == null && hasProfiles;
|
||||
}
|
||||
|
||||
/// Three-step form to add a Jellyfin server:
|
||||
/// Three-step form to add a Jellyfin or Emby server:
|
||||
/// 1. Probe URL candidates (`/System/Info/Public`).
|
||||
/// 2. Username + password (`/Users/AuthenticateByName`) **or** Quick Connect
|
||||
/// (`/QuickConnect/Initiate` → poll → `/Users/AuthenticateWithQuickConnect`).
|
||||
/// 2. Username + password (`/Users/AuthenticateByName`) or Quick Connect
|
||||
/// when supported by the selected [dialect].
|
||||
/// 3. Persist via [ConnectionRegistry] and create a [ProfileConnection]
|
||||
/// row binding the server to [targetProfile] (or the active profile,
|
||||
/// if not provided). When the target *is* the active profile we also
|
||||
/// register the client with the manager so libraries refresh
|
||||
/// immediately; otherwise the binder picks it up on the next switch.
|
||||
class AddJellyfinScreen extends StatefulWidget {
|
||||
/// When set, the new Jellyfin connection is bound to this profile via a
|
||||
/// When set, the new MediaBrowser connection is bound to this profile via a
|
||||
/// [ProfileConnection] row. When null, falls back to the currently active
|
||||
/// profile (typical for the global Connections screen entry point).
|
||||
final Profile? targetProfile;
|
||||
final MediaBrowserDialect dialect;
|
||||
final FutureOr<JellyfinConnectionAuthService> Function()? _authServiceFactory;
|
||||
final FutureOr<List<DiscoveredJellyfinServer>> Function()? _localDiscoveryFactory;
|
||||
|
||||
const AddJellyfinScreen({
|
||||
super.key,
|
||||
this.targetProfile,
|
||||
this.dialect = MediaBrowserDialect.jellyfin,
|
||||
@visibleForTesting this._authServiceFactory,
|
||||
@visibleForTesting this._localDiscoveryFactory,
|
||||
});
|
||||
@@ -157,7 +160,10 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
final factory = widget._localDiscoveryFactory;
|
||||
final servers = factory != null
|
||||
? await factory()
|
||||
: await JellyfinLanDiscoveryService().discover(responseWindow: const Duration(milliseconds: 1300));
|
||||
: await JellyfinLanDiscoveryService().discover(
|
||||
dialect: widget.dialect,
|
||||
responseWindow: const Duration(milliseconds: 1300),
|
||||
);
|
||||
if (!mounted || attemptId != _localDiscoveryAttemptId) return;
|
||||
setState(() {
|
||||
_localServers = servers;
|
||||
@@ -165,7 +171,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
_syncDiscoveredServerFocusNodes(servers);
|
||||
});
|
||||
} catch (e, st) {
|
||||
appLogger.w('Add Jellyfin local discovery failed', error: e, stackTrace: st);
|
||||
appLogger.w('Add ${widget.dialect.productName} local discovery failed', error: e, stackTrace: st);
|
||||
if (!mounted || attemptId != _localDiscoveryAttemptId) return;
|
||||
setState(() => _isDiscoveringLocalServers = false);
|
||||
}
|
||||
@@ -201,9 +207,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
}
|
||||
|
||||
Future<void> _probe() async {
|
||||
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls());
|
||||
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls(), dialect: widget.dialect);
|
||||
if (input.probeBaseUrls.isEmpty) {
|
||||
setErrorText(t.addServer.enterJellyfinUrlError);
|
||||
setErrorText(t.addServer.enterMediaBrowserUrlError(product: widget.dialect.productName));
|
||||
return;
|
||||
}
|
||||
final autoStartQuickConnect = await runAsync<bool>(
|
||||
@@ -214,7 +220,10 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
baseUrlsToPersist: input.explicitBaseUrls,
|
||||
baseUrlValidationGroups: input.validationBaseUrlGroups,
|
||||
);
|
||||
final qcEnabled = await auth.isQuickConnectEnabled(endpoint.activeBaseUrl);
|
||||
final serverDialect = endpoint.serverInfo.dialect ?? widget.dialect;
|
||||
final qcEnabled = widget.dialect.supportsQuickConnect && serverDialect.supportsQuickConnect
|
||||
? await auth.isQuickConnectEnabled(endpoint.activeBaseUrl)
|
||||
: false;
|
||||
if (!mounted) return false;
|
||||
setState(() {
|
||||
_serverEndpoint = endpoint;
|
||||
@@ -268,7 +277,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
},
|
||||
errorMapper: (e) {
|
||||
if (e is MediaServerAuthException) return e.message;
|
||||
appLogger.e('Add Jellyfin failed', error: e);
|
||||
appLogger.e('Add ${widget.dialect.productName} failed', error: e);
|
||||
return t.addServer.signInFailed(error: e.toString());
|
||||
},
|
||||
);
|
||||
@@ -278,6 +287,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
final info = _serverInfo;
|
||||
final endpoint = _serverEndpoint;
|
||||
if (info == null || endpoint == null) return;
|
||||
if (!widget.dialect.supportsQuickConnect || !(info.dialect ?? widget.dialect).supportsQuickConnect) return;
|
||||
final attemptId = ++_qcAttemptId;
|
||||
setState(() => _qcCancelled = false);
|
||||
await runAsync<void>(
|
||||
@@ -440,7 +450,12 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
if (authServiceFactory != null) return await authServiceFactory();
|
||||
final clientVersion = await resolveJellyfinClientVersion();
|
||||
final deviceName = await _resolveDeviceName();
|
||||
return JellyfinConnectionAuthService(clientName: 'Plezy', clientVersion: clientVersion, deviceName: deviceName);
|
||||
return JellyfinConnectionAuthService(
|
||||
clientName: 'Plezy',
|
||||
clientVersion: clientVersion,
|
||||
deviceName: deviceName,
|
||||
dialect: widget.dialect,
|
||||
);
|
||||
}
|
||||
|
||||
/// The raw name, not a header-sanitized one: the Jellyfin `MediaBrowser`
|
||||
@@ -455,9 +470,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.addServer.addJellyfinTitle),
|
||||
title: Text(t.addServer.addMediaBrowserTitle(product: widget.dialect.productName)),
|
||||
slivers: [
|
||||
if (_qcInitiation != null)
|
||||
if (widget.dialect.supportsQuickConnect && _qcInitiation != null)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
@@ -509,7 +524,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
decoration: InputDecoration(
|
||||
labelText: t.addServer.serverUrls,
|
||||
// URL example — intentionally not localized.
|
||||
hintText: 'https://jellyfin.example.com',
|
||||
hintText: widget.dialect.exampleBaseUrl,
|
||||
helperText: _serverInfo == null ? t.addServer.serverUrlsHelper : null,
|
||||
prefixIcon: const AppIcon(Symbols.link_rounded, fill: 1),
|
||||
),
|
||||
@@ -560,7 +575,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
labelText: t.addServer.password,
|
||||
prefixIcon: const AppIcon(Symbols.lock_rounded, fill: 1),
|
||||
),
|
||||
// Empty password is valid for some Jellyfin setups, so don't
|
||||
// Empty passwords are valid on some MediaBrowser servers, so don't
|
||||
// require a value.
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -574,7 +589,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
label: Text(t.addServer.signIn),
|
||||
),
|
||||
),
|
||||
if (_quickConnectEnabled) ...[
|
||||
if (widget.dialect.supportsQuickConnect && _quickConnectEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
FocusableButton(
|
||||
focusNode: _quickConnectFocus,
|
||||
@@ -609,7 +624,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
children: [
|
||||
Text(_serverInfo!.serverName, style: theme.textTheme.titleSmall),
|
||||
Text(
|
||||
'Jellyfin ${_serverInfo!.version}',
|
||||
'${widget.dialect.productName} ${_serverInfo!.version}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
],
|
||||
@@ -649,7 +664,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.addServer.searchingLocalServers,
|
||||
t.addServer.searchingLocalMediaBrowserServers(product: widget.dialect.productName),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
),
|
||||
@@ -662,7 +677,10 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
final tokensRef = tokens(context);
|
||||
return [
|
||||
const SizedBox(height: 16),
|
||||
Text(t.addServer.localServers, style: theme.textTheme.titleSmall),
|
||||
Text(
|
||||
t.addServer.localMediaBrowserServers(product: widget.dialect.productName),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final (i, server) in _localServers.indexed) ...[
|
||||
if (i > 0) SizedBox(height: tokensRef.groupGap),
|
||||
|
||||
@@ -43,8 +43,11 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
await runAsync<void>(
|
||||
() async {
|
||||
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls());
|
||||
final endpoint = await JellyfinEndpointDiscovery().raceEndpoints(
|
||||
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(
|
||||
_enteredUrls(),
|
||||
dialect: widget.connection.dialect,
|
||||
);
|
||||
final endpoint = await JellyfinEndpointDiscovery(dialect: widget.connection.dialect).raceEndpoints(
|
||||
input.probeBaseUrls,
|
||||
preferredUrl: widget.connection.baseUrl,
|
||||
expectedMachineId: widget.connection.serverMachineId,
|
||||
@@ -63,7 +66,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
||||
},
|
||||
errorMapper: (e) {
|
||||
if (e is MediaServerUrlException) return e.message;
|
||||
appLogger.e('Edit Jellyfin connection failed', error: e);
|
||||
appLogger.e('Edit ${widget.connection.dialect.productName} connection failed', error: e);
|
||||
return t.addServer.couldNotReachServer(error: e.toString());
|
||||
},
|
||||
);
|
||||
@@ -75,7 +78,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.connections.editJellyfinTitle),
|
||||
title: Text(t.connections.editMediaBrowserTitle(product: widget.connection.dialect.productName)),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -86,7 +89,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
Text(
|
||||
t.connections.editJellyfinIntro(serverName: widget.connection.serverName),
|
||||
t.connections.editMediaBrowserIntro(serverName: widget.connection.serverName),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
+20
-14
@@ -12,18 +12,21 @@ import '../utils/isolate_helper.dart';
|
||||
///
|
||||
/// Stores raw JSON keyed by `serverId:endpoint` in the shared `ApiCache`
|
||||
/// Drift table. `serverId` values are globally unique across connected
|
||||
/// backends, so Plex and Jellyfin entries never collide despite sharing
|
||||
/// backends, so Plex and MediaBrowser entries never collide despite sharing
|
||||
/// the same table.
|
||||
///
|
||||
/// Plex- and Jellyfin-specific helpers (item-id pinning, metadata parsing)
|
||||
/// Plex- and MediaBrowser-specific helpers (item-id pinning, metadata parsing)
|
||||
/// live on subclasses [PlexApiCache] / [JellyfinApiCache], which also
|
||||
/// implement the abstract [getMetadata] / [pinForOffline] / [deleteForItem]
|
||||
/// methods so callers can dispatch via [forBackend] instead of switching on
|
||||
/// the backend type at every call site.
|
||||
class ApiCacheSingleton<T extends ApiCache> {
|
||||
ApiCacheSingleton(this.backend, this.typeName);
|
||||
ApiCacheSingleton(this.backends, this.typeName);
|
||||
|
||||
final MediaBackend backend;
|
||||
/// Every backend this cache answers for. Jellyfin and Emby share one
|
||||
/// instance: their DTO shapes are identical and cache rows are keyed by the
|
||||
/// compound `machineId/userId` scope, so there is nothing to isolate.
|
||||
final Set<MediaBackend> backends;
|
||||
final String typeName;
|
||||
T? _instance;
|
||||
|
||||
@@ -37,7 +40,7 @@ class ApiCacheSingleton<T extends ApiCache> {
|
||||
|
||||
void install(T instance) {
|
||||
_instance = instance;
|
||||
ApiCache.registerInstance(backend, instance);
|
||||
ApiCache.registerInstance(instance, backends);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,14 +68,17 @@ Map<String, MediaItem> decodeCachedMediaRows<T>(
|
||||
abstract class ApiCache {
|
||||
static final Map<MediaBackend, ApiCache> _byBackend = {};
|
||||
|
||||
/// Registers a backend cache. A new database marks a new application/test
|
||||
/// lifecycle, so registrations tied to the previous database are discarded
|
||||
/// instead of leaving backend dispatch pointed at a closed connection.
|
||||
static void registerInstance(MediaBackend backend, ApiCache cache) {
|
||||
if (_byBackend.values.any((registered) => !identical(registered.database, cache.database))) {
|
||||
/// Registers [instance] for each requested backend. A new database marks a
|
||||
/// new application/test lifecycle, so registrations tied to the previous
|
||||
/// database are discarded instead of leaving backend dispatch pointed at a
|
||||
/// closed connection.
|
||||
static void registerInstance(ApiCache instance, Set<MediaBackend> backends) {
|
||||
if (_byBackend.values.any((registered) => !identical(registered.database, instance.database))) {
|
||||
_byBackend.clear();
|
||||
}
|
||||
_byBackend[backend] = cache;
|
||||
for (final backend in backends) {
|
||||
_byBackend[backend] = instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the cache for [backend]. Plex is the legacy default — covers items
|
||||
@@ -169,7 +175,7 @@ abstract class ApiCache {
|
||||
/// Pull pinned rows for [serverId] and extract the first capture group of
|
||||
/// [keyPattern] from each `cacheKey`. Returns the unique set of captured
|
||||
/// ids — backend subclasses use this to enumerate their pinned items
|
||||
/// (Plex ratingKeys, Jellyfin item ids).
|
||||
/// (Plex ratingKeys, MediaBrowser item ids).
|
||||
Future<Set<String>> extractPinnedIds(ServerId serverId, RegExp keyPattern) async {
|
||||
final rows = await (_db.select(
|
||||
_db.apiCache,
|
||||
@@ -215,7 +221,7 @@ abstract class ApiCache {
|
||||
/// [itemId] so reloads (`getMetadata` / `getAllPinnedMetadata`) reflect the
|
||||
/// state without having to refetch from the server. No-op when the row
|
||||
/// isn't cached. Backend subclasses know which JSON fields to mutate
|
||||
/// (Plex `viewCount`, Jellyfin `UserData.PlayCount` / `Played`).
|
||||
/// (Plex `viewCount`, MediaBrowser `UserData.PlayCount` / `Played`).
|
||||
///
|
||||
/// Optional positional progress fields ([viewOffsetMs], [lastViewedAt],
|
||||
/// [viewedLeafCount]) let the offline-watch-sync service mirror richer
|
||||
@@ -228,7 +234,7 @@ abstract class ApiCache {
|
||||
/// shape + units are not. Adding a new watch-state input here means
|
||||
/// updating *both* concrete impls ([PlexApiCache.applyWatchState],
|
||||
/// [JellyfinApiCache.applyWatchState]) — Plex stores epoch-seconds and
|
||||
/// flat fields, Jellyfin stores ISO-8601 + ticks under `UserData`.
|
||||
/// flat fields, while Jellyfin and Emby store ISO-8601 + ticks under `UserData`.
|
||||
/// The mutations are too short (~3 lines per backend) for a shared
|
||||
/// adapter to be a net win, so they live duplicated by design.
|
||||
Future<void> applyWatchState({
|
||||
|
||||
@@ -23,7 +23,12 @@ class CachedPlaybackMetadataService {
|
||||
try {
|
||||
return switch (backend) {
|
||||
MediaBackend.plex => _fetchPlexMediaSourceInfo(ServerId(cacheServerId), itemId, mediaIndex: mediaIndex),
|
||||
MediaBackend.jellyfin => _fetchJellyfinMediaSourceInfo(cacheServerId, itemId, mediaIndex: mediaIndex),
|
||||
MediaBackend.jellyfin || MediaBackend.emby => _fetchJellyfinMediaSourceInfo(
|
||||
cacheServerId,
|
||||
itemId,
|
||||
backend: backend,
|
||||
mediaIndex: mediaIndex,
|
||||
),
|
||||
};
|
||||
} catch (e) {
|
||||
appLogger.d('Cached media source info unavailable for $cacheServerId:$itemId', error: e);
|
||||
@@ -48,9 +53,10 @@ class CachedPlaybackMetadataService {
|
||||
creditsPattern: creditsPattern,
|
||||
forceChapterFallback: forceChapterFallback,
|
||||
),
|
||||
MediaBackend.jellyfin => _fetchJellyfinPlaybackExtras(
|
||||
MediaBackend.jellyfin || MediaBackend.emby => _fetchJellyfinPlaybackExtras(
|
||||
cacheServerId,
|
||||
itemId,
|
||||
backend: backend,
|
||||
introPattern: introPattern,
|
||||
creditsPattern: creditsPattern,
|
||||
forceChapterFallback: forceChapterFallback,
|
||||
@@ -96,9 +102,10 @@ class CachedPlaybackMetadataService {
|
||||
static Future<MediaSourceInfo?> _fetchJellyfinMediaSourceInfo(
|
||||
String cacheServerId,
|
||||
String itemId, {
|
||||
required MediaBackend backend,
|
||||
required int mediaIndex,
|
||||
}) async {
|
||||
final resolved = await _jellyfinRawItem(cacheServerId, itemId);
|
||||
final resolved = await _jellyfinRawItem(cacheServerId, itemId, backend: backend);
|
||||
final raw = resolved.raw;
|
||||
final sources = raw['MediaSources'];
|
||||
if (sources is! List || sources.isEmpty) return null;
|
||||
@@ -110,13 +117,14 @@ class CachedPlaybackMetadataService {
|
||||
static Future<PlaybackExtras?> _fetchJellyfinPlaybackExtras(
|
||||
String cacheServerId,
|
||||
String itemId, {
|
||||
required MediaBackend backend,
|
||||
String? introPattern,
|
||||
String? creditsPattern,
|
||||
bool forceChapterFallback = false,
|
||||
}) async {
|
||||
final resolved = await _jellyfinRawItem(cacheServerId, itemId);
|
||||
final resolved = await _jellyfinRawItem(cacheServerId, itemId, backend: backend);
|
||||
final raw = resolved.raw;
|
||||
final markers = await _jellyfinMediaSegmentMarkers(resolved.scopeId, itemId);
|
||||
final markers = await _jellyfinMediaSegmentMarkers(resolved.scopeId, itemId, backend: backend);
|
||||
return jellyfinPlaybackExtrasFromRaw(
|
||||
raw,
|
||||
itemId,
|
||||
@@ -127,10 +135,14 @@ class CachedPlaybackMetadataService {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<MediaMarker>> _jellyfinMediaSegmentMarkers(String cacheServerId, String itemId) async {
|
||||
static Future<List<MediaMarker>> _jellyfinMediaSegmentMarkers(
|
||||
String cacheServerId,
|
||||
String itemId, {
|
||||
required MediaBackend backend,
|
||||
}) async {
|
||||
try {
|
||||
final raw = await ApiCache.forBackend(
|
||||
MediaBackend.jellyfin,
|
||||
backend,
|
||||
).get(ServerId(cacheServerId), JellyfinApiCache.mediaSegmentsEndpoint(itemId));
|
||||
return jellyfinMediaSegmentsToMarkers(raw);
|
||||
} catch (e) {
|
||||
@@ -141,11 +153,12 @@ class CachedPlaybackMetadataService {
|
||||
|
||||
static Future<({Map<String, dynamic> raw, String scopeId})> _jellyfinRawItem(
|
||||
String cacheServerId,
|
||||
String itemId,
|
||||
) async {
|
||||
final cache = ApiCache.forBackend(MediaBackend.jellyfin);
|
||||
String itemId, {
|
||||
required MediaBackend backend,
|
||||
}) async {
|
||||
final cache = ApiCache.forBackend(backend);
|
||||
final resolved = await JellyfinCacheResolver(cache.database).findItem(cacheServerId, itemId);
|
||||
if (resolved == null) throw StateError('No Jellyfin cache row for $cacheServerId:$itemId');
|
||||
if (resolved == null) throw StateError('No MediaBrowser cache row for $cacheServerId:$itemId');
|
||||
return (raw: jsonDecode(resolved.cacheRow.data) as Map<String, dynamic>, scopeId: resolved.key.scopeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ import '../../utils/app_logger.dart';
|
||||
///
|
||||
/// Proves same-account membership via a backend-derived shared secret.
|
||||
///
|
||||
/// Plex uses the Plex Home metadata available to signed-in devices. Jellyfin
|
||||
/// uses the stable server/user identity available after sign-in, matching the
|
||||
/// same local-LAN trust model: peers that know the same backend identity can
|
||||
/// discover and authenticate each other without a central pairing round-trip.
|
||||
/// Plex uses the Plex Home metadata available to signed-in devices. The
|
||||
/// MediaBrowser backends (Jellyfin and Emby) use the stable server/user
|
||||
/// identity available after sign-in, matching the same local-LAN trust model:
|
||||
/// peers that know the same backend identity can discover and authenticate
|
||||
/// each other without a central pairing round-trip.
|
||||
class RemoteAuthService {
|
||||
RemoteAuthService._();
|
||||
static final instance = RemoteAuthService._();
|
||||
@@ -76,7 +77,14 @@ class RemoteAuthService {
|
||||
return deriveHomeSecret(home.id, admin.uuid);
|
||||
}
|
||||
|
||||
/// Derive a companion remote secret from a Jellyfin server/user identity.
|
||||
/// Derive a companion remote secret from a MediaBrowser server/user identity
|
||||
/// — Jellyfin or Emby.
|
||||
///
|
||||
/// Both peers of a pair are signed into the same server and user, so both
|
||||
/// derive the same secret regardless of dialect. The `jellyfin-secret` HKDF
|
||||
/// `info` label and the `jellyfin:` cache prefix are therefore fixed domain
|
||||
/// separators, not dialect discriminators: changing either would invalidate
|
||||
/// every existing pairing for zero benefit.
|
||||
Future<List<int>> deriveJellyfinSecret({required String serverMachineId, required String userId}) async {
|
||||
final normalizedServerId = serverMachineId.toLowerCase();
|
||||
final normalizedUserId = userId.toLowerCase();
|
||||
@@ -98,7 +106,7 @@ class RemoteAuthService {
|
||||
_cachedSecret = await secretKey.extractBytes();
|
||||
_cachedSecretKey = cacheKey;
|
||||
|
||||
appLogger.d('RemoteAuth: Derived Jellyfin secret');
|
||||
appLogger.d('RemoteAuth: Derived MediaBrowser secret');
|
||||
return _cachedSecret!;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,7 @@ class CredentialVault {
|
||||
|
||||
static Future<Map<String, Object?>> protectConnectionConfig(String kind, Map<String, Object?> config) async {
|
||||
final copy = Map<String, Object?>.from(config);
|
||||
final tokenKey = switch (kind) {
|
||||
'plex' => 'accountToken',
|
||||
'jellyfin' => 'accessToken',
|
||||
_ => null,
|
||||
};
|
||||
final tokenKey = _tokenKeyForKind(kind);
|
||||
final token = tokenKey == null ? null : copy[tokenKey];
|
||||
if (token is String) copy[tokenKey!] = await protect(token);
|
||||
if (kind == 'plex') {
|
||||
@@ -81,11 +77,7 @@ class CredentialVault {
|
||||
Map<String, dynamic> config,
|
||||
) async {
|
||||
final copy = Map<String, dynamic>.from(config);
|
||||
final tokenKey = switch (kind) {
|
||||
'plex' => 'accountToken',
|
||||
'jellyfin' => 'accessToken',
|
||||
_ => null,
|
||||
};
|
||||
final tokenKey = _tokenKeyForKind(kind);
|
||||
var migrated = false;
|
||||
final token = tokenKey == null ? null : copy[tokenKey];
|
||||
if (token is String && token.isNotEmpty) {
|
||||
@@ -103,6 +95,15 @@ class CredentialVault {
|
||||
return (config: copy, migrated: migrated);
|
||||
}
|
||||
|
||||
/// Config key holding the long-lived credential for a `connections.kind`
|
||||
/// value. Returning `null` means "nothing to encrypt", so every new kind MUST
|
||||
/// be listed here — an omission silently persists the token in plaintext.
|
||||
static String? _tokenKeyForKind(String kind) => switch (kind) {
|
||||
'plex' => 'accountToken',
|
||||
'jellyfin' || 'emby' => 'accessToken',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
static Future<Object?> _protectPlexServers(Object? rawServers) async {
|
||||
if (rawServers is! List) return rawServers;
|
||||
final servers = <Object?>[];
|
||||
|
||||
@@ -459,18 +459,19 @@ class DownloadManagerService {
|
||||
|
||||
/// Returns the cache namespace visible to [activeProfileId] for [serverId].
|
||||
///
|
||||
/// Jellyfin prefers the persisted profile-to-user binding so a cold launch
|
||||
/// and a profile switch cannot inherit the physical download row's creator
|
||||
/// scope. A live scope is used only when no persisted binding exists.
|
||||
/// MediaBrowser backends prefer the persisted profile-to-user binding so a
|
||||
/// cold launch and a profile switch cannot inherit the physical download
|
||||
/// row's creator scope. A live scope is used only when no persisted binding
|
||||
/// exists.
|
||||
Future<String?> profileClientScopeIdForServer(ServerId serverId, String? activeProfileId) async {
|
||||
if (activeProfileId == null || activeProfileId.isEmpty) return null;
|
||||
final backend = await _backendForServer(serverId);
|
||||
if (backend == MediaBackend.plex) {
|
||||
return buildPlexProfileScopeId(serverId: serverId, profileId: activeProfileId);
|
||||
if (backend == null) return null;
|
||||
if (backend.usesMediaBrowserApi) {
|
||||
final persisted = await JellyfinCacheResolver(_database).findProfileScopeId(serverId, activeProfileId);
|
||||
return persisted ?? activeClientScopeIdForServer(serverId);
|
||||
}
|
||||
if (backend != MediaBackend.jellyfin) return null;
|
||||
final persisted = await JellyfinCacheResolver(_database).findProfileScopeId(serverId, activeProfileId);
|
||||
return persisted ?? activeClientScopeIdForServer(serverId);
|
||||
return buildPlexProfileScopeId(serverId: serverId, profileId: activeProfileId);
|
||||
}
|
||||
|
||||
/// Bulk-load pinned metadata. Profile-visible hydration reads only exact
|
||||
@@ -568,16 +569,19 @@ class DownloadManagerService {
|
||||
/// is currently offline (the connection persists across launches).
|
||||
///
|
||||
/// [JellyfinCacheResolver] reconciles bare machine ids with compound
|
||||
/// `${serverMachineId}/$userId` connection ids without treating `_` or `%`
|
||||
/// as wildcards.
|
||||
/// `${serverMachineId}/$userId` MediaBrowser connection ids without treating
|
||||
/// `_` or `%` as wildcards.
|
||||
Future<MediaBackend?> _backendForServer(ServerId serverId) async {
|
||||
// Prefer a live client — `MediaServerClient.backend` is in memory.
|
||||
final live = _getClient(serverId);
|
||||
if (live != null) return live.backend;
|
||||
final row = await JellyfinCacheResolver(_database).findConnection(serverId);
|
||||
if (row == null) return null;
|
||||
// Keep the persisted discriminator intact even though both MediaBrowser
|
||||
// values dispatch to the same cache implementation.
|
||||
return switch (row.kind) {
|
||||
'jellyfin' => MediaBackend.jellyfin,
|
||||
'emby' => MediaBackend.emby,
|
||||
'plex' => MediaBackend.plex,
|
||||
_ => null,
|
||||
};
|
||||
@@ -590,8 +594,8 @@ class DownloadManagerService {
|
||||
/// When [_backendForServer] can't resolve the backend (no live client and
|
||||
/// no `connections` row — happens when a server has been removed but old
|
||||
/// download rows still reference it), fan out to every registered backend
|
||||
/// cache instead of silently defaulting to Plex. Otherwise Jellyfin items
|
||||
/// would render with blank metadata after a connection is severed.
|
||||
/// cache instead of silently defaulting to Plex. Otherwise MediaBrowser
|
||||
/// items would render with blank metadata after a connection is severed.
|
||||
Future<MediaItem?> _lookupMetadata(ServerId serverId, String itemId, {String? clientScopeId}) async {
|
||||
final backend = await _backendForServer(serverId);
|
||||
final live = _getClient(serverId, clientScopeId: clientScopeId);
|
||||
@@ -712,8 +716,8 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-aware "ensure cached & pin". Jellyfin loads playback extras so
|
||||
/// both item metadata and native media segments are available offline; other
|
||||
/// Backend-aware "ensure cached & pin". MediaBrowser backends load playback
|
||||
/// extras; Jellyfin can additionally cache native media segments. Other
|
||||
/// backends only need the item metadata row. Then pin cached rows so they
|
||||
/// survive general cache eviction.
|
||||
Future<void> _pinMetadataForOffline(MediaServerClient client, MediaItem metadata) async {
|
||||
@@ -722,7 +726,7 @@ class DownloadManagerService {
|
||||
appLogger.w('Cannot pin metadata without serverId');
|
||||
return;
|
||||
}
|
||||
if (client.backend == MediaBackend.jellyfin) {
|
||||
if (client.backend.usesMediaBrowserApi) {
|
||||
try {
|
||||
await client.fetchPlaybackExtras(metadata.id);
|
||||
} catch (e) {
|
||||
@@ -3564,7 +3568,7 @@ class DownloadManagerService {
|
||||
/// Save metadata for a media item (show, season, movie, or episode)
|
||||
/// Used to persist parent metadata (shows/seasons) for offline display.
|
||||
///
|
||||
/// Both backends now have read-path cache-through, so the work is just to
|
||||
/// All backends have read-path cache-through, so the work is just to
|
||||
/// hit `client.fetchItem` (idempotent) and pin the resulting row.
|
||||
Future<void> saveMetadata(MediaItem metadata, MediaServerClient client) async {
|
||||
if (metadata.serverId == null) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:drift/drift.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
@@ -14,22 +15,26 @@ import 'credential_vault.dart';
|
||||
import 'jellyfin_cache_resolver.dart';
|
||||
import 'jellyfin_mappers.dart';
|
||||
|
||||
/// Jellyfin-shape helpers on top of the shared [ApiCache] substrate.
|
||||
/// MediaBrowser-shape helpers on top of the shared [ApiCache] substrate.
|
||||
///
|
||||
/// Cache rows for Jellyfin item metadata use the compound connection id
|
||||
/// (`{machineId}/{userId}`) plus the read-path endpoint key
|
||||
/// Cache rows for Jellyfin and Emby item metadata use the compound connection
|
||||
/// id (`{machineId}/{userId}`) plus the read-path endpoint key
|
||||
/// `/Users/{userId}/Items/{itemId}`. The public [MediaItem.serverId] remains
|
||||
/// the bare machine id; the compound prefix only isolates local user-scoped
|
||||
/// state such as `UserData`.
|
||||
class JellyfinApiCache extends ApiCache {
|
||||
static final _singleton = ApiCacheSingleton<JellyfinApiCache>(MediaBackend.jellyfin, 'JellyfinApiCache');
|
||||
static final _singleton = ApiCacheSingleton<JellyfinApiCache>(const {
|
||||
MediaBackend.jellyfin,
|
||||
MediaBackend.emby,
|
||||
}, 'JellyfinApiCache');
|
||||
static JellyfinApiCache get instance => _singleton.instance;
|
||||
|
||||
JellyfinApiCache._(super.db);
|
||||
|
||||
/// Initialize the singleton with an [AppDatabase] instance. Also registers
|
||||
/// this instance with the [ApiCache] backend dispatch so callers using
|
||||
/// `ApiCache.forBackend(MediaBackend.jellyfin)` resolve here.
|
||||
/// `ApiCache.forBackend(MediaBackend.jellyfin)` or
|
||||
/// `ApiCache.forBackend(MediaBackend.emby)` resolve here.
|
||||
static void initialize(AppDatabase db) => _singleton.install(JellyfinApiCache._(db));
|
||||
|
||||
JellyfinCacheResolver get _resolver => JellyfinCacheResolver(database);
|
||||
@@ -111,6 +116,7 @@ class JellyfinApiCache extends ApiCache {
|
||||
serverId: ServerId(ctx.machineId),
|
||||
serverName: ctx.name,
|
||||
absolutizer: absolutizer,
|
||||
dialect: ctx.dialect,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -118,18 +124,17 @@ class JellyfinApiCache extends ApiCache {
|
||||
}
|
||||
|
||||
/// Persist a watched/unwatched flip into cached `BaseItemDto` rows for
|
||||
/// [itemId]. Compound Jellyfin scope ids update only their user. A legacy
|
||||
/// [itemId]. Compound MediaBrowser scope ids update only their user. A legacy
|
||||
/// bare machine id is accepted only when its matching rows belong to one
|
||||
/// user; ambiguous multi-user writes are skipped rather than bleeding watch
|
||||
/// state across profiles.
|
||||
///
|
||||
/// [viewOffsetMs] is converted to Jellyfin's 100-ns ticks for
|
||||
/// [viewOffsetMs] is converted to MediaBrowser 100-ns ticks for
|
||||
/// `UserData.PlaybackPositionTicks`. [lastViewedAt] is treated as Plex's
|
||||
/// epoch-seconds and translated to Jellyfin's ISO-8601 `LastPlayedDate`.
|
||||
/// [viewedLeafCount] is ignored — Jellyfin tracks per-show rollup via
|
||||
/// `UserData.UnplayedItemCount`, computed from individual children rather
|
||||
/// than aggregated on the parent. The parameter is accepted for API parity
|
||||
/// with the Plex caller.
|
||||
/// epoch-seconds and translated to the MediaBrowser ISO-8601
|
||||
/// `LastPlayedDate`. [viewedLeafCount] is ignored — both dialects compute
|
||||
/// per-show rollup from individual children via `UserData.UnplayedItemCount`.
|
||||
/// The parameter is accepted for API parity with the Plex caller.
|
||||
@override
|
||||
Future<void> applyWatchState({
|
||||
required ServerId serverId,
|
||||
@@ -150,7 +155,7 @@ class JellyfinApiCache extends ApiCache {
|
||||
};
|
||||
if (userIds.length > 1) {
|
||||
appLogger.w(
|
||||
'Skipping ambiguous bare-scope Jellyfin watch-state cache write',
|
||||
'Skipping ambiguous bare-scope MediaBrowser watch-state cache write',
|
||||
error: {'serverId': serverId, 'itemId': itemId, 'userCount': userIds.length},
|
||||
);
|
||||
return;
|
||||
@@ -192,7 +197,7 @@ class JellyfinApiCache extends ApiCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all pinned Jellyfin metadata in a single query.
|
||||
/// Load all pinned MediaBrowser metadata in a single query.
|
||||
///
|
||||
/// Returns a map keyed by `buildGlobalKey(ServerId(serverId), itemId)` for O(1)
|
||||
/// lookups, mirroring [PlexApiCache.getAllPinnedMetadata] so callers can
|
||||
@@ -213,9 +218,10 @@ class JellyfinApiCache extends ApiCache {
|
||||
|
||||
// Resolve the connection context per serverId once on the main thread
|
||||
// (DB queries can't move into the isolate). Each context carries the
|
||||
// serverName used to stamp the [MediaItem] plus the baseUrl/accessToken
|
||||
// required to absolutize image paths.
|
||||
final contexts = <String, ({String machineId, String name, String baseUrl, String accessToken})>{};
|
||||
// serverName and dialect used to stamp the [MediaItem] plus the
|
||||
// baseUrl/accessToken required to absolutize image paths.
|
||||
final contexts =
|
||||
<String, ({String machineId, String name, String baseUrl, String accessToken, MediaBrowserDialect dialect})>{};
|
||||
final absolutizers = <String, JellyfinImageAbsolutizer>{};
|
||||
for (final entry in entries) {
|
||||
final id = entry.connection.id;
|
||||
@@ -240,6 +246,7 @@ class JellyfinApiCache extends ApiCache {
|
||||
serverId: ServerId(ctx.machineId),
|
||||
serverName: ctx.name,
|
||||
absolutizer: absolutizer,
|
||||
dialect: ctx.dialect,
|
||||
);
|
||||
if (mapped == null) return null;
|
||||
return MapEntry(buildGlobalKey(ServerId(entry.key.scopeId), entry.key.itemId), mapped);
|
||||
@@ -248,27 +255,26 @@ class JellyfinApiCache extends ApiCache {
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve the connection context (server name + base URL + access token)
|
||||
/// for a cache row keyed by the server's machineId. The [Connections]
|
||||
/// Resolve the connection context (server name, dialect, base URL, and access
|
||||
/// token) for a cache row keyed by the server's machineId. The [Connections]
|
||||
/// row's `id` is `${serverMachineId}/$userId`, so a direct `id == serverId`
|
||||
/// lookup misses; fall back to a prefix match.
|
||||
///
|
||||
/// `name` matches what the live [JellyfinClient] stamps onto online
|
||||
/// MediaItems (`connection.serverName`, not the compound `displayName`).
|
||||
/// `baseUrl` and `accessToken` come from the same `configJson` payload
|
||||
/// [JellyfinConnection.toConfigJson] writes, so cache-read absolutization
|
||||
/// uses the current values — token/URL rotations Just Work.
|
||||
/// `name` and `dialect` match what the live [JellyfinClient] stamps onto
|
||||
/// online MediaItems. `baseUrl` and `accessToken` come from the same
|
||||
/// `configJson` payload [JellyfinConnection.toConfigJson] writes, so
|
||||
/// cache-read absolutization uses the current values — token/URL rotations
|
||||
/// Just Work.
|
||||
///
|
||||
/// Returns `null` when no row matches or the row carries an empty
|
||||
/// `baseUrl` (no honest URL we can build).
|
||||
Future<({String machineId, String name, String baseUrl, String accessToken})?> _serverContext(
|
||||
ConnectionRow row, {
|
||||
required String machineId,
|
||||
}) async {
|
||||
/// Returns `null` when no row matches or the row carries an empty `baseUrl`
|
||||
/// (no honest URL we can build).
|
||||
Future<({String machineId, String name, String baseUrl, String accessToken, MediaBrowserDialect dialect})?>
|
||||
_serverContext(ConnectionRow row, {required String machineId}) async {
|
||||
String? configName;
|
||||
String? configMachineId;
|
||||
String baseUrl = '';
|
||||
String accessToken = '';
|
||||
final dialect = MediaBrowserDialect.fromIdOrJellyfin(row.kind);
|
||||
try {
|
||||
final rawConfig = jsonDecode(row.configJson) as Map<String, dynamic>;
|
||||
final config = (await CredentialVault.revealConnectionConfig(row.kind, rawConfig)).config;
|
||||
@@ -282,6 +288,6 @@ class JellyfinApiCache extends ApiCache {
|
||||
if (baseUrl.isEmpty) return null;
|
||||
configMachineId ??= machineId;
|
||||
final name = (configName != null && configName.isNotEmpty) ? configName : row.displayName;
|
||||
return (machineId: configMachineId, name: name, baseUrl: baseUrl, accessToken: accessToken);
|
||||
return (machineId: configMachineId, name: name, baseUrl: baseUrl, accessToken: accessToken, dialect: dialect);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import '../utils/device_identity.dart';
|
||||
|
||||
/// Build the `MediaBrowser` Authorization header value the way the official
|
||||
/// Jellyfin SDK formats it: every field value is percent-encoded, and the
|
||||
/// server reverses that with `WebUtility.UrlDecode` while parsing the header.
|
||||
/// Used at auth time and on every authenticated request so the server sees a
|
||||
/// Builds the `MediaBrowser` Authorization header value understood by both
|
||||
/// Jellyfin and Emby. Every field value is percent-encoded, and the server
|
||||
/// reverses that encoding while parsing the header. The same value is used at
|
||||
/// auth time and on every authenticated request so either dialect sees a
|
||||
/// consistent client identity.
|
||||
///
|
||||
/// Encoding is what keeps the header sendable at all. A device name like
|
||||
/// `Bjørn PC` cannot travel verbatim: `dart:io` rejects header values above
|
||||
/// 0x7F outright, and CFNetwork puts the raw code unit on the wire as a
|
||||
/// Latin-1 byte, which Kestrel — the HTTP server hosting Jellyfin — refuses
|
||||
/// as a malformed header with 400 before the request is ever routed. It also
|
||||
/// removes the grammar hazards the header has no escape for: quotes, commas,
|
||||
/// and `=` inside a value.
|
||||
/// Latin-1 byte, which the server rejects as a malformed header before the
|
||||
/// request is routed. It also removes the grammar hazards the header has no
|
||||
/// escape for: quotes, commas, and `=` inside a value.
|
||||
///
|
||||
/// Jellyfin requires non-empty client, device, and version fields when
|
||||
/// Both dialects require non-empty client, device, and version fields when
|
||||
/// creating a session, so those values use stable fallbacks. An empty device
|
||||
/// ID is omitted for authenticated requests, where Jellyfin can recover it
|
||||
/// ID is omitted for authenticated requests, where the server can recover it
|
||||
/// from the token; unauthenticated entry points must call
|
||||
/// [requireJellyfinDeviceId].
|
||||
String buildJellyfinAuthHeader({
|
||||
@@ -49,14 +48,14 @@ final RegExp _controlCharacters = RegExp(r'[\x00-\x1f\x7f-\x9f]');
|
||||
|
||||
/// Percent-encoding makes any byte transportable, so the only values worth
|
||||
/// filtering are the ones that carry no identity at all — a name of control
|
||||
/// characters would otherwise reach Jellyfin's device list as `%00` noise
|
||||
/// characters would otherwise reach the server's device list as `%00` noise
|
||||
/// instead of falling back to a readable label.
|
||||
String _meaningful(String value) => value.replaceAll(_controlCharacters, '').trim();
|
||||
|
||||
/// Validates the stable device identity required by unauthenticated Jellyfin
|
||||
/// session creation. Never substitute a placeholder: Jellyfin keys sessions
|
||||
/// and access tokens by this value, so a shared fallback would collide across
|
||||
/// installations.
|
||||
/// Validates the stable device identity required by unauthenticated
|
||||
/// MediaBrowser session creation. Never substitute a placeholder: both
|
||||
/// dialects key sessions and access tokens by this value, so a shared fallback
|
||||
/// would collide across installations.
|
||||
String requireJellyfinDeviceId(String deviceId) {
|
||||
final sanitized = sanitizeHeaderValue(deviceId);
|
||||
if (sanitized == null || sanitized != deviceId || sanitized.contains('"')) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
||||
|
||||
import '../connection/connection.dart';
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/media_server_timeouts.dart';
|
||||
@@ -13,6 +14,7 @@ import '../utils/log_redaction_manager.dart';
|
||||
import '../utils/poll_with_backoff.dart';
|
||||
import 'jellyfin_auth_header.dart';
|
||||
import 'jellyfin_endpoint_discovery.dart';
|
||||
import 'media_browser_paths.dart';
|
||||
|
||||
/// Result of `POST /QuickConnect/Initiate`. The [code] is shown to the user
|
||||
/// and entered in their Jellyfin web UI to approve sign-in; the [secret] is
|
||||
@@ -42,7 +44,7 @@ class _JellyfinAuthenticationResponse {
|
||||
/// Auth flow for adding or refreshing a [JellyfinConnection].
|
||||
///
|
||||
/// Lifecycle for adding a server:
|
||||
/// 1. [probe] — validates the URL responds as a Jellyfin server.
|
||||
/// 1. [probe] — validates the URL responds as a MediaBrowser server.
|
||||
/// 2. [authenticateByName] (or future Quick Connect equivalent) — exchanges
|
||||
/// credentials for a long-lived access token and returns a built
|
||||
/// [JellyfinConnection] ready to insert into [ConnectionRegistry].
|
||||
@@ -53,12 +55,15 @@ class JellyfinConnectionAuthService {
|
||||
required this.clientName,
|
||||
required this.clientVersion,
|
||||
required this.deviceName,
|
||||
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
||||
@visibleForTesting this._testHttpClientFactory,
|
||||
}) : _endpointDiscovery = JellyfinEndpointDiscovery(testHttpClientFactory: _testHttpClientFactory);
|
||||
}) : dialect = dialect,
|
||||
_endpointDiscovery = JellyfinEndpointDiscovery(dialect: dialect, testHttpClientFactory: _testHttpClientFactory);
|
||||
|
||||
/// App identity sent in the `MediaBrowser` Authorization header. Jellyfin
|
||||
/// uses `Client`/`Device`/`DeviceId`/`Version` to populate the device list
|
||||
/// in its admin UI and to issue tokens.
|
||||
/// and Emby use `Client`/`Device`/`DeviceId`/`Version` to populate the
|
||||
/// device list in their admin UI and to issue tokens.
|
||||
final MediaBrowserDialect dialect;
|
||||
final String clientName;
|
||||
final String clientVersion;
|
||||
final String deviceName;
|
||||
@@ -81,7 +86,7 @@ class JellyfinConnectionAuthService {
|
||||
/// Probe the server identified by [baseUrl] without authenticating. Returns
|
||||
/// the public info used by the UI to confirm "yes that's the right server"
|
||||
/// before asking for credentials. Throws [MediaServerUrlException] when the
|
||||
/// URL is unreachable or doesn't look like a Jellyfin server.
|
||||
/// URL is unreachable or doesn't look like the selected media server.
|
||||
Future<JellyfinServerInfo> probe(String baseUrl) async {
|
||||
return _endpointDiscovery.probe(baseUrl);
|
||||
}
|
||||
@@ -158,10 +163,12 @@ class JellyfinConnectionAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [baseUrl] has Quick Connect enabled. Returns `false` for any
|
||||
/// failure — Jellyfin <10.7 returns 404 on this path, and an offline server
|
||||
/// is functionally indistinguishable from QC-disabled for UI purposes.
|
||||
/// Whether [baseUrl] has Quick Connect enabled. Returns `false` without a
|
||||
/// request for dialects that do not support it, and for any probe failure —
|
||||
/// Jellyfin <10.7 returns 404 on this path, and an offline server is
|
||||
/// functionally indistinguishable from QC-disabled for UI purposes.
|
||||
Future<bool> isQuickConnectEnabled(String baseUrl) async {
|
||||
if (!dialect.supportsQuickConnect) return false;
|
||||
final normalised = _normaliseBaseUrl(baseUrl);
|
||||
final client = _buildHttpClient(baseUrl: normalised);
|
||||
try {
|
||||
@@ -184,6 +191,7 @@ class JellyfinConnectionAuthService {
|
||||
required String baseUrl,
|
||||
required String deviceId,
|
||||
}) async {
|
||||
_requireQuickConnectSupport();
|
||||
final validDeviceId = requireJellyfinDeviceId(deviceId);
|
||||
final normalised = _normaliseBaseUrl(baseUrl);
|
||||
final authHeader = buildJellyfinAuthHeader(
|
||||
@@ -228,7 +236,7 @@ class JellyfinConnectionAuthService {
|
||||
/// in their Jellyfin web UI, then exchange the approved secret for a token
|
||||
/// and return a fully-formed [JellyfinConnection]. Returns `null` on
|
||||
/// cancel, timeout, or server-side secret expiry (404 mid-poll). Throws
|
||||
/// [MediaServerAuthException] on auth failures (401/403).
|
||||
/// [MediaServerAuthException] on auth failures or an unsupported dialect.
|
||||
Future<JellyfinConnection?> authenticateByQuickConnect({
|
||||
required String baseUrl,
|
||||
List<String>? baseUrls,
|
||||
@@ -238,6 +246,7 @@ class JellyfinConnectionAuthService {
|
||||
Duration timeout = const Duration(minutes: 5),
|
||||
bool Function()? shouldCancel,
|
||||
}) async {
|
||||
_requireQuickConnectSupport();
|
||||
final validDeviceId = requireJellyfinDeviceId(deviceId);
|
||||
final normalised = _normaliseBaseUrl(baseUrl);
|
||||
final info = serverInfo ?? await probe(normalised);
|
||||
@@ -331,8 +340,9 @@ class JellyfinConnectionAuthService {
|
||||
Future<bool> validate(Connection connection) async {
|
||||
if (connection is! JellyfinConnection) return false;
|
||||
final client = _authenticatedClient(connection);
|
||||
final currentUser = MediaBrowserPaths(dialect: dialect, userId: connection.userId).currentUser;
|
||||
try {
|
||||
final response = await client.get('/Users/Me', timeout: MediaServerTimeouts.jellyfinProbe);
|
||||
final response = await client.get(currentUser, timeout: MediaServerTimeouts.jellyfinProbe);
|
||||
return response.statusCode == 200;
|
||||
} on MediaServerHttpException catch (e) {
|
||||
if (e.statusCode == 401 || e.statusCode == 403) return false;
|
||||
@@ -368,6 +378,15 @@ class JellyfinConnectionAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emby 4.9.5 returns 404 for every `/QuickConnect/*` route. Emby Connect is
|
||||
/// a separate account-level product and is not an authentication flow Plezy
|
||||
/// implements.
|
||||
void _requireQuickConnectSupport() {
|
||||
if (!dialect.supportsQuickConnect) {
|
||||
throw MediaServerAuthException('Quick Connect rejected by server');
|
||||
}
|
||||
}
|
||||
|
||||
MediaServerHttpClient _authenticatedClient(JellyfinConnection connection) {
|
||||
LogRedactionManager.registerToken(connection.accessToken);
|
||||
return _buildHttpClient(
|
||||
@@ -441,7 +460,7 @@ class JellyfinConnectionAuthService {
|
||||
/// Build a [JellyfinConnection] from a successful auth/exchange response.
|
||||
/// Connection id is derived from `(machineId, userId)` so each user on a
|
||||
/// given server has a single stable connection row.
|
||||
static JellyfinConnection _buildConnection({
|
||||
JellyfinConnection _buildConnection({
|
||||
required JellyfinServerInfo info,
|
||||
required String normalisedBaseUrl,
|
||||
List<String>? baseUrls,
|
||||
@@ -463,6 +482,7 @@ class JellyfinConnectionAuthService {
|
||||
userName: userName,
|
||||
accessToken: accessToken,
|
||||
deviceId: deviceId,
|
||||
dialect: info.dialect ?? dialect,
|
||||
isAdministrator: isAdministrator,
|
||||
primaryImageTag: primaryImageTag,
|
||||
status: ConnectionStatus.online,
|
||||
|
||||
@@ -8,7 +8,7 @@ typedef JellyfinItemCacheKey = ({String scopeId, String machineId, String userId
|
||||
typedef JellyfinCacheItem = ({ApiCacheData cacheRow, JellyfinItemCacheKey key});
|
||||
typedef ResolvedJellyfinCacheItem = ({ApiCacheData cacheRow, ConnectionRow connection, JellyfinItemCacheKey key});
|
||||
|
||||
/// Canonical Jellyfin connection and item-cache key resolution.
|
||||
/// Canonical MediaBrowser connection and item-cache key resolution.
|
||||
class JellyfinCacheResolver {
|
||||
JellyfinCacheResolver(this.database);
|
||||
|
||||
@@ -18,6 +18,9 @@ class JellyfinCacheResolver {
|
||||
static const _usersMarker = ':/Users/';
|
||||
static const _itemsMarker = '/Items/';
|
||||
|
||||
static Expression<bool> _mediaBrowserKind(GeneratedColumn<String> kind) =>
|
||||
kind.equals('jellyfin') | kind.equals('emby');
|
||||
|
||||
Expression<bool> itemKeyPredicate(GeneratedColumn<String> column, String serverOrScopeId, String itemId) {
|
||||
final scope = _splitScope(serverOrScopeId);
|
||||
final escapedItemId = _escapeLike(itemId);
|
||||
@@ -78,7 +81,7 @@ class JellyfinCacheResolver {
|
||||
.get();
|
||||
if (rows.isEmpty) return const [];
|
||||
|
||||
final connections = await (database.select(database.connections)..where((t) => t.kind.equals('jellyfin'))).get();
|
||||
final connections = await (database.select(database.connections)..where((t) => _mediaBrowserKind(t.kind))).get();
|
||||
final connectionById = {for (final connection in connections) connection.id: connection};
|
||||
final bindings = await database.select(database.profileConnections).get();
|
||||
final bindingsByConnection = <String, List<ProfileConnectionRow>>{};
|
||||
@@ -111,11 +114,11 @@ class JellyfinCacheResolver {
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// Resolves the exact persisted Jellyfin cache namespace owned by
|
||||
/// Resolves the exact persisted MediaBrowser cache namespace owned by
|
||||
/// [profileId] for [serverOrScopeId].
|
||||
///
|
||||
/// The physical download row is deliberately not consulted: it is shared
|
||||
/// across profiles and may have been created by a different Jellyfin user.
|
||||
/// across profiles and may have been created by a different MediaBrowser user.
|
||||
Future<String?> findProfileScopeId(String serverOrScopeId, String profileId) async {
|
||||
if (profileId.isEmpty) return null;
|
||||
final requested = _splitScope(serverOrScopeId);
|
||||
@@ -132,7 +135,7 @@ class JellyfinCacheResolver {
|
||||
if (binding.userIdentifier.isEmpty) continue;
|
||||
final connection = await (database.select(
|
||||
database.connections,
|
||||
)..where((t) => t.id.equals(binding.connectionId) & t.kind.equals('jellyfin'))).getSingleOrNull();
|
||||
)..where((t) => t.id.equals(binding.connectionId) & _mediaBrowserKind(t.kind))).getSingleOrNull();
|
||||
if (connection == null) continue;
|
||||
|
||||
final connectionScope = _splitScope(connection.id);
|
||||
@@ -169,12 +172,12 @@ class JellyfinCacheResolver {
|
||||
final compoundId = '${scope.machineId}/$expectedUserId';
|
||||
final compound = await (database.select(
|
||||
database.connections,
|
||||
)..where((t) => t.id.equals(compoundId) & t.kind.equals('jellyfin'))).getSingleOrNull();
|
||||
)..where((t) => t.id.equals(compoundId) & _mediaBrowserKind(t.kind))).getSingleOrNull();
|
||||
if (compound != null && await _matchesProfileBinding(compound.id, expectedUserId)) return compound;
|
||||
|
||||
final legacy = await (database.select(
|
||||
database.connections,
|
||||
)..where((t) => t.id.equals(scope.machineId) & t.kind.equals('jellyfin'))).getSingleOrNull();
|
||||
)..where((t) => t.id.equals(scope.machineId) & _mediaBrowserKind(t.kind))).getSingleOrNull();
|
||||
if (legacy != null && await _matchesProfileBinding(legacy.id, expectedUserId)) return legacy;
|
||||
return null;
|
||||
}
|
||||
@@ -189,7 +192,7 @@ class JellyfinCacheResolver {
|
||||
|
||||
final prefix = '${scope.machineId}/';
|
||||
return (database.select(database.connections)
|
||||
..where((t) => t.id.substr(1, prefix.length).equals(prefix) & t.kind.equals('jellyfin'))
|
||||
..where((t) => t.id.substr(1, prefix.length).equals(prefix) & _mediaBrowserKind(t.kind))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.id)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -17,6 +18,7 @@ import '../media/media_filter.dart';
|
||||
import '../media/live_tv_support.dart';
|
||||
import '../media/lyrics.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../media/media_file_info.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
@@ -61,6 +63,7 @@ import 'jellyfin_media_info_mapper.dart';
|
||||
import 'jellyfin_playback_bundle.dart';
|
||||
import 'jellyfin_playback_urls.dart';
|
||||
import 'jellyfin_trickplay_service.dart';
|
||||
import 'media_browser_paths.dart';
|
||||
import 'playback_initialization_types.dart';
|
||||
import 'scrub_preview_source.dart';
|
||||
import 'subtitle_preference.dart';
|
||||
@@ -86,18 +89,79 @@ part 'jellyfin_client/parts/metadata_edit.dart';
|
||||
/// used by a single part stay declared in that part.
|
||||
mixin _JellyfinClientInternals on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
MediaBrowserDialect get dialect;
|
||||
MediaBrowserPaths get paths;
|
||||
FailoverHttpClient get _http;
|
||||
MediaItem? _mapItem(Map<String, dynamic> json);
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||
String? _absolutizeImagePath(String? path);
|
||||
|
||||
/// Row metadata Jellyfin volunteers on `/Items` list responses but Emby
|
||||
/// withholds unless it is named in `Fields`.
|
||||
///
|
||||
/// Measured on Emby 4.9.5 against a movie that carries them all: a row built
|
||||
/// from [_baseBrowseFields] came back with no `ProductionYear`,
|
||||
/// `OfficialRating`, `PremiereDate` or `DateCreated`, while the same query
|
||||
/// naming them returned `2011`, `PG-13`, the premiere date and the library-add
|
||||
/// time. Jellyfin 10.11 includes the first three in every row regardless.
|
||||
/// Emby's *detail* route volunteers everything, so only list rows are
|
||||
/// affected — but that is every card in the app, which would otherwise lose
|
||||
/// its year and age-rating badge.
|
||||
///
|
||||
/// `DateCreated` is load-bearing beyond display: it is the `addedAt` every
|
||||
/// recency-ordered surface degrades to when a row has never been played.
|
||||
///
|
||||
/// `UserDataLastPlayedDate` is the odd one out: it is not an `ItemFields`
|
||||
/// member but an Emby-specific token, and it is the only way to get
|
||||
/// `UserData.LastPlayedDate` onto a list row. Measured on Emby 4.9.5: the
|
||||
/// played date is absent under `Fields=UserData`, `EnableUserData=true` and the
|
||||
/// user-scoped `Ids=` form, and present only on the single-item detail route or
|
||||
/// when this token is named. Jellyfin 10.11 volunteers the date on every row and
|
||||
/// accepts the token without changing its responses, but since it is undocumented
|
||||
/// there, only Emby is asked for it.
|
||||
///
|
||||
/// Every row set needs it, not just the recency-ordered ones: a null played date
|
||||
/// on a *watched* row makes [JellyfinApiCache.applyWatchState] stamp
|
||||
/// `DateTime.now()`, so an offline watch-state pull over episode rows would
|
||||
/// rewrite the cached play time of everything it touched.
|
||||
static const _embyWithheldRowFields = [
|
||||
'ProductionYear',
|
||||
'OfficialRating',
|
||||
'PremiereDate',
|
||||
'DateCreated',
|
||||
'UserDataLastPlayedDate',
|
||||
];
|
||||
|
||||
/// Append the fields this dialect withholds, skipping any the set already
|
||||
/// names so Jellyfin's request strings stay byte-identical.
|
||||
String _withDialectRowFields(String fields) {
|
||||
if (dialect != MediaBrowserDialect.emby) return fields;
|
||||
final present = fields.split(',').map((field) => field.trim()).toSet();
|
||||
final missing = _embyWithheldRowFields.where((field) => !present.contains(field));
|
||||
return missing.isEmpty ? fields : '$fields,${missing.join(',')}';
|
||||
}
|
||||
|
||||
String get _browseFields => _withDialectRowFields(_baseBrowseFields);
|
||||
String get _hubRowFields => _withDialectRowFields(_baseHubRowFields);
|
||||
String get _episodeRowFields => _withDialectRowFields(_baseEpisodeRowFields);
|
||||
String get _folderBrowseFields => _withDialectRowFields(_baseFolderBrowseFields);
|
||||
String get _folderRowFields => _withDialectRowFields(_baseFolderRowFields);
|
||||
String get _musicAlbumRowFields => _withDialectRowFields(_baseMusicAlbumRowFields);
|
||||
String get _musicTrackRowFields => _withDialectRowFields(_baseMusicTrackRowFields);
|
||||
String get _queueFields => _withDialectRowFields(_baseQueueFields);
|
||||
}
|
||||
|
||||
/// [MediaServerClient] over a Jellyfin server.
|
||||
/// [MediaServerClient] over a MediaBrowser-family server — Jellyfin or Emby.
|
||||
///
|
||||
/// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the
|
||||
/// HTTP wrapper is backend-agnostic despite the name). Implements the full
|
||||
/// neutral interface: browse, watch state, playlist read, playback session
|
||||
/// reporting, and live TV via [LiveTvSupport].
|
||||
///
|
||||
/// Jellyfin forked from Emby 3.5.2 and the wire contract is still ~95% shared,
|
||||
/// so one client serves both. [dialect] selects the divergent routes (via
|
||||
/// [paths]) and the features that exist on only one side — see
|
||||
/// [MediaBrowserDialect].
|
||||
class JellyfinClient
|
||||
with
|
||||
MediaServerCacheMixin,
|
||||
@@ -119,7 +183,8 @@ class JellyfinClient
|
||||
ScopedMediaServerClient,
|
||||
GracefullyCloseable {
|
||||
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
|
||||
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
|
||||
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository(),
|
||||
_paths = MediaBrowserPaths(dialect: _connection.dialect, userId: _connection.userId);
|
||||
|
||||
/// Build a fully-initialised [JellyfinClient]. Endpoint reachability is
|
||||
/// raced before construction by onboarding/profile binding; this factory
|
||||
@@ -131,6 +196,10 @@ class JellyfinClient
|
||||
/// reject requests that only carry the legacy `X-Emby-Token` header,
|
||||
/// returning 404 from the proxy or a routing-level handler instead of
|
||||
/// 401. We send `X-Emby-Token` too for old Emby/Jellyfin builds.
|
||||
///
|
||||
/// Emby accepts this header pair verbatim: it authored both the
|
||||
/// `MediaBrowser` Authorization scheme and `X-Emby-Token`, so no dialect
|
||||
/// branch is needed here (verified against Emby 4.9.5).
|
||||
static Future<JellyfinClient> create(
|
||||
JellyfinConnection connection, {
|
||||
FavoriteChannelsRepository? favoritesRepository,
|
||||
@@ -140,7 +209,7 @@ class JellyfinClient
|
||||
// HTTP traffic. Orchestration logs contain no literals; this additionally
|
||||
// protects unavoidable network-layer diagnostics.
|
||||
_registerConnectionDiagnostics(connection);
|
||||
final endpointDiscovery = JellyfinEndpointDiscovery();
|
||||
final endpointDiscovery = JellyfinEndpointDiscovery(dialect: connection.dialect);
|
||||
String version = '1.0';
|
||||
try {
|
||||
final pkg = await PackageInfo.fromPlatform();
|
||||
@@ -203,7 +272,10 @@ class JellyfinClient
|
||||
void Function()? onAllEndpointsExhausted,
|
||||
}) {
|
||||
_registerConnectionDiagnostics(connection);
|
||||
final endpointDiscovery = JellyfinEndpointDiscovery(testHttpClientFactory: endpointProbeHttpClientFactory);
|
||||
final endpointDiscovery = JellyfinEndpointDiscovery(
|
||||
dialect: connection.dialect,
|
||||
testHttpClientFactory: endpointProbeHttpClientFactory,
|
||||
);
|
||||
late JellyfinClient client;
|
||||
final mediaHttp = FailoverHttpClient(
|
||||
baseUrl: connection.baseUrl,
|
||||
@@ -221,11 +293,23 @@ class JellyfinClient
|
||||
}
|
||||
|
||||
/// Mutable so [isHealthy] can refresh `Policy.IsAdministrator` from the
|
||||
/// `/Users/Me` probe response — admin status changed server-side should
|
||||
/// current-user probe response — admin status changed server-side should
|
||||
/// propagate without forcing the user to re-auth.
|
||||
JellyfinConnection _connection;
|
||||
@override
|
||||
JellyfinConnection get connection => _connection;
|
||||
|
||||
/// Which MediaBrowser dialect this server speaks. Fixed for the lifetime of
|
||||
/// the client: an endpoint switch can move the base URL but never turns a
|
||||
/// Jellyfin server into an Emby one.
|
||||
@override
|
||||
MediaBrowserDialect get dialect => _connection.dialect;
|
||||
|
||||
/// Route builders for the endpoints where the two dialects diverge.
|
||||
@override
|
||||
MediaBrowserPaths get paths => _paths;
|
||||
final MediaBrowserPaths _paths;
|
||||
|
||||
@override
|
||||
final FailoverHttpClient _http;
|
||||
final FavoriteChannelsRepository _favoritesRepository;
|
||||
@@ -275,8 +359,13 @@ class JellyfinClient
|
||||
String? _absolutizeImagePath(String? path) => _absolutizer.absolutize(path);
|
||||
|
||||
@override
|
||||
MediaItem? _mapItem(Map<String, dynamic> json) =>
|
||||
JellyfinMappers.mediaItem(json, serverId: serverId, serverName: serverName, absolutizer: _absolutizer);
|
||||
MediaItem? _mapItem(Map<String, dynamic> json) => JellyfinMappers.mediaItem(
|
||||
json,
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
absolutizer: _absolutizer,
|
||||
dialect: dialect,
|
||||
);
|
||||
|
||||
@override
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items) =>
|
||||
@@ -292,20 +381,23 @@ class JellyfinClient
|
||||
String? get serverName => connection.serverName;
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.jellyfin;
|
||||
MediaBackend get backend => dialect.backend;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
|
||||
ServerCapabilities get capabilities => switch (dialect) {
|
||||
MediaBrowserDialect.jellyfin => ServerCapabilities.jellyfin,
|
||||
MediaBrowserDialect.emby => ServerCapabilities.emby,
|
||||
};
|
||||
|
||||
/// Jellyfin doesn't expose a per-server played-threshold pref, so we mirror
|
||||
/// Neither dialect exposes a per-server played-threshold pref, so we mirror
|
||||
/// Plex's default of 90%.
|
||||
@override
|
||||
double get watchedThreshold => 0.9;
|
||||
|
||||
/// Jellyfin marks an item played from `/Sessions/Playing/Stopped` itself
|
||||
/// (server `MaxResumePct`, default 90%), so the in-player auto-scrobble must
|
||||
/// not also `POST /UserPlayedItems` — that double-scrobbles via the Trakt
|
||||
/// plugin (#1287). Manual mark-watched still hits `/UserPlayedItems`.
|
||||
/// Both dialects mark an item played from `/Sessions/Playing/Stopped`
|
||||
/// themselves (server `MaxResumePct`, default 90%), so the in-player
|
||||
/// auto-scrobble must not also POST the played route — that double-scrobbles
|
||||
/// via the Trakt plugin (#1287). Manual mark-watched still writes it.
|
||||
@override
|
||||
bool get marksWatchedOnPlaybackStopped => true;
|
||||
|
||||
@@ -316,7 +408,8 @@ class JellyfinClient
|
||||
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) =>
|
||||
_http.closeGracefully(drainTimeout: drainTimeout);
|
||||
|
||||
/// Reachable *and* token-valid. We probe `/Users/Me` (auth-required)
|
||||
/// Reachable *and* token-valid. We probe the current-user route
|
||||
/// ([MediaBrowserPaths.currentUser], auth-required)
|
||||
/// rather than `/System/Info/Public` so a revoked token surfaces as
|
||||
/// unhealthy on the very next sweep, instead of waiting for the first
|
||||
/// real call to 401.
|
||||
@@ -332,7 +425,7 @@ class JellyfinClient
|
||||
@override
|
||||
Future<HealthStatus> checkHealth() async {
|
||||
try {
|
||||
final response = await _http.get('/Users/Me', timeout: MediaServerTimeouts.jellyfinProbe);
|
||||
final response = await _http.get(paths.currentUser, timeout: MediaServerTimeouts.jellyfinProbe);
|
||||
final ok = response.statusCode >= 200 && response.statusCode < 300;
|
||||
if (ok) {
|
||||
final data = response.data;
|
||||
@@ -382,7 +475,7 @@ class JellyfinClient
|
||||
/// Returns null on transport failures — caller treats as "no preference".
|
||||
Future<JellyfinUserProfile?> fetchUserProfile() async {
|
||||
try {
|
||||
final response = await _http.get('/Users/Me');
|
||||
final response = await _http.get(paths.currentUser);
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) return null;
|
||||
|
||||
@@ -68,7 +68,7 @@ LibraryPage<T> _pagedItems<T>(
|
||||
/// `CommaDelimitedCollectionModelBinder` drops them element-by-element and
|
||||
/// they never did anything. `UserData` is governed by `EnableUserData`
|
||||
/// (default true) and `dto.PremiereDate` is set unconditionally.
|
||||
const _browseFields = 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Overview';
|
||||
const _baseBrowseFields = 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Overview';
|
||||
|
||||
/// Field set for the home / per-library hub rows (Recently Added, Continue
|
||||
/// Watching, Next Up). Poster cards render artwork, title, year and the
|
||||
@@ -87,7 +87,7 @@ const _browseFields = 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Over
|
||||
/// (`Folder.FillUserDataDtoValues`), and [MediaItem.unwatchedCount] falls back
|
||||
/// to `UserData.UnplayedItemCount`. Only the season progress bar needs real
|
||||
/// leaf totals, and seasons never appear on a hub row.
|
||||
const _hubRowFields = 'Overview';
|
||||
const _baseHubRowFields = 'Overview';
|
||||
|
||||
/// How far back `/Shows/NextUp` looks for a series to resume, mirroring
|
||||
/// Jellyfin web's `maxDaysForNextUp` default. Without it the server's
|
||||
@@ -101,7 +101,7 @@ String _nextUpDateCutoff() =>
|
||||
/// Existing episode-row requests can show Plex-style quality labels when the
|
||||
/// response includes `MediaSources`. Keep this off broad library/search/latest
|
||||
/// queries because it is the heaviest item field Jellyfin returns.
|
||||
const _episodeRowFields = '$_browseFields,MediaSources';
|
||||
const _baseEpisodeRowFields = '$_baseBrowseFields,MediaSources';
|
||||
|
||||
/// Media types global search surfaces. Episodes are included so a user can
|
||||
/// find a single episode by name.
|
||||
@@ -115,13 +115,13 @@ const _searchItemTypes = 'Movie,Series,Episode,MusicAlbum,Audio';
|
||||
/// Jellyfin web's folder view requests none of them either. The unwatched
|
||||
/// badge survives via `UserData.UnplayedItemCount`
|
||||
/// ([MediaItem.unwatchedCount] fallback).
|
||||
const _folderBrowseFields = 'UserData,PremiereDate,OriginalTitle,SortName';
|
||||
const _baseFolderBrowseFields = 'UserData,PremiereDate,OriginalTitle,SortName';
|
||||
|
||||
/// Folder-tree field set for FILESYSTEM FOLDER children, which render only
|
||||
/// their name. Queried with `EnableUserData=false`: user data on a folder dto
|
||||
/// makes the server compute a recursive unplayed count per folder, by far the
|
||||
/// dominant cost of folder browsing (see [_fetchFolderChildren]).
|
||||
const _folderRowFields = 'SortName';
|
||||
const _baseFolderRowFields = 'SortName';
|
||||
|
||||
/// Latest Albums hub row. `/Users/{id}/Items/Latest` on a music library
|
||||
/// returns MusicAlbum FOLDER dtos, so [_browseFields] would trigger the same
|
||||
@@ -133,13 +133,13 @@ const _folderRowFields = 'SortName';
|
||||
/// filesystem folder rows. Trade-off: fully played albums lose the watched
|
||||
/// checkmark on this row (Jellyfin web's latest-albums row shows no play
|
||||
/// state either).
|
||||
const _musicAlbumRowFields = 'PremiereDate,OriginalTitle,SortName';
|
||||
const _baseMusicAlbumRowFields = 'PremiereDate,OriginalTitle,SortName';
|
||||
|
||||
/// Played-track hub rows (Recently Played / Most Played): Audio LEAF dtos.
|
||||
/// Keeps `UserData` — a cheap direct lookup on leaves that drives the
|
||||
/// play-state overlay — and drops the folder count fields (meaningless on
|
||||
/// Audio) and `Overview` (never rendered on track cards).
|
||||
const _musicTrackRowFields = 'UserData,PremiereDate,OriginalTitle,SortName';
|
||||
const _baseMusicTrackRowFields = 'UserData,PremiereDate,OriginalTitle,SortName';
|
||||
|
||||
/// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows
|
||||
/// only need title, thumbnail (`ImageTags['Primary']`), season/episode
|
||||
@@ -149,7 +149,7 @@ const _musicTrackRowFields = 'UserData,PremiereDate,OriginalTitle,SortName';
|
||||
/// Specials interleave — see [compareEpisodesByWatchOrder]). Drops
|
||||
/// `Overview` etc. so even a thousand-episode shounen show fits in one
|
||||
/// response.
|
||||
const _queueFields = 'UserData,PremiereDate';
|
||||
const _baseQueueFields = 'UserData,PremiereDate';
|
||||
|
||||
/// Page size for [fetchClientSideEpisodeQueue]. Keeps each server response
|
||||
/// bounded while still returning the full series queue.
|
||||
@@ -177,11 +177,14 @@ const _seriesLastPlayedLookupLimit = 24;
|
||||
/// shelf is better off unstamped than waiting on the 10s/120s shared defaults.
|
||||
const _seriesLastPlayedRequestTimeout = Duration(seconds: 3);
|
||||
|
||||
/// Hard ceiling on [_attachSeriesLastPlayed]. On expiry it aborts the in-flight
|
||||
/// batch, so it bounds the whole pass regardless of how many batches remain or
|
||||
/// which request phase a lookup is stuck in. Two orders of magnitude under the
|
||||
/// 10s-connect/120s-receive defaults the single unscoped scan ran with, so a
|
||||
/// stalled endpoint cannot make the scoped form slower than the query it fixes.
|
||||
/// Hard ceiling on [_attachSeriesLastPlayed] and on the reconstructed Next Up
|
||||
/// pass, recency probe included. On expiry it aborts whatever is in flight, so it
|
||||
/// bounds the whole pass regardless of how many batches remain or which request
|
||||
/// phase a lookup is stuck in — the per-request timeouts alone cannot, because
|
||||
/// each applies to the connect and receive phases independently. Two orders of
|
||||
/// magnitude under the 10s-connect/120s-receive defaults the single unscoped scan
|
||||
/// ran with, so a stalled endpoint cannot make the scoped form slower than the
|
||||
/// query it fixes.
|
||||
const _seriesLastPlayedBudget = Duration(seconds: 4);
|
||||
|
||||
const _childrenPageSize = 500;
|
||||
@@ -203,8 +206,8 @@ String _jellyfinFolderSortName(Map<String, dynamic> item) {
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
|
||||
/// `/Items/Filters` is a legacy unpaged endpoint; keep failures isolated from
|
||||
/// the paged Browse tab so very large libraries can still open.
|
||||
/// Aggregate/facet filter lookups are kept isolated from the paged Browse tab
|
||||
/// so failures on very large libraries do not prevent the library from opening.
|
||||
const _filtersTimeout = Duration(seconds: 8);
|
||||
|
||||
/// Full field set for the detail screen and the resume / next-up
|
||||
@@ -212,24 +215,22 @@ const _filtersTimeout = Duration(seconds: 8);
|
||||
const _detailFields =
|
||||
'Overview,Genres,People,Studios,ProductionLocations,Tags,Taglines,DateCreated,DateLastSaved,'
|
||||
'PremiereDate,RecursiveItemCount,ChildCount,UserData,MediaSources,OriginalTitle,SortName,'
|
||||
// Chapters: Jellyfin returns them at the item level; the playback
|
||||
// init flow plucks `raw['Chapters']` and feeds the seek-bar tick UI.
|
||||
// Chapters: both dialects return them at the item level; playback plucks
|
||||
// `raw['Chapters']` and feeds the seek-bar tick UI.
|
||||
'Chapters,'
|
||||
// Trickplay: per-resolution sprite-sheet manifest. The scrub-thumbnail
|
||||
// loader reads `raw['Trickplay']` and computes tile URLs from it.
|
||||
// Trickplay: Jellyfin's per-resolution sprite-sheet manifest. Emby 4.9.5
|
||||
// tolerates this unknown field selection and never populates the field.
|
||||
'Trickplay,'
|
||||
// ProviderIds carries Tmdb/Imdb/Tvdb keys — required for Trakt + the
|
||||
// unified tracker coordinator to scrobble Jellyfin items without
|
||||
// any extra round-trip.
|
||||
// unified tracker coordinator to scrobble MediaBrowser items without an
|
||||
// extra round-trip.
|
||||
'ProviderIds';
|
||||
|
||||
mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
// Endpoint conventions follow what the official Jellyfin Kotlin SDK
|
||||
// generates (cross-checked against the Findroid client). The SDK mixes
|
||||
// `/Users/{userId}/...` for "user library" / "views" / "latest" / "single
|
||||
// item" calls and `/Items?userId=...` for the generic list and resume
|
||||
// endpoints. We mirror that exactly so requests hash the same way against
|
||||
// proxy rules and rate limiters as a stock Jellyfin app.
|
||||
// Shared endpoints and query shapes follow the official Jellyfin SDK so
|
||||
// Jellyfin requests remain unchanged. [MediaBrowserPaths] owns the measured
|
||||
// route differences: Emby 4.9.5 requires the older user-scoped spellings,
|
||||
// while the Jellyfin spellings remain unprefixed.
|
||||
|
||||
/// Views as of the last load, reused by scoped search.
|
||||
///
|
||||
@@ -278,7 +279,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
abort?.throwIfAborted();
|
||||
throwIfHttpError(response);
|
||||
final items = _itemsArray(response.data);
|
||||
// Jellyfin surfaces the user's collection (BoxSet) and playlist roots as
|
||||
// Both MediaBrowser dialects surface collection (BoxSet) and playlist roots as
|
||||
// top-level views. We expose those as per-library tabs instead of
|
||||
// standalone library entries — matches the Plex shape and avoids
|
||||
// duplicating the same data in two navigation slots.
|
||||
@@ -287,7 +288,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
final ct = (view['CollectionType'] as String?)?.toLowerCase();
|
||||
return ct != 'boxsets' && ct != 'playlists';
|
||||
})
|
||||
.map((view) => JellyfinMappers.library(view, serverId: serverId, serverName: serverName))
|
||||
.map((view) => JellyfinMappers.library(view, serverId: serverId, serverName: serverName, dialect: dialect))
|
||||
.whereType<MediaLibrary>()
|
||||
.toList();
|
||||
}
|
||||
@@ -348,13 +349,13 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
}
|
||||
|
||||
/// Jellyfin's `/Items/Filters` returns Genres / OfficialRatings / Tags /
|
||||
/// Categories + values from `/Items/Filters` in a single call. The
|
||||
/// unwatched/unplayed boolean is synthetic because Jellyfin exposes it as
|
||||
/// an `/Items` query filter, not a filter-listing category. Keys are
|
||||
/// translated to Plex's filter naming so the existing filter-param map
|
||||
/// round-trips through `_buildFilterParams` unchanged; the synthesised
|
||||
/// `MediaFilter.key` is prefixed `jellyfin:` so FiltersBottomSheet can
|
||||
/// recognise it as cached and skip the per-category value fetch.
|
||||
/// Years in one call. Emby has no aggregate or official-rating route, so its
|
||||
/// branch concurrently reads `/Genres`, `/Tags`, and `/Years`. The
|
||||
/// unwatched/unplayed boolean remains synthetic because both dialects expose
|
||||
/// it as an `/Items` query filter. Keys are translated to Plex's filter
|
||||
/// naming so the existing filter-param map round-trips through
|
||||
/// `_buildFilterParams` unchanged; the synthesised `MediaFilter.key` keeps
|
||||
/// the historic `jellyfin:` prefix so existing cached preferences remain valid.
|
||||
@override
|
||||
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async {
|
||||
final filters = <MediaFilter>[
|
||||
@@ -382,13 +383,25 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
return raw.whereType<String>().where((s) => s.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
List<String> yearList(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.map(
|
||||
(year) => switch (year) {
|
||||
final num value => value.toInt().toString(),
|
||||
final String value => value,
|
||||
_ => '',
|
||||
},
|
||||
)
|
||||
.where((year) => year.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final raw = <String, List<String>>{
|
||||
'genre': stringList(data['Genres']),
|
||||
'contentRating': stringList(data['OfficialRatings']),
|
||||
'tag': stringList(data['Tags']),
|
||||
'year': (data['Years'] is List)
|
||||
? (data['Years'] as List).whereType<num>().map((y) => y.toInt().toString()).toList()
|
||||
: const <String>[],
|
||||
'year': yearList(data['Years']),
|
||||
};
|
||||
|
||||
const order = ['genre', 'year', 'contentRating', 'tag'];
|
||||
@@ -417,6 +430,11 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _safeFetchFilterPayload(String libraryId) async {
|
||||
if (!dialect.supportsAggregateItemFilters) {
|
||||
// Emby 4.9.5 returns 404 for `/Items/Filters`; its three facet routes below are verified 200.
|
||||
return _safeFetchFilterFacets(libraryId);
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _http.get(
|
||||
'/Items/Filters',
|
||||
@@ -433,6 +451,50 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reassemble the `/Items/Filters` payload from Emby's per-facet routes.
|
||||
///
|
||||
/// Note the route spellings: Emby serves the ratings facet at
|
||||
/// `/OfficialRatings`, not the `/Items/OfficialRatings` form its siblings
|
||||
/// might suggest (that one 404s). Jellyfin has none of these four and answers
|
||||
/// the aggregate route instead.
|
||||
Future<Map<String, dynamic>> _safeFetchFilterFacets(String libraryId) async {
|
||||
final facets = await Future.wait([
|
||||
_safeFetchFilterFacet('/Genres', libraryId),
|
||||
_safeFetchFilterFacet('/OfficialRatings', libraryId),
|
||||
_safeFetchFilterFacet('/Tags', libraryId),
|
||||
_safeFetchFilterFacet('/Years', libraryId),
|
||||
]);
|
||||
return {'Genres': facets[0], 'OfficialRatings': facets[1], 'Tags': facets[2], 'Years': facets[3]};
|
||||
}
|
||||
|
||||
Future<List<String>> _safeFetchFilterFacet(String endpoint, String libraryId) async {
|
||||
try {
|
||||
final response = await _http.get(
|
||||
endpoint,
|
||||
// `Recursive=true` is required: without it Emby only considers the
|
||||
// library view's direct children and every facet comes back empty
|
||||
// (measured against Emby 4.9.5 — `/Years` returns 0 vs 15 rows).
|
||||
queryParameters: {'UserId': connection.userId, 'ParentId': libraryId, 'Recursive': 'true'},
|
||||
timeout: _filtersTimeout,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) return const [];
|
||||
final items = data['Items'];
|
||||
if (items is! List) return const [];
|
||||
final names = <String>[];
|
||||
for (final item in items) {
|
||||
if (item is! Map<String, dynamic>) continue;
|
||||
final name = item['Name'];
|
||||
if (name is String && name.isNotEmpty) names.add(name);
|
||||
}
|
||||
return names;
|
||||
} catch (e, st) {
|
||||
appLogger.w('MediaBrowserClient: $endpoint filter facet unavailable', error: e, stackTrace: st);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Jellyfin has no `/sorts` listing endpoint, so this returns a hardcoded
|
||||
/// list based on the broad sort set Streamyfin exposes. Keys remain
|
||||
/// backend-neutral where Plezy already had saved preferences (`rating`,
|
||||
@@ -1498,8 +1560,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
@override
|
||||
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async {
|
||||
final results = await Future.wait([
|
||||
_fetchItemsArray('/UserItems/Resume', {
|
||||
_fetchItemsArray(_resumePath, {
|
||||
'userId': connection.userId,
|
||||
..._resumeFilterQuery,
|
||||
'Limit': ?count?.toString(),
|
||||
'Fields': _hubRowFields,
|
||||
'MediaTypes': 'Video',
|
||||
@@ -1507,7 +1570,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
}, retry: _continueWatchingRetry),
|
||||
_safeFetchItemsArray('/Shows/NextUp', {
|
||||
_fetchNextUpRows({
|
||||
'userId': connection.userId,
|
||||
'Limit': ?count?.toString(),
|
||||
'Fields': _hubRowFields,
|
||||
@@ -1518,9 +1581,13 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
}, retry: _continueWatchingRetry),
|
||||
]);
|
||||
|
||||
final nextUp = _mapItems(results[1]);
|
||||
return _mergeContinueWatchingAndNextUp(
|
||||
resume: _mapItems(results.first),
|
||||
nextUp: await _attachSeriesLastPlayed(_mapItems(results[1])),
|
||||
// Only the server-side shelf needs enriching: `/Shows/NextUp` rows carry no
|
||||
// series play date, while the reconstructed rows were already stamped from
|
||||
// the recency query that ordered them.
|
||||
nextUp: dialect.supportsGlobalNextUp ? await _attachSeriesLastPlayed(nextUp) : nextUp,
|
||||
limit: count,
|
||||
);
|
||||
}
|
||||
@@ -1631,8 +1698,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
|
||||
final results = await Future.wait([
|
||||
latestFuture,
|
||||
_safeFetchItemsArray('/UserItems/Resume', {
|
||||
_safeFetchItemsArray(_resumePath, {
|
||||
'userId': connection.userId,
|
||||
..._resumeFilterQuery,
|
||||
'ParentId': ?parentId,
|
||||
'Limit': limit.toString(),
|
||||
'Fields': _hubRowFields,
|
||||
@@ -1642,7 +1710,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
...jellyfinImageQueryParameters,
|
||||
}, retry: retry),
|
||||
includeNextUp
|
||||
? _safeFetchItemsArray('/Shows/NextUp', {
|
||||
? _fetchNextUpRows({
|
||||
'userId': connection.userId,
|
||||
'ParentId': ?parentId,
|
||||
'Limit': limit.toString(),
|
||||
@@ -1657,7 +1725,10 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
|
||||
return [
|
||||
hub('continue', continueTitle, 'mixed', results[1]),
|
||||
hub('nextup', nextUpTitle, 'episode', results[2]),
|
||||
// The reconstructed Emby shelf is capped by the series lookup limit rather
|
||||
// than the caller's, so slice it to the requested preview size the way the
|
||||
// server-side query already does for Jellyfin.
|
||||
hub('nextup', nextUpTitle, 'episode', results[2].take(limit).toList(growable: false)),
|
||||
hub('recent', recentTitle, 'mixed', results.first),
|
||||
].where((h) => h.items.isNotEmpty).toList();
|
||||
}
|
||||
@@ -1816,9 +1887,10 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
);
|
||||
case 'continue':
|
||||
return _safeFetchMediaPage(
|
||||
'/UserItems/Resume',
|
||||
_resumePath,
|
||||
{
|
||||
'userId': connection.userId,
|
||||
..._resumeFilterQuery,
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _hubRowFields,
|
||||
@@ -1832,23 +1904,31 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
abort: abort,
|
||||
);
|
||||
case 'nextup':
|
||||
return _safeFetchMediaPage(
|
||||
'/Shows/NextUp',
|
||||
{
|
||||
'userId': connection.userId,
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _hubRowFields,
|
||||
'ParentId': ?parentId,
|
||||
'EnableResumable': 'false',
|
||||
'NextUpDateCutoff': _nextUpDateCutoff(),
|
||||
'EnableTotalRecordCount': 'true',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
offset: offset,
|
||||
requestedSize: pageSize,
|
||||
abort: abort,
|
||||
);
|
||||
final nextUpQuery = <String, dynamic>{
|
||||
'userId': connection.userId,
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _hubRowFields,
|
||||
'ParentId': ?parentId,
|
||||
'EnableResumable': 'false',
|
||||
'NextUpDateCutoff': _nextUpDateCutoff(),
|
||||
'EnableTotalRecordCount': 'true',
|
||||
...jellyfinImageQueryParameters,
|
||||
};
|
||||
if (dialect.supportsGlobalNextUp) {
|
||||
return _safeFetchMediaPage(
|
||||
'/Shows/NextUp',
|
||||
nextUpQuery,
|
||||
offset: offset,
|
||||
requestedSize: pageSize,
|
||||
abort: abort,
|
||||
);
|
||||
}
|
||||
// Emby's shelf is assembled client-side and is capped by
|
||||
// [_seriesLastPlayedLookupLimit], so it is paged in memory.
|
||||
final rows = await _fetchNextUpRows(nextUpQuery, abort: abort);
|
||||
final window = rows.skip(offset).take(pageSize).toList(growable: false);
|
||||
return LibraryPage<MediaItem>(items: _mapItems(window), totalCount: rows.length, offset: offset);
|
||||
case 'recentlyplayed':
|
||||
case 'mostplayed':
|
||||
return _safeFetchMediaPage(
|
||||
@@ -1925,22 +2005,16 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
].where((h) => h.items.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Jellyfin exposes local trailers separately from special features. Combine
|
||||
/// both into Plezy's existing extras row, but keep remote/YouTube trailers
|
||||
/// out of scope because they are external URLs, not playable Jellyfin items.
|
||||
/// Both dialects expose local trailers separately from special features.
|
||||
/// Combine them into Plezy's existing extras row, but keep remote/YouTube
|
||||
/// trailers out of scope because they are external URLs, not playable items.
|
||||
@override
|
||||
Future<List<MediaItem>> fetchExtras(String id) async {
|
||||
if (isOfflineMode) return const [];
|
||||
|
||||
final results = await Future.wait([
|
||||
_safeFetchItemsArray('/Items/${_segment(id)}/LocalTrailers', {
|
||||
'userId': connection.userId,
|
||||
...jellyfinImageQueryParameters,
|
||||
}),
|
||||
_safeFetchItemsArray('/Items/${_segment(id)}/SpecialFeatures', {
|
||||
'userId': connection.userId,
|
||||
...jellyfinImageQueryParameters,
|
||||
}),
|
||||
_safeFetchItemsArray(paths.localTrailers(id), {'userId': connection.userId, ...jellyfinImageQueryParameters}),
|
||||
_safeFetchItemsArray(paths.specialFeatures(id), {'userId': connection.userId, ...jellyfinImageQueryParameters}),
|
||||
]);
|
||||
|
||||
return _playableExtrasFromRaw(results.expand((items) => items));
|
||||
@@ -2043,6 +2117,259 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
];
|
||||
}
|
||||
|
||||
/// Route and extra query that list genuinely in-progress items.
|
||||
///
|
||||
/// Jellyfin's dedicated resume route already returns only started items. Emby's
|
||||
/// does not: measured against one in-progress movie and 30 started series it
|
||||
/// returned 30 rows — the movie plus every started series' next episode — with
|
||||
/// the movie sorted *last*, and no `Filters` value removed them. Paging that is
|
||||
/// unusable, so Emby reads the ordinary `/Items` route with
|
||||
/// `Filters=IsResumable`, which returned exactly the movie
|
||||
/// (`TotalRecordCount: 1`) and supports the same sort, paging and `ParentId`
|
||||
/// scoping. Those next episodes reach the UI through the Next Up shelf, which
|
||||
/// is where Plezy models them.
|
||||
Map<String, dynamic> get _resumeFilterQuery => dialect.resumeReturnsOnlyStartedItems
|
||||
? const {}
|
||||
: const {'Filters': 'IsResumable', 'SortBy': 'DatePlayed', 'SortOrder': 'Descending'};
|
||||
|
||||
/// `/Items` on Emby (see [_resumeFilterQuery]); the dedicated route otherwise.
|
||||
String get _resumePath => dialect.resumeReturnsOnlyStartedItems ? paths.resumeItems : '/Items';
|
||||
|
||||
/// Library-wide Next Up rows.
|
||||
///
|
||||
/// Jellyfin computes this server-side. Emby only computes Next Up per series
|
||||
/// — an unscoped `/Shows/NextUp` returns nothing there under any parameter
|
||||
/// combination (see [MediaBrowserDialect.supportsGlobalNextUp]) — so this
|
||||
/// reconstructs the shelf in two phases:
|
||||
///
|
||||
/// 1. one `/Items` query for the most recently played episodes, newest first,
|
||||
/// which yields the started series in exactly the order the shelf wants;
|
||||
/// 2. one `/Shows/NextUp?SeriesId=` per distinct series, bounded by the same
|
||||
/// concurrency and lookup cap [_attachSeriesLastPlayed] uses, and by one
|
||||
/// [_seriesLastPlayedBudget] wall clock that covers phase 1 as well, so a
|
||||
/// slow server cannot hold the home screen.
|
||||
///
|
||||
/// Rows come back series-recency-ordered, which is what the caller's merge
|
||||
/// expects, and a failed per-series lookup drops only that series.
|
||||
Future<List<Map<String, dynamic>>> _fetchNextUpRows(
|
||||
Map<String, dynamic> queryParameters, {
|
||||
_HubRetryPolicy? retry,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
if (dialect.supportsGlobalNextUp) {
|
||||
return _safeFetchItemsArray('/Shows/NextUp', queryParameters, retry: retry, abort: abort);
|
||||
}
|
||||
|
||||
final budgetAbort = AbortController();
|
||||
final deadline = Timer(_seriesLastPlayedBudget, budgetAbort.abort);
|
||||
// The caller's cancellation has to reach the requests this pass owns, which
|
||||
// all run under [budgetAbort].
|
||||
if (abort != null) unawaited(abort.trigger.then((_) => budgetAbort.abort()));
|
||||
try {
|
||||
return await _reconstructNextUpRows(queryParameters, abort: abort, budgetAbort: budgetAbort);
|
||||
} finally {
|
||||
deadline.cancel();
|
||||
budgetAbort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// The reconstruction itself, under a caller-supplied wall-clock budget.
|
||||
Future<List<Map<String, dynamic>>> _reconstructNextUpRows(
|
||||
Map<String, dynamic> queryParameters, {
|
||||
required AbortController? abort,
|
||||
required AbortController budgetAbort,
|
||||
}) async {
|
||||
final probe = _fetchRecentlyPlayedSeriesIds(parentId: queryParameters['ParentId'] as String?, abort: budgetAbort);
|
||||
final List<(String, String?)> probed;
|
||||
try {
|
||||
// Raced, not just awaited: aborting is advisory at the transport, and the
|
||||
// probe's own timeout applies to the connect and receive phases
|
||||
// independently, so a delayed header plus a stalled body could outlive the
|
||||
// deadline. Same policy as the per-series batches below.
|
||||
probed = await Future.any([probe, budgetAbort.trigger.then((_) => const <(String, String?)>[])]);
|
||||
} on MediaServerHttpException catch (e) {
|
||||
// Our own deadline expiring is not a disrupted fetch: this shelf is
|
||||
// best-effort, so an over-budget probe costs the Next Up half rather than
|
||||
// the caller's whole request. A cancellation the *caller* asked for still
|
||||
// propagates.
|
||||
if (e.isCancellation && abort?.isAborted != true) return const [];
|
||||
rethrow;
|
||||
} finally {
|
||||
// The loser may still fail later; its outcome is no longer anyone's.
|
||||
probe.ignore();
|
||||
}
|
||||
// Before the early return below: an empty probe can also mean the caller
|
||||
// cancelled mid-request and the transport reported an ordinary timeout, which
|
||||
// must not be published as "nothing to watch next".
|
||||
abort?.throwIfAborted();
|
||||
|
||||
final recentSeries = _withinNextUpCutoff(probed, queryParameters['NextUpDateCutoff'] as String?);
|
||||
if (recentSeries.isEmpty) return const [];
|
||||
final seriesIds = [for (final (seriesId, _) in recentSeries) seriesId];
|
||||
|
||||
// Per-series queries carry the caller's field/image selection but never its
|
||||
// paging: the shelf is assembled here and sliced by the caller.
|
||||
final perSeriesQuery = <String, dynamic>{
|
||||
for (final entry in queryParameters.entries)
|
||||
if (entry.key != 'StartIndex' && entry.key != 'Limit' && entry.key != 'ParentId') entry.key: entry.value,
|
||||
'Limit': '1',
|
||||
};
|
||||
|
||||
final rowsBySeries = <String, Map<String, dynamic>>{};
|
||||
for (var start = 0; start < seriesIds.length; start += _seriesLastPlayedConcurrency) {
|
||||
if (budgetAbort.isAborted || abort?.isAborted == true) break;
|
||||
final batch = seriesIds.skip(start).take(_seriesLastPlayedConcurrency);
|
||||
// Each lookup records itself as it completes, so a sibling that is still
|
||||
// slow when the deadline fires costs only its own row. Awaiting the batch
|
||||
// as one future would discard every result it had already collected.
|
||||
final lookups = [
|
||||
for (final seriesId in batch)
|
||||
_fetchSeriesNextUp(seriesId, perSeriesQuery, budgetAbort).then((result) {
|
||||
final (id, row) = result;
|
||||
if (row != null) rowsBySeries[id] = row;
|
||||
}),
|
||||
];
|
||||
final batchDone = Future.wait(lookups);
|
||||
await Future.any([batchDone, budgetAbort.trigger]);
|
||||
// No-op once the batch has won; suppresses the loser's late completion.
|
||||
batchDone.ignore();
|
||||
}
|
||||
|
||||
// The loop breaks on either abort, and the caller's means disruption: a
|
||||
// partial shelf must not be published as though the series had no next
|
||||
// episode. Our own budget expiring keeps degrading to whatever was collected.
|
||||
abort?.throwIfAborted();
|
||||
|
||||
// Reassemble in series-recency order rather than completion order, stamping
|
||||
// each row with its series' newest play date. A Next Up episode has never
|
||||
// been played, so its own `UserData` carries no date, and the shelf's sort
|
||||
// key would otherwise fall back to when the episode was added to the
|
||||
// library. The date comes from the same response that established this
|
||||
// order, so no extra request is needed.
|
||||
return [
|
||||
for (final (seriesId, lastPlayed) in recentSeries)
|
||||
if (rowsBySeries[seriesId] case final row?)
|
||||
if (lastPlayed == null)
|
||||
row
|
||||
else
|
||||
{
|
||||
...row,
|
||||
// Tolerant read: a dto whose `UserData` is absent or not an object
|
||||
// still gets a date rather than raising past the shelf's guards.
|
||||
'UserData': {
|
||||
...?(row['UserData'] is Map<String, dynamic> ? row['UserData'] as Map<String, dynamic> : null),
|
||||
'LastPlayedDate': lastPlayed,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/// Drop series whose newest play predates [cutoff], the window Jellyfin's own
|
||||
/// `/Shows/NextUp` applies through `NextUpDateCutoff`.
|
||||
///
|
||||
/// Measured on Emby 4.9.5: `/Shows/NextUp` ignores `NextUpDateCutoff` entirely
|
||||
/// — a cutoff of 2030 still returned the row that a 2019 cutoff did, while
|
||||
/// Jellyfin 10.11 returned nothing for a future cutoff. Without this the
|
||||
/// reconstructed shelf would resurrect series the user abandoned years ago that
|
||||
/// Jellyfin hides.
|
||||
///
|
||||
/// Filtered here rather than on the server because Emby has no played-date
|
||||
/// filter for this scan: `MinDatePlayed` and `MinDateLastPlayed` are both
|
||||
/// silently ignored (`TotalRecordCount` unchanged at 53 with a cutoff of 2030),
|
||||
/// while `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` do filter
|
||||
/// but on unrelated dates. The scan's own cap therefore still counts rows
|
||||
/// outside the window; that only shortens an already best-effort shelf.
|
||||
///
|
||||
/// A series the server gave no date for is kept: the date is missing only when
|
||||
/// the server withheld it, and an unfiltered row is a better failure than a
|
||||
/// silently empty shelf.
|
||||
List<(String, String?)> _withinNextUpCutoff(List<(String, String?)> series, String? cutoff) {
|
||||
final threshold = cutoff == null ? null : DateTime.tryParse(cutoff);
|
||||
if (threshold == null) return series;
|
||||
return [
|
||||
for (final entry in series)
|
||||
if (entry.$2 == null || !(DateTime.tryParse(entry.$2!)?.isBefore(threshold) ?? false)) entry,
|
||||
];
|
||||
}
|
||||
|
||||
/// Distinct series ids behind the user's most recently played episodes, newest
|
||||
/// first, capped at [_seriesLastPlayedLookupLimit], each paired with that
|
||||
/// series' newest play date.
|
||||
///
|
||||
/// `Filters=IsPlayed` + `SortBy=DatePlayed` is the only library-wide recency
|
||||
/// signal Emby exposes for series: a series dto carries neither a usable
|
||||
/// `PlayCount` nor a `DatePlayed` sort key, so the episodes have to supply the
|
||||
/// order. Because the rows arrive newest-first, the first row naming a series
|
||||
/// *is* that series' newest play, so this one request yields both the shelf
|
||||
/// order and the timestamp each reconstructed row needs — no per-series
|
||||
/// enrichment round trip.
|
||||
Future<List<(String, String?)>> _fetchRecentlyPlayedSeriesIds({String? parentId, AbortController? abort}) async {
|
||||
final rows = await _safeFetchItemsArray(
|
||||
'/Items',
|
||||
{
|
||||
'userId': connection.userId,
|
||||
'ParentId': ?parentId,
|
||||
'Recursive': 'true',
|
||||
'IncludeItemTypes': 'Episode',
|
||||
'Filters': 'IsPlayed',
|
||||
'SortBy': 'DatePlayed',
|
||||
'SortOrder': 'Descending',
|
||||
// Eight played episodes per shelf slot: enough that a few binged series
|
||||
// near the top do not starve the rest. A user who watched more than this
|
||||
// window inside a single series gets a shorter shelf, never a wrong one —
|
||||
// the series they were last watching still ranks first, which is the
|
||||
// ordering the shelf exists to show.
|
||||
'Limit': (_seriesLastPlayedLookupLimit * 8).toString(),
|
||||
'Fields': _withDialectRowFields('SeriesId'),
|
||||
'EnableImages': 'false',
|
||||
'EnableTotalRecordCount': 'false',
|
||||
},
|
||||
timeout: _seriesLastPlayedRequestTimeout,
|
||||
abort: abort,
|
||||
// Best-effort shelf: a timeout or 5xx here must not move the client's
|
||||
// active endpoint, which is what every other hub leg also avoids.
|
||||
//
|
||||
// This [timeout] bounds a single request phase, not the probe: like every
|
||||
// per-call timeout it applies to connect and receive independently. The
|
||||
// wall clock comes from the caller's shared [_seriesLastPlayedBudget],
|
||||
// which covers this probe and the per-series lookups together.
|
||||
//
|
||||
// Deliberately *not* the hub retry policy: `_getItemsResponse` ignores
|
||||
// `timeout` on the retried path, which would leave this request bounded
|
||||
// only by the 15s home / 20s library hub deadline.
|
||||
allowEndpointFailover: false,
|
||||
);
|
||||
|
||||
// Insertion-ordered map: preserves the server's recency ordering, and keeps
|
||||
// each series' first (newest) play date rather than a later, older one.
|
||||
final lastPlayedBySeries = <String, String?>{};
|
||||
for (final row in rows) {
|
||||
final seriesId = row['SeriesId'];
|
||||
if (seriesId is! String || seriesId.isEmpty || lastPlayedBySeries.containsKey(seriesId)) continue;
|
||||
final userData = row['UserData'];
|
||||
lastPlayedBySeries[seriesId] = userData is Map ? userData['LastPlayedDate'] as String? : null;
|
||||
if (lastPlayedBySeries.length >= _seriesLastPlayedLookupLimit) break;
|
||||
}
|
||||
return [for (final entry in lastPlayedBySeries.entries) (entry.key, entry.value)];
|
||||
}
|
||||
|
||||
/// The single Next Up episode for [seriesId], or null when the series has none
|
||||
/// left — or when the lookup failed, which drops only this series.
|
||||
Future<(String, Map<String, dynamic>?)> _fetchSeriesNextUp(
|
||||
String seriesId,
|
||||
Map<String, dynamic> queryParameters,
|
||||
AbortController abort,
|
||||
) async {
|
||||
final rows = await _safeFetchItemsArray(
|
||||
'/Shows/NextUp',
|
||||
{...queryParameters, 'SeriesId': seriesId},
|
||||
abort: abort,
|
||||
timeout: _seriesLastPlayedRequestTimeout,
|
||||
allowEndpointFailover: false,
|
||||
);
|
||||
return (seriesId, rows.isEmpty ? null : rows.first);
|
||||
}
|
||||
|
||||
/// Newest `LastPlayedDate` across [seriesId]'s episodes, or null when the
|
||||
/// series has never been played — or when the lookup failed, in which case the
|
||||
/// row keeps a null date and degrades to its `addedAt` in the shelf sort.
|
||||
|
||||
@@ -33,7 +33,7 @@ mixin _JellyfinLiveTvMethods on _JellyfinClientInternals {
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
appLogger.d('Jellyfin Live TV probe failed', error: e);
|
||||
appLogger.d('${dialect.productName} Live TV probe failed', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,8 @@ mixin _JellyfinLiveTvMethods on _JellyfinClientInternals {
|
||||
|
||||
/// EPG / programs grid. [channelIds] scopes to specific channels (when
|
||||
/// empty, the server returns programs across all channels). [beginsAt] /
|
||||
/// [endsAt] are epoch seconds and bound the time window — Jellyfin uses
|
||||
/// ISO 8601 strings on the wire.
|
||||
/// [endsAt] are epoch seconds and bound the time window — both MediaBrowser
|
||||
/// dialects use ISO 8601 strings on the wire.
|
||||
Future<List<LiveTvProgram>> fetchLiveTvPrograms({
|
||||
List<String> channelIds = const [],
|
||||
int? beginsAt,
|
||||
@@ -143,7 +143,7 @@ mixin _JellyfinLiveTvMethods on _JellyfinClientInternals {
|
||||
LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this as JellyfinClient);
|
||||
}
|
||||
|
||||
/// Adapter from [LiveTvSupport] to Jellyfin channel/program helpers.
|
||||
/// Adapter from [LiveTvSupport] to MediaBrowser channel/program helpers.
|
||||
class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
final JellyfinClient _client;
|
||||
_JellyfinLiveTvSupport(this._client);
|
||||
@@ -178,8 +178,8 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
if (sources.isEmpty) return null;
|
||||
final firstSource = sources.first;
|
||||
if (firstSource is! Map<String, dynamic>) {
|
||||
throw const PlaybackException(
|
||||
'Jellyfin returned invalid Live TV playback data',
|
||||
throw PlaybackException(
|
||||
'${_client.dialect.productName} returned invalid Live TV playback data',
|
||||
reason: PlaybackFailureReason.invalidPlaybackData,
|
||||
);
|
||||
}
|
||||
@@ -192,12 +192,12 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
var liveStreamId = nonEmptyString(source['LiveStreamId']);
|
||||
final rawUrl = nonEmptyString(source['TranscodingUrl']);
|
||||
if (rawUrl == null) {
|
||||
appLogger.w('Jellyfin Live TV negotiation returned no HLS transcode URL');
|
||||
appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL');
|
||||
return null;
|
||||
}
|
||||
final rawUri = Uri.tryParse(rawUrl);
|
||||
if (rawUri == null || !rawUri.path.toLowerCase().endsWith('.m3u8')) {
|
||||
appLogger.w('Jellyfin Live TV negotiation returned no HLS transcode URL');
|
||||
appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL');
|
||||
return null;
|
||||
}
|
||||
final url = _client._withApiKey(rawUrl);
|
||||
@@ -222,8 +222,9 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
}
|
||||
|
||||
/// SharedPreferences key for the locally-persisted favorite-channel list.
|
||||
/// Keyed by the compound connection id (`{machineId}/{userId}`) so two
|
||||
/// Jellyfin users on the same server don't share favorites.
|
||||
/// Keyed by the compound connection id (`{machineId}/{userId}`) so users on
|
||||
/// the same MediaBrowser server don't share favorites.
|
||||
// Keep the legacy prefix: the connection id isolates both dialects, and changing it would lose Jellyfin ordering.
|
||||
String get _favoritesPrefsKey => 'jellyfin_fav_channels:${_client.connection.id}';
|
||||
|
||||
/// Legacy bare-machineId key, kept for one-shot migration.
|
||||
@@ -266,7 +267,11 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
} catch (error, stackTrace) {
|
||||
firstError ??= error;
|
||||
firstStackTrace ??= stackTrace;
|
||||
appLogger.w('Failed to update a Jellyfin favorite channel', error: error, stackTrace: stackTrace);
|
||||
appLogger.w(
|
||||
'Failed to update a ${_client.dialect.productName} favorite channel',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +296,7 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Jellyfin live playback session: one negotiated HLS transcode URL plus
|
||||
/// A MediaBrowser live playback session: one negotiated HLS transcode URL plus
|
||||
/// `/Sessions/Playing*` heartbeats via [JellyfinLiveSessionTracker]. No
|
||||
/// program-scoped session and no time-shift — [recover] re-opens the same
|
||||
/// negotiated URL.
|
||||
|
||||
@@ -50,6 +50,14 @@ mixin _JellyfinMetadataEditMethods on _JellyfinClientInternals {
|
||||
return response.statusCode >= 200 && response.statusCode < 300;
|
||||
}
|
||||
|
||||
/// Upload custom artwork for [itemId].
|
||||
///
|
||||
/// The body must be base64 **text**, not the raw bytes: both dialects reject
|
||||
/// a binary body with HTTP 500 (Emby 4.9.5 says so explicitly — `The input is
|
||||
/// not a valid Base-64 string` — and Jellyfin 10.11 answers a bare
|
||||
/// `Error processing request.`), and both accept the encoded form with 204.
|
||||
/// The `Content-Type` still names the *image* type, which is how the server
|
||||
/// decides the on-disk extension.
|
||||
Future<bool> uploadItemImage(
|
||||
String itemId, {
|
||||
required String imageType,
|
||||
@@ -58,7 +66,7 @@ mixin _JellyfinMetadataEditMethods on _JellyfinClientInternals {
|
||||
}) async {
|
||||
final response = await _http.post(
|
||||
'/Items/${_segment(itemId)}/Images/${_segment(imageType)}',
|
||||
body: bytes,
|
||||
body: base64Encode(bytes),
|
||||
headers: {'Content-Type': contentType},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
|
||||
@@ -66,13 +66,17 @@ mixin _JellyfinMusicMethods on _JellyfinClientInternals {
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
}
|
||||
|
||||
/// Lyrics for [track] from `/Audio/{id}/Lyrics`. Jellyfin's `LyricDto`
|
||||
/// Lyrics for [track] from Jellyfin's `/Audio/{id}/Lyrics`. `LyricDto`
|
||||
/// carries per-line `Start` offsets in ticks when the source is an LRC /
|
||||
/// synced provider; `IsSynced` is absent on some server versions, so
|
||||
/// synced-ness is inferred from any line carrying a `Start`. 404 means
|
||||
/// the track has no lyrics → `null`.
|
||||
/// synced-ness is inferred from any line carrying a `Start`. A Jellyfin 404
|
||||
/// means the track has no lyrics → `null`; Emby is rejected before the request.
|
||||
@override
|
||||
Future<Lyrics?> fetchLyrics(MediaItem track) async {
|
||||
if (!dialect.supportsLyrics) {
|
||||
// Emby 4.9.5 binds `Lyrics` as an audio container and starts a failing ffmpeg transcode, so this call is harmful.
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final response = await _http.get('/Audio/${_segment(track.id)}/Lyrics');
|
||||
throwIfHttpError(response);
|
||||
|
||||
@@ -10,10 +10,10 @@ bool _canUseJellyfinStaticStreamFallback(Object error) {
|
||||
}
|
||||
|
||||
mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
/// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters
|
||||
/// at the item level (`raw['Chapters']`) and native skip segments through a
|
||||
/// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort
|
||||
/// so older servers still use chapter title fallback.
|
||||
/// Backend-neutral [PlaybackExtras] for [itemId]. Both dialects expose
|
||||
/// chapters at the item level (`raw['Chapters']`), while only Jellyfin exposes
|
||||
/// native skip segments through `/MediaSegments/{itemId}`. Segment loading is
|
||||
/// best-effort so unsupported and older servers use chapter title fallback.
|
||||
@override
|
||||
Future<PlaybackExtras> fetchPlaybackExtras(
|
||||
String itemId, {
|
||||
@@ -71,6 +71,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
required MediaItem item,
|
||||
required MediaSourceInfo mediaSource,
|
||||
}) async {
|
||||
// Emby 4.9.5 has neither the `Trickplay` field nor the tile route; its capabilities stop URL construction here.
|
||||
if (!capabilities.scrubThumbnails) return null;
|
||||
final manifest = mediaSource.trickplayByWidth;
|
||||
if (manifest == null || manifest.isEmpty) return null;
|
||||
@@ -83,6 +84,10 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
}
|
||||
|
||||
Future<List<MediaMarker>> _fetchMediaSegmentMarkers(String itemId) async {
|
||||
if (!dialect.supportsMediaSegments) {
|
||||
// Emby 4.9.5 returns 404 for `/MediaSegments/{itemId}`; empty markers preserve the chapter-name fallback.
|
||||
return const [];
|
||||
}
|
||||
final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId);
|
||||
try {
|
||||
return await fetchWithCacheFallback<List<MediaMarker>>(
|
||||
@@ -126,14 +131,13 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
return uri.replace(queryParameters: params).toString();
|
||||
}
|
||||
|
||||
/// Jellyfin playback URL resolution.
|
||||
/// MediaBrowser playback URL resolution.
|
||||
///
|
||||
/// Always POSTs `/Items/{id}/PlaybackInfo` so Jellyfin can resolve external
|
||||
/// Always POSTs `/Items/{id}/PlaybackInfo` so the server can resolve external
|
||||
/// audio/subtitle streams server-side. Uses the returned `TranscodingUrl`
|
||||
/// when the caller asked for a capped quality; otherwise — and on any
|
||||
/// DirectPlay decision — builds the static direct stream URL
|
||||
/// (`/Videos/{id}/stream?Static=true&api_key=...`) itself, because Jellyfin
|
||||
/// never returns a direct-play URL of its own.
|
||||
/// DirectPlay decision — builds the shared static direct stream URL
|
||||
/// (`/Videos/{id}/stream?Static=true&api_key=...`) itself.
|
||||
///
|
||||
/// The returned `MediaSourceInfo` is what the player uses for track-picker
|
||||
/// labels and auto-track selection by language.
|
||||
@@ -764,13 +768,28 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
'PlayMethod': playMethod ?? 'DirectPlay',
|
||||
'RepeatMode': 'RepeatNone',
|
||||
'PlaybackOrder': 'Default',
|
||||
'PlaySessionId': ?playSessionId,
|
||||
'PlaySessionId': ?_resolvePlaySessionId(playSessionId, itemId),
|
||||
'LiveStreamId': ?liveStreamId,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
}
|
||||
|
||||
/// Session id for a `/Sessions/Playing*` body.
|
||||
///
|
||||
/// Normally the caller forwards the id returned by the PlaybackInfo
|
||||
/// negotiation. Callers that never negotiated one — the offline
|
||||
/// watch-progress sync, which replays a recorded position — leave it null,
|
||||
/// which Emby rejects with HTTP 400 (see
|
||||
/// [MediaBrowserDialect.requiresPlaySessionId]). The synthesized id is
|
||||
/// derived from [itemId] so the started/progress/stopped triple of one replay
|
||||
/// lands on a single server-side session row instead of orphaning each call.
|
||||
String? _resolvePlaySessionId(String? playSessionId, String itemId) {
|
||||
if (playSessionId != null) return playSessionId;
|
||||
if (!dialect.requiresPlaySessionId) return null;
|
||||
return 'plezy-replay-$itemId';
|
||||
}
|
||||
|
||||
/// Tell the server the user has started playing [itemId].
|
||||
///
|
||||
/// [duration] is accepted for interface symmetry with Plex but ignored —
|
||||
@@ -847,7 +866,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
'MediaSourceId': ?mediaSourceId,
|
||||
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
|
||||
'Failed': false,
|
||||
'PlaySessionId': ?playSessionId,
|
||||
'PlaySessionId': ?_resolvePlaySessionId(playSessionId, itemId),
|
||||
'LiveStreamId': ?liveStreamId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -37,16 +37,28 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals {
|
||||
return LibraryPage<MediaPlaylist>(items: const [], totalCount: 0, offset: offset);
|
||||
}
|
||||
|
||||
// Emby returns the entire item index when `/Items` carries a `MediaTypes`
|
||||
// filter alongside `IncludeItemTypes=Playlist`, and never types a playlist
|
||||
// DTO, so it lists every playlist and the requested type only decides how
|
||||
// the results are labelled. See
|
||||
// [MediaBrowserDialect.playlistsFilterByMediaType].
|
||||
final filterByMediaType = dialect.playlistsFilterByMediaType;
|
||||
final labelType = requestedType.isEmpty ? 'video' : requestedType;
|
||||
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'IncludeItemTypes': 'Playlist',
|
||||
'Recursive': 'true',
|
||||
'MediaTypes': ?mediaType,
|
||||
'MediaTypes': ?(filterByMediaType ? mediaType : null),
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': pageSize.toString(),
|
||||
'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags',
|
||||
// `DateModified` carries the modification time on Emby, which leaves
|
||||
// `DateLastSaved` null for playlists. Unlike the detail route, the list
|
||||
// route honours `Fields` strictly, so the mapper's fallback is dead
|
||||
// unless the field is requested here.
|
||||
'Fields': 'Overview,DateCreated,DateLastSaved,DateModified,ChildCount,Tags',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
abort: abort,
|
||||
@@ -56,7 +68,7 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals {
|
||||
response.data,
|
||||
offset: offset,
|
||||
requestedSize: pageSize,
|
||||
map: (raw) => raw.map(_playlistFromJson).toList(),
|
||||
map: (raw) => raw.map((json) => _playlistFromJson(json, labelType: labelType)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +78,7 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals {
|
||||
if (item == null) return null;
|
||||
return MediaPlaylist(
|
||||
id: item.id,
|
||||
backend: MediaBackend.jellyfin,
|
||||
backend: dialect.backend,
|
||||
title: item.title ?? t.playlists.playlist,
|
||||
summary: item.summary,
|
||||
smart: false,
|
||||
@@ -141,13 +153,13 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals {
|
||||
|
||||
@override
|
||||
Future<bool> deletePlaylist(MediaPlaylist playlist) async {
|
||||
// Jellyfin treats playlists as items — same delete endpoint.
|
||||
// Both MediaBrowser dialects treat playlists as items — same delete endpoint.
|
||||
final response = await _http.delete('/Items/${_segment(playlist.id)}');
|
||||
throwIfHttpError(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Jellyfin's move endpoint takes an absolute index, so [afterItem] is
|
||||
/// The MediaBrowser move endpoint takes an absolute index, so [afterItem] is
|
||||
/// ignored — its sibling Plex impl needs it for `?after=`. The "wrong
|
||||
/// backend" / "missing playlistItemId" branches still return `false`
|
||||
/// (business not-applicable, not a network error) so callers can revert
|
||||
@@ -193,18 +205,23 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals {
|
||||
return true;
|
||||
}
|
||||
|
||||
MediaPlaylist _playlistFromJson(Map<String, dynamic> json) {
|
||||
/// [labelType] backs `MediaType` when the server omits it — always the case
|
||||
/// on Emby, which leaves playlists untyped.
|
||||
MediaPlaylist _playlistFromJson(Map<String, dynamic> json, {String labelType = 'video'}) {
|
||||
final id = json['Id'] as String? ?? '';
|
||||
return MediaPlaylist(
|
||||
id: id,
|
||||
backend: MediaBackend.jellyfin,
|
||||
backend: dialect.backend,
|
||||
title: json['Name'] as String? ?? t.playlists.playlist,
|
||||
summary: json['Overview'] as String?,
|
||||
smart: false,
|
||||
playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? 'video',
|
||||
playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? labelType,
|
||||
leafCount: json['ChildCount'] as int?,
|
||||
addedAt: jellyfinIsoToEpochSeconds(json['DateCreated'] as String?),
|
||||
updatedAt: jellyfinIsoToEpochSeconds(json['DateLastSaved'] as String?),
|
||||
// Emby leaves `DateLastSaved` null on a playlist and carries the
|
||||
// timestamp in `DateModified`; the item and library mappers already use
|
||||
// this same fallback.
|
||||
updatedAt: jellyfinIsoToEpochSeconds(json['DateLastSaved'] as String? ?? json['DateModified'] as String?),
|
||||
thumbPath: _absolutizeImagePath(_imageTagPath(id, json['ImageTags'])),
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
|
||||
@@ -3,38 +3,43 @@ part of '../../jellyfin_client.dart';
|
||||
mixin _JellyfinWatchStateMethods on _JellyfinClientInternals {
|
||||
@override
|
||||
Future<void> markWatched(MediaItem item) async {
|
||||
final response = await _http.post(
|
||||
'/UserPlayedItems/${_segment(item.id)}',
|
||||
queryParameters: {'userId': connection.userId},
|
||||
);
|
||||
final response = await _http.post(paths.playedItem(item.id), queryParameters: {'userId': connection.userId});
|
||||
throwIfHttpError(response);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markUnwatched(MediaItem item) async {
|
||||
final response = await _http.delete(
|
||||
'/UserPlayedItems/${_segment(item.id)}',
|
||||
queryParameters: {'userId': connection.userId},
|
||||
);
|
||||
final response = await _http.delete(paths.playedItem(item.id), queryParameters: {'userId': connection.userId});
|
||||
throwIfHttpError(response);
|
||||
}
|
||||
|
||||
/// Hide [item] from Continue Watching while keeping its resume position.
|
||||
///
|
||||
/// Emby-only. `POST /Users/{uid}/Items/{id}/HideFromResume?Hide=true` drops
|
||||
/// the row from `/Users/{uid}/Items/Resume` and leaves
|
||||
/// `UserData.PlaybackPositionTicks` untouched (verified on Emby 4.9.5).
|
||||
/// Jellyfin 10.11 has no equivalent route, so it keeps throwing and
|
||||
/// [ServerCapabilities.continueWatchingRemoval] keeps the affordance hidden.
|
||||
@override
|
||||
Future<void> removeFromContinueWatching(MediaItem item) async {
|
||||
if (!dialect.supportsContinueWatchingRemoval) {
|
||||
throw UnsupportedError('${dialect.productName} does not support removing items from Continue Watching.');
|
||||
}
|
||||
final response = await _http.post(paths.hideFromResume(item.id), queryParameters: {'Hide': 'true'});
|
||||
throwIfHttpError(response);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeFromContinueWatching(MediaItem item) async {
|
||||
throw UnsupportedError('Jellyfin does not support removing items from Continue Watching.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> rate(MediaItem item, double rating) async {
|
||||
// Lossy mapping — Jellyfin only stores a binary like/dislike. Treat
|
||||
// a negative input as "clear the rating" (DELETE), >= 6/10 as a like
|
||||
// Lossy mapping — the MediaBrowser API only stores a binary like/dislike.
|
||||
// Treat a negative input as "clear the rating" (DELETE), >= 6/10 as a like
|
||||
// (POST Likes=true), and the rest as a dislike (POST Likes=false).
|
||||
// No longer reachable from the rate sheet, which uses [setFavorite]
|
||||
// for Jellyfin instead; kept as transport for the abstract member.
|
||||
// for MediaBrowser servers instead; kept as transport for the abstract member.
|
||||
final response = rating < 0
|
||||
? await _http.delete('/UserItems/${_segment(item.id)}/Rating', queryParameters: {'userId': connection.userId})
|
||||
? await _http.delete(paths.itemRating(item.id), queryParameters: {'userId': connection.userId})
|
||||
: await _http.post(
|
||||
'/UserItems/${_segment(item.id)}/Rating',
|
||||
paths.itemRating(item.id),
|
||||
queryParameters: {'userId': connection.userId, 'Likes': (rating >= 6.0).toString()},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
@@ -44,9 +49,9 @@ mixin _JellyfinWatchStateMethods on _JellyfinClientInternals {
|
||||
Future<void> setFavorite(MediaItem item, bool isFavorite) => _setItemFavorite(item.id, isFavorite);
|
||||
|
||||
/// Toggle the per-user `IsFavorite` flag for [itemId]. Backs [setFavorite]
|
||||
/// and the live-TV favorite-channel adapter; works on any Jellyfin item.
|
||||
/// and the live-TV favorite-channel adapter; works on either MediaBrowser dialect.
|
||||
Future<void> _setItemFavorite(String itemId, bool isFavorite) async {
|
||||
final path = '/UserFavoriteItems/${_segment(itemId)}';
|
||||
final path = paths.favoriteItem(itemId);
|
||||
final response = isFavorite
|
||||
? await _http.post(path, queryParameters: {'userId': connection.userId})
|
||||
: await _http.delete(path, queryParameters: {'userId': connection.userId});
|
||||
|
||||
@@ -3,23 +3,28 @@ import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../utils/endpoint_race.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/media_server_timeouts.dart';
|
||||
import '../utils/url_utils.dart';
|
||||
|
||||
/// Result of a successful Jellyfin URL probe (`/System/Info/Public`).
|
||||
/// Result of a successful MediaBrowser server URL probe (`/System/Info/Public`).
|
||||
class JellyfinServerInfo {
|
||||
final String serverName;
|
||||
|
||||
/// Server's `Id` field — Jellyfin's machine identifier (UUID hex).
|
||||
/// Server's `Id` field — its stable machine identifier.
|
||||
final String machineId;
|
||||
|
||||
/// Server's reported version string.
|
||||
final String version;
|
||||
|
||||
const JellyfinServerInfo({required this.serverName, required this.machineId, required this.version});
|
||||
/// Dialect detected from public system info, or `null` when the response has
|
||||
/// no trustworthy Jellyfin/Emby discriminator.
|
||||
final MediaBrowserDialect? dialect;
|
||||
|
||||
const JellyfinServerInfo({required this.serverName, required this.machineId, required this.version, this.dialect});
|
||||
}
|
||||
|
||||
class JellyfinEndpointRaceResult {
|
||||
@@ -100,8 +105,9 @@ class JellyfinEndpointUserInputCandidates {
|
||||
class JellyfinEndpointDiscovery {
|
||||
static const int defaultPort = 8096;
|
||||
|
||||
JellyfinEndpointDiscovery({this._testHttpClientFactory});
|
||||
JellyfinEndpointDiscovery({this.dialect = MediaBrowserDialect.jellyfin, this._testHttpClientFactory});
|
||||
|
||||
final MediaBrowserDialect dialect;
|
||||
final http.Client Function()? _testHttpClientFactory;
|
||||
|
||||
MediaServerHttpClient _buildHttpClient({required String baseUrl}) {
|
||||
@@ -140,10 +146,15 @@ class JellyfinEndpointDiscovery {
|
||||
final id = data['Id'];
|
||||
final name = data['ServerName'] ?? data['LocalAddress'];
|
||||
if (id is! String || name is! String) {
|
||||
throw MediaServerUrlException('Server response missing Id/ServerName — not a Jellyfin server?');
|
||||
throw MediaServerUrlException('Server response missing Id/ServerName — not a ${dialect.productName} server?');
|
||||
}
|
||||
return (
|
||||
serverInfo: JellyfinServerInfo(serverName: name, machineId: id, version: data['Version'] as String? ?? ''),
|
||||
serverInfo: JellyfinServerInfo(
|
||||
serverName: name,
|
||||
machineId: id,
|
||||
version: data['Version'] as String? ?? '',
|
||||
dialect: MediaBrowserDialect.detectFromPublicSystemInfo(data),
|
||||
),
|
||||
effectiveBaseUrl: effectiveBaseUrl,
|
||||
);
|
||||
} on MediaServerUrlException {
|
||||
@@ -160,7 +171,7 @@ class JellyfinEndpointDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Races public Jellyfin probes and returns persistence-safe endpoints.
|
||||
/// Races public MediaBrowser server probes and returns persistence-safe endpoints.
|
||||
///
|
||||
/// [baseUrlsToPersist] contains caller-selected persistence candidates.
|
||||
/// Candidates that reported another machine are excluded; candidates that
|
||||
@@ -175,7 +186,7 @@ class JellyfinEndpointDiscovery {
|
||||
}) async {
|
||||
final urls = normalizeBaseUrls(baseUrls);
|
||||
if (urls.isEmpty) {
|
||||
throw MediaServerUrlException('Enter at least one Jellyfin server URL');
|
||||
throw MediaServerUrlException('Enter at least one ${dialect.productName} server URL');
|
||||
}
|
||||
|
||||
final persistUrls = baseUrlsToPersist == null ? urls : normalizeBaseUrls(baseUrlsToPersist);
|
||||
@@ -199,7 +210,7 @@ class JellyfinEndpointDiscovery {
|
||||
EndpointRaceSelection<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>? bestSelection;
|
||||
|
||||
await for (final selection in raceEndpointCandidates<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>(
|
||||
label: 'Jellyfin server URL',
|
||||
label: '${dialect.productName} server URL',
|
||||
candidates: candidates,
|
||||
preferredUrl: preferred,
|
||||
urlOf: (candidate) => candidate.url,
|
||||
@@ -232,7 +243,7 @@ class JellyfinEndpointDiscovery {
|
||||
|
||||
final selected = bestSelection ?? firstSelection;
|
||||
if (selected == null || selected.result.serverInfo == null) {
|
||||
throw MediaServerUrlException('No reachable Jellyfin server found');
|
||||
throw MediaServerUrlException('No reachable ${dialect.productName} server found');
|
||||
}
|
||||
|
||||
final Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult> successfulResults =
|
||||
@@ -256,7 +267,7 @@ class JellyfinEndpointDiscovery {
|
||||
|
||||
final selectedInfo = selectedResult.serverInfo;
|
||||
if (selectedInfo == null) {
|
||||
throw MediaServerUrlException('No reachable Jellyfin server found');
|
||||
throw MediaServerUrlException('No reachable ${dialect.productName} server found');
|
||||
}
|
||||
|
||||
final expected = hasExpectedMachineId ? expectedMachineIdTrimmed! : selectedInfo.machineId;
|
||||
@@ -270,7 +281,7 @@ class JellyfinEndpointDiscovery {
|
||||
final candidate = _selectValidationCandidate(groupResults, expectedMachineId: expectedMachineIdTrimmed);
|
||||
final info = candidate == null ? null : groupResults[candidate]?.serverInfo;
|
||||
if (info != null && info.machineId != expected) {
|
||||
throw MediaServerUrlException('The URLs point to different Jellyfin servers');
|
||||
throw MediaServerUrlException('The URLs point to different ${dialect.productName} servers');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,13 +290,13 @@ class JellyfinEndpointDiscovery {
|
||||
if (!validateUrlSet.contains(entry.key.url)) continue;
|
||||
final info = entry.value.serverInfo;
|
||||
if (info != null && info.machineId != expected) {
|
||||
throw MediaServerUrlException('The URLs point to different Jellyfin servers');
|
||||
throw MediaServerUrlException('The URLs point to different ${dialect.productName} servers');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedInfo.machineId != expected) {
|
||||
throw MediaServerUrlException('The URL does not match this Jellyfin server');
|
||||
throw MediaServerUrlException('The URL does not match this ${dialect.productName} server');
|
||||
}
|
||||
|
||||
final effectiveUrls = <String, String>{};
|
||||
@@ -392,7 +403,7 @@ class JellyfinEndpointDiscovery {
|
||||
return _selectLowestLatencyCandidate(results);
|
||||
}
|
||||
|
||||
static String _resolveEffectiveBaseUrl(String requestedBaseUrl, MediaServerResponse response) {
|
||||
String _resolveEffectiveBaseUrl(String requestedBaseUrl, MediaServerResponse response) {
|
||||
final requestedUri = response.requestUri;
|
||||
final effectiveUri = response.effectiveUri;
|
||||
if (requestedUri == null || effectiveUri == null || effectiveUri == requestedUri) {
|
||||
@@ -407,7 +418,9 @@ class JellyfinEndpointDiscovery {
|
||||
throw MediaServerUrlException('Server redirected to an unsupported URL');
|
||||
}
|
||||
if (requestedBaseUri.host.toLowerCase() != effectiveUri.host.toLowerCase()) {
|
||||
throw MediaServerUrlException('Server redirected to a different host. Enter the final Jellyfin URL directly');
|
||||
throw MediaServerUrlException(
|
||||
'Server redirected to a different host. Enter the final ${dialect.productName} URL directly',
|
||||
);
|
||||
}
|
||||
if (requestedBaseUri.scheme.toLowerCase() == 'https' && effectiveScheme != 'https') {
|
||||
throw MediaServerUrlException('Server redirected from HTTPS to an insecure URL');
|
||||
@@ -415,18 +428,23 @@ class JellyfinEndpointDiscovery {
|
||||
|
||||
const publicInfoPath = '/System/Info/Public';
|
||||
if (!effectiveUri.path.endsWith(publicInfoPath)) {
|
||||
throw MediaServerUrlException('Server redirected to an unsupported URL. Enter the final Jellyfin URL directly');
|
||||
throw MediaServerUrlException(
|
||||
'Server redirected to an unsupported URL. Enter the final ${dialect.productName} URL directly',
|
||||
);
|
||||
}
|
||||
final basePath = effectiveUri.path.substring(0, effectiveUri.path.length - publicInfoPath.length);
|
||||
return normalizeBaseUrl(effectiveUri.replace(path: basePath, query: null, fragment: null).toString());
|
||||
}
|
||||
|
||||
/// Normalizes a concrete Jellyfin base URL without inventing a scheme or port.
|
||||
/// Normalizes a concrete MediaBrowser base URL without inventing a scheme or port.
|
||||
static String normalizeBaseUrl(String input) => canonicalizeBaseUrl(input);
|
||||
|
||||
/// Expands a user-typed add/edit form entry into temporary probe candidates.
|
||||
/// These guesses are for discovery only; failed guesses should not be stored.
|
||||
static List<String> expandInputToBaseUrls(String input) {
|
||||
static List<String> expandInputToBaseUrls(
|
||||
String input, {
|
||||
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
||||
}) {
|
||||
final trimmed = canonicalizeBaseUrl(input);
|
||||
if (trimmed.isEmpty) return const [];
|
||||
if (_hasScheme(trimmed)) return [trimmed];
|
||||
@@ -448,7 +466,9 @@ class JellyfinEndpointDiscovery {
|
||||
} else {
|
||||
add(parsed.replace(scheme: 'http', port: defaultPort));
|
||||
add(parsed.replace(scheme: 'https'));
|
||||
add(parsed.replace(scheme: 'https', port: defaultPort));
|
||||
for (final port in dialect.httpsPortGuesses) {
|
||||
add(parsed.replace(scheme: 'https', port: port));
|
||||
}
|
||||
add(parsed.replace(scheme: 'http'));
|
||||
}
|
||||
return List.unmodifiable(result);
|
||||
@@ -460,7 +480,10 @@ class JellyfinEndpointDiscovery {
|
||||
return raw.split(RegExp(r'[\n,]+')).map((url) => url.trim()).where((url) => url.isNotEmpty).toList(growable: false);
|
||||
}
|
||||
|
||||
static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable<String> input) {
|
||||
static JellyfinEndpointUserInputCandidates buildUserInputCandidates(
|
||||
Iterable<String> input, {
|
||||
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
||||
}) {
|
||||
final probeBaseUrls = <String>[];
|
||||
final explicitBaseUrls = <String>[];
|
||||
final validationBaseUrlGroups = <List<String>>[];
|
||||
@@ -488,7 +511,7 @@ class JellyfinEndpointDiscovery {
|
||||
validationBaseUrlGroups.add([normalized]);
|
||||
} else {
|
||||
final group = <String>[];
|
||||
for (final candidate in expandInputToBaseUrls(normalized)) {
|
||||
for (final candidate in expandInputToBaseUrls(normalized, dialect: dialect)) {
|
||||
addProbe(candidate);
|
||||
group.add(candidate);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/udp_broadcast_sockets.dart';
|
||||
import 'jellyfin_endpoint_discovery.dart';
|
||||
@@ -10,17 +11,19 @@ class DiscoveredJellyfinServer {
|
||||
final String address;
|
||||
final String id;
|
||||
final String name;
|
||||
final MediaBrowserDialect dialect;
|
||||
|
||||
DiscoveredJellyfinServer({required this.address, required this.id, required this.name});
|
||||
DiscoveredJellyfinServer({required this.address, required this.id, required this.name, required this.dialect});
|
||||
}
|
||||
|
||||
class JellyfinLanDiscoveryService {
|
||||
static const int discoveryPort = 7359;
|
||||
static const String discoveryMessage = 'who is JellyfinServer?';
|
||||
|
||||
/// Sends two discovery packets 350 ms apart, then listens for
|
||||
/// [responseWindow] after the second packet.
|
||||
/// Sends the selected dialect's discovery packet twice, 350 ms apart, then
|
||||
/// listens for [responseWindow] after the second packet. Jellyfin and Emby
|
||||
/// answer only their own payload, while both use the same response shape.
|
||||
Future<List<DiscoveredJellyfinServer>> discover({
|
||||
required MediaBrowserDialect dialect,
|
||||
Duration responseWindow = const Duration(seconds: 2),
|
||||
InternetAddress? broadcastAddress,
|
||||
}) async {
|
||||
@@ -29,19 +32,19 @@ class JellyfinLanDiscoveryService {
|
||||
try {
|
||||
socketSet = await UdpBroadcastSockets.bind();
|
||||
socketSet.listen((datagram) {
|
||||
final server = parseDiscoveryResponse(datagram.data);
|
||||
final server = parseDiscoveryResponse(datagram.data, dialect: dialect);
|
||||
if (server == null) return;
|
||||
discovered.putIfAbsent(server.id, () => server);
|
||||
}, debugLabel: 'Jellyfin LAN discovery');
|
||||
}, debugLabel: '${dialect.productName} LAN discovery');
|
||||
|
||||
final data = utf8.encode(discoveryMessage);
|
||||
final data = utf8.encode(dialect.lanDiscoveryMessage);
|
||||
final target = broadcastAddress ?? UdpBroadcastSockets.limitedBroadcastAddress;
|
||||
socketSet.send(data, target, discoveryPort);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 350));
|
||||
socketSet.send(data, target, discoveryPort);
|
||||
await Future<void>.delayed(responseWindow);
|
||||
} catch (e, st) {
|
||||
appLogger.w('Jellyfin LAN discovery failed', error: e, stackTrace: st);
|
||||
appLogger.w('${dialect.productName} LAN discovery failed', error: e, stackTrace: st);
|
||||
} finally {
|
||||
await socketSet?.close();
|
||||
}
|
||||
@@ -56,12 +59,14 @@ class JellyfinLanDiscoveryService {
|
||||
if (name != 0) return name;
|
||||
final address = a.address.compareTo(b.address);
|
||||
if (address != 0) return address;
|
||||
return a.id.compareTo(b.id);
|
||||
final id = a.id.compareTo(b.id);
|
||||
if (id != 0) return id;
|
||||
return a.dialect.id.compareTo(b.dialect.id);
|
||||
});
|
||||
return List.unmodifiable(sorted);
|
||||
}
|
||||
|
||||
static DiscoveredJellyfinServer? parseDiscoveryResponse(List<int> data) {
|
||||
static DiscoveredJellyfinServer? parseDiscoveryResponse(List<int> data, {required MediaBrowserDialect dialect}) {
|
||||
try {
|
||||
final decoded = jsonDecode(utf8.decode(data));
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
@@ -73,7 +78,7 @@ class JellyfinLanDiscoveryService {
|
||||
|
||||
final normalized = JellyfinEndpointDiscovery.normalizeBaseUrl(address);
|
||||
if (normalized.isEmpty || id.trim().isEmpty || name.trim().isEmpty) return null;
|
||||
return DiscoveredJellyfinServer(address: normalized, id: id.trim(), name: name.trim());
|
||||
return DiscoveredJellyfinServer(address: normalized, id: id.trim(), name: name.trim(), dialect: dialect);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../media/ids.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
@@ -180,17 +180,22 @@ class JellyfinMappers {
|
||||
return '/Items/${_segment(id)}/Images/$type$indexPart$tagPart';
|
||||
}
|
||||
|
||||
/// Map a Jellyfin `BaseItemDto` (the `Items[]` shape returned by most
|
||||
/// Map a MediaBrowser `BaseItemDto` (the `Items[]` shape returned by most
|
||||
/// browse endpoints) into a [MediaItem]. Returns `null` when the server
|
||||
/// payload is missing `Id` — the mapped item would otherwise carry an
|
||||
/// empty-string id that breaks cache keys and image URLs (e.g.
|
||||
/// `/Items//Images/Primary`). Callers should filter nulls with
|
||||
/// `.whereType<MediaItem>()`.
|
||||
///
|
||||
/// [dialect] stamps the produced item so downstream UI resolves the right
|
||||
/// backend badge/label. Jellyfin and Emby DTOs are field-identical, so the
|
||||
/// mapping itself is shared.
|
||||
static MediaItem? mediaItem(
|
||||
Map<String, dynamic> item, {
|
||||
required ServerId serverId,
|
||||
String? serverName,
|
||||
required JellyfinImageAbsolutizer? absolutizer,
|
||||
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
||||
}) {
|
||||
final id = item['Id'] as String?;
|
||||
if (id == null || id.isEmpty) return null;
|
||||
@@ -212,6 +217,7 @@ class JellyfinMappers {
|
||||
: <String>[seriesBackdropPath];
|
||||
|
||||
final mapped = JellyfinMediaItem(
|
||||
dialect: dialect,
|
||||
id: id,
|
||||
kind: kind,
|
||||
guid: id,
|
||||
@@ -273,13 +279,13 @@ class JellyfinMappers {
|
||||
rating: (item['CommunityRating'] as num?)?.toDouble(),
|
||||
ratings: _ratingSources(item, kind),
|
||||
isFavorite: _userData(item)?['IsFavorite'] as bool?,
|
||||
genres: _stringList(item['Genres']),
|
||||
genres: _stringListOrNamePairs(item['Genres'], item['GenreItems']),
|
||||
directors: _peopleByType(item['People'], 'Director'),
|
||||
writers: _peopleByType(item['People'], 'Writer'),
|
||||
producers: _peopleByType(item['People'], 'Producer'),
|
||||
countries: _stringList(item['ProductionLocations']),
|
||||
collections: null,
|
||||
labels: _stringList(item['Tags']),
|
||||
labels: _stringListOrNamePairs(item['Tags'], item['TagItems']),
|
||||
styles: null,
|
||||
moods: null,
|
||||
roles: _actors(item['People']),
|
||||
@@ -297,18 +303,23 @@ class JellyfinMappers {
|
||||
return absolutizer == null ? mapped : absolutizer.applyTo(mapped);
|
||||
}
|
||||
|
||||
/// Map a Jellyfin "view" (returned by `/Users/{userId}/Views`) into a
|
||||
/// Map a MediaBrowser "view" (returned by `/Users/{userId}/Views`) into a
|
||||
/// [MediaLibrary]. The CollectionType field maps onto [MediaKind] roughly.
|
||||
/// Returns `null` when the view is missing `Id` — same rationale as
|
||||
/// [mediaItem].
|
||||
static MediaLibrary? library(Map<String, dynamic> view, {required ServerId serverId, String? serverName}) {
|
||||
static MediaLibrary? library(
|
||||
Map<String, dynamic> view, {
|
||||
required ServerId serverId,
|
||||
String? serverName,
|
||||
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
||||
}) {
|
||||
final id = view['Id'] as String?;
|
||||
if (id == null || id.isEmpty) return null;
|
||||
final collectionType = view['CollectionType'] as String?;
|
||||
final type = view['Type'] as String?;
|
||||
return MediaLibrary(
|
||||
id: id,
|
||||
backend: MediaBackend.jellyfin,
|
||||
backend: dialect.backend,
|
||||
title: view['Name'] as String? ?? t.libraries.fallbackTitle,
|
||||
kind: _libraryKindFromCollectionType(collectionType, type),
|
||||
defaultBrowseKinds: _defaultBrowseKindsFromCollectionType(collectionType, type),
|
||||
@@ -451,6 +462,25 @@ class JellyfinMappers {
|
||||
return stringListFromRaw(list);
|
||||
}
|
||||
|
||||
/// A `Genres`/`Tags` style list, falling back to its `…Items` name-pair
|
||||
/// sibling when the plain array is absent.
|
||||
///
|
||||
/// Emby never returns the plain `Tags` array on an item DTO — only
|
||||
/// `TagItems` — whatever `Fields` the request asks for (measured on Emby
|
||||
/// 4.9.5), so reading the plain key alone silently drops every tag.
|
||||
static List<String>? _stringListOrNamePairs(Object? plain, Object? namePairs) {
|
||||
final direct = stringListFromRaw(plain);
|
||||
if (direct != null && direct.isNotEmpty) return direct;
|
||||
if (namePairs is! List) return direct;
|
||||
final result = <String>[];
|
||||
for (final entry in namePairs) {
|
||||
if (entry is! Map<String, dynamic>) continue;
|
||||
final name = entry['Name'];
|
||||
if (name is String && name.trim().isNotEmpty) result.add(name.trim());
|
||||
}
|
||||
return nullIfEmptyList(result) ?? direct;
|
||||
}
|
||||
|
||||
static List<String>? _peopleByType(Object? list, String type) {
|
||||
if (list is! List) return null;
|
||||
final result = <String>[];
|
||||
|
||||
@@ -17,14 +17,18 @@ import 'jellyfin_client.dart';
|
||||
import 'media_list_playback_launcher.dart';
|
||||
import 'playlist_items_loader.dart';
|
||||
|
||||
/// Backend-neutral launcher for Jellyfin collections, playlists, and folders.
|
||||
/// Backend-neutral launcher for MediaBrowser collections, playlists, and
|
||||
/// folders.
|
||||
///
|
||||
/// Jellyfin has no server-side queue resource — the client fetches
|
||||
/// children (collection) or playlist items, applies shuffle locally,
|
||||
/// and hands the flat list to [PlaybackStateProvider] via
|
||||
/// [PlaybackStateProvider.setPlaybackFromLocalQueue] which the player
|
||||
/// already consumes (mirrors the path
|
||||
/// [EpisodeNavigationService] uses for episode windows).
|
||||
/// Jellyfin and Emby have no server-side queue resource — the client fetches
|
||||
/// children (collection) or playlist items, applies shuffle locally, and hands
|
||||
/// the flat list to [PlaybackStateProvider] via
|
||||
/// [PlaybackStateProvider.setPlaybackFromLocalQueue], which the player already
|
||||
/// consumes (mirrors the path [EpisodeNavigationService] uses for episode
|
||||
/// windows).
|
||||
///
|
||||
/// The persisted `jellyfin:` queue-id prefix predates Emby support and covers
|
||||
/// both dialects. It must remain stable so existing saved queues keep working.
|
||||
class JellyfinSequentialLauncher extends MediaListPlaybackLauncher {
|
||||
final BuildContext context;
|
||||
|
||||
@@ -99,9 +103,10 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher {
|
||||
);
|
||||
}
|
||||
|
||||
/// Launch playback from a Jellyfin folder row. Jellyfin has no server-side
|
||||
/// queue resource, so folders use the same local queue path as collections.
|
||||
/// The client query is video-only; music-only folders return [PlayQueueEmpty].
|
||||
/// Launch playback from a MediaBrowser folder row. Neither dialect has a
|
||||
/// server-side queue resource, so folders use the same local queue path as
|
||||
/// collections. The client query is video-only; music-only folders return
|
||||
/// [PlayQueueEmpty].
|
||||
@override
|
||||
Future<PlayQueueResult> launchFromFolder({
|
||||
required MediaItem folder,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import '../media/media_browser_dialect.dart';
|
||||
|
||||
/// Route builders for the endpoints where the Jellyfin and Emby dialects of the
|
||||
/// MediaBrowser API diverge.
|
||||
///
|
||||
/// Jellyfin 10.9 renamed a batch of user-scoped routes to unprefixed forms
|
||||
/// (`/Users/{uid}/PlayedItems/{id}` → `/UserPlayedItems/{id}`) and introduced
|
||||
/// `/Users/Me`. Emby only ever shipped the user-scoped spellings and fails on
|
||||
/// the new ones — `/Users/Me` returns 500 `Unrecognized Guid format` because
|
||||
/// `Me` is bound as a user id, and the rest 404. Keeping every divergent route
|
||||
/// here lets the client parts stay dialect-agnostic.
|
||||
///
|
||||
/// Routes that are identical on both dialects (`/Items`, `/Users/{uid}/Views`,
|
||||
/// `/Users/{uid}/Items/{id}`, `/Users/{uid}/Items/Latest`, `/Shows/*`,
|
||||
/// `/Sessions/Playing*`, `/Items/{id}/PlaybackInfo`, image and stream routes)
|
||||
/// deliberately do not appear here.
|
||||
class MediaBrowserPaths {
|
||||
const MediaBrowserPaths({required this.dialect, required this.userId});
|
||||
|
||||
final MediaBrowserDialect dialect;
|
||||
final String userId;
|
||||
|
||||
String get _user => '/Users/${Uri.encodeComponent(userId)}';
|
||||
|
||||
static String _id(String itemId) => Uri.encodeComponent(itemId);
|
||||
|
||||
/// The authenticated user's own DTO — health probe and user-preference read.
|
||||
String get currentUser => dialect.requiresUserScopedItemRoutes ? _user : '/Users/Me';
|
||||
|
||||
/// Continue Watching / resumable items.
|
||||
String get resumeItems => dialect.requiresUserScopedItemRoutes ? '$_user/Items/Resume' : '/UserItems/Resume';
|
||||
|
||||
/// Played flag write route (`POST` to mark, `DELETE` to unmark).
|
||||
String playedItem(String itemId) =>
|
||||
dialect.requiresUserScopedItemRoutes ? '$_user/PlayedItems/${_id(itemId)}' : '/UserPlayedItems/${_id(itemId)}';
|
||||
|
||||
/// Favourite flag write route (`POST` to add, `DELETE` to remove).
|
||||
String favoriteItem(String itemId) => dialect.requiresUserScopedItemRoutes
|
||||
? '$_user/FavoriteItems/${_id(itemId)}'
|
||||
: '/UserFavoriteItems/${_id(itemId)}';
|
||||
|
||||
/// Thumbs-up/down write route (`POST ?Likes=`, `DELETE` to clear).
|
||||
String itemRating(String itemId) =>
|
||||
dialect.requiresUserScopedItemRoutes ? '$_user/Items/${_id(itemId)}/Rating' : '/UserItems/${_id(itemId)}/Rating';
|
||||
|
||||
/// Trailers attached to a movie/series.
|
||||
String localTrailers(String itemId) => dialect.requiresUserScopedItemRoutes
|
||||
? '$_user/Items/${_id(itemId)}/LocalTrailers'
|
||||
: '/Items/${_id(itemId)}/LocalTrailers';
|
||||
|
||||
/// Extras/behind-the-scenes children.
|
||||
String specialFeatures(String itemId) => dialect.requiresUserScopedItemRoutes
|
||||
? '$_user/Items/${_id(itemId)}/SpecialFeatures'
|
||||
: '/Items/${_id(itemId)}/SpecialFeatures';
|
||||
|
||||
/// Hide an item from Continue Watching without touching its playback
|
||||
/// position (`?Hide=true` to hide, `?Hide=false` to restore).
|
||||
///
|
||||
/// Emby-only: Jellyfin 10.11 has no equivalent under either spelling
|
||||
/// (measured 404 for both `/UserItems/{id}/HideFromResume` and the
|
||||
/// user-scoped form), which is why [ServerCapabilities.jellyfin] leaves
|
||||
/// `continueWatchingRemoval` false while [ServerCapabilities.emby] sets it.
|
||||
String hideFromResume(String itemId) => '$_user/Items/${_id(itemId)}/HideFromResume';
|
||||
}
|
||||
@@ -46,10 +46,10 @@ class PlayQueueError extends PlayQueueResult {
|
||||
/// Backend-neutral playback launcher for collections and playlists.
|
||||
///
|
||||
/// Plex uses server-side `/playQueues` (one round trip, server tracks
|
||||
/// queue state). Jellyfin has no equivalent — the concrete Jellyfin launcher
|
||||
/// builds an in-memory queue from playable descendants or playlist items.
|
||||
/// [MediaListPlaybackLauncher.forItem] picks the implementation by inspecting
|
||||
/// the item's backend.
|
||||
/// queue state). MediaBrowser servers have no equivalent — the concrete
|
||||
/// [JellyfinSequentialLauncher] builds an in-memory queue from playable
|
||||
/// descendants or playlist items. [MediaListPlaybackLauncher.forItem] picks
|
||||
/// the implementation by inspecting the item's backend.
|
||||
abstract class MediaListPlaybackLauncher {
|
||||
/// Launch playback from a collection (a [MediaItem] with
|
||||
/// `kind == MediaKind.collection`) or a [MediaPlaylist].
|
||||
@@ -57,7 +57,7 @@ abstract class MediaListPlaybackLauncher {
|
||||
/// [startItem] (optional) starts playback at that item rather than the head
|
||||
/// of the queue — used by the playlist detail screen's "tap an item to
|
||||
/// start here" interaction. Plex passes it as `key` to `/playQueues`;
|
||||
/// Jellyfin rotates the locally-built queue. Ignored when [shuffle] is
|
||||
/// MediaBrowser rotates the locally-built queue. Ignored when [shuffle] is
|
||||
/// true.
|
||||
Future<PlayQueueResult> launchFromCollectionOrPlaylist({
|
||||
required Object item,
|
||||
@@ -67,17 +67,17 @@ abstract class MediaListPlaybackLauncher {
|
||||
});
|
||||
|
||||
/// Launch shuffled playback for a show or season. Plex builds a server-side
|
||||
/// `/playQueues` with `shuffle=1`; Jellyfin fetches the full episode list
|
||||
/// via `fetchClientSideEpisodeQueue`, shuffles locally, and publishes
|
||||
/// through `setPlaybackFromLocalQueue` (same path as the sequential
|
||||
/// queue from `EpisodeNavigationService`).
|
||||
/// `/playQueues` with `shuffle=1`; MediaBrowser fetches the full episode
|
||||
/// list via `fetchClientSideEpisodeQueue`, shuffles locally, and publishes
|
||||
/// through `setPlaybackFromLocalQueue` (same path as the sequential queue
|
||||
/// from `EpisodeNavigationService`).
|
||||
Future<PlayQueueResult> launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true});
|
||||
|
||||
/// Launch playback from a folder row of the library tree. Everything each
|
||||
/// backend needs is stamped onto [folder]: Plex builds a server-side
|
||||
/// `/playQueues` from [MediaItem.backendFolderKey] (returning a
|
||||
/// [PlayQueueError] when the row carries none), Jellyfin fetches the
|
||||
/// folder's playable descendants and publishes a local queue.
|
||||
/// [PlayQueueError] when the row carries none), while MediaBrowser fetches
|
||||
/// the folder's playable descendants and publishes a local queue.
|
||||
Future<PlayQueueResult> launchFromFolder({
|
||||
required MediaItem folder,
|
||||
required bool shuffle,
|
||||
@@ -88,7 +88,7 @@ abstract class MediaListPlaybackLauncher {
|
||||
/// [MediaItem.backend] / [MediaPlaylist.backend].
|
||||
static MediaListPlaybackLauncher forItem(BuildContext context, Object item) {
|
||||
final backend = _backendOf(item);
|
||||
if (backend == MediaBackend.jellyfin) {
|
||||
if (backend.usesMediaBrowserApi) {
|
||||
return JellyfinSequentialLauncher(context: context);
|
||||
}
|
||||
return PlexPlayQueueLauncher.forContext(context, item);
|
||||
|
||||
@@ -43,7 +43,7 @@ bool _isMediaServerAuthFailure(Object error) =>
|
||||
/// The internal map and public accessors are typed against the
|
||||
/// [MediaServerClient] interface so consumers don't depend on the concrete
|
||||
/// backend. Onboarding helpers branch on backend (Plex `PlexServer`,
|
||||
/// Jellyfin `JellyfinConnection`) and instantiate the matching client.
|
||||
/// MediaBrowser `JellyfinConnection`) and instantiate the matching client.
|
||||
class MultiServerManager {
|
||||
MultiServerManager({
|
||||
PlexClientFactory plexClientFactory = PlexClient.create,
|
||||
@@ -126,16 +126,18 @@ class MultiServerManager {
|
||||
}
|
||||
|
||||
/// Whether [compoundId] is still the client bound as the active user for
|
||||
/// [machineId]. Async Jellyfin work must re-check this before publishing a
|
||||
/// result — a profile switch can rebind the machine mid-probe.
|
||||
/// [machineId]. Async MediaBrowser work must re-check this before publishing
|
||||
/// a result — a profile switch can rebind the machine mid-probe.
|
||||
bool _isActiveJellyfin(String machineId, String compoundId) => _activeJellyfinMachine[machineId] == compoundId;
|
||||
|
||||
/// All Jellyfin clients ever added, keyed by the compound connection id
|
||||
/// (`{serverMachineId}/{userId}`). Lets two users on the same Jellyfin
|
||||
/// server coexist — adding the second user's client won't tear down the
|
||||
/// first user's in-flight operations. [_clients] holds the currently
|
||||
/// "active" entry per machineId for everyone-pass-machineId-as-serverId
|
||||
/// consumers (cache resolver, visibility filter, MediaItem.serverId).
|
||||
/// All MediaBrowser clients ever added, keyed by the compound connection id
|
||||
/// (`{serverMachineId}/{userId}`). This lets users and dialects coexist
|
||||
/// without tearing down another connection's in-flight operations. [_clients]
|
||||
/// holds the currently "active" entry per machineId for consumers that pass
|
||||
/// the public machine id as the server id.
|
||||
///
|
||||
/// These private members retain their Jellyfin-era names because one
|
||||
/// [JellyfinClient] implements both the Jellyfin and Emby dialects.
|
||||
final Map<String, JellyfinClient> _jellyfinByCompoundId = {};
|
||||
final Map<String, String> _activeJellyfinMachine = {};
|
||||
final Map<String, HealthStatus> _jellyfinHealthByCompoundId = {};
|
||||
@@ -159,15 +161,15 @@ class MultiServerManager {
|
||||
/// Debounce timer for connectivity events — collapses rapid network flapping
|
||||
Timer? _connectivityDebounce;
|
||||
|
||||
/// Get all registered server IDs (Plex + Jellyfin).
|
||||
/// Get all registered server IDs (Plex + MediaBrowser).
|
||||
///
|
||||
/// Sourced from [_clients] rather than [_plexServers] because
|
||||
/// [_plexServers] only holds the Plex-specific [PlexServer] structs
|
||||
/// (host/port metadata used for connection-racing). Jellyfin connections
|
||||
/// (host/port metadata used for connection-racing). MediaBrowser connections
|
||||
/// are registered as clients only — falling back to [_plexServers] would
|
||||
/// silently exclude them and callers (the active-profile binder, library
|
||||
/// refresh gates) would behave as if the manager were empty for
|
||||
/// Jellyfin-only profiles.
|
||||
/// MediaBrowser-only profiles.
|
||||
List<String> get serverIds => _clients.keys.toList();
|
||||
|
||||
List<String> get onlineServerIds => _serverStatus.entries.where((e) => e.value).map((e) => e.key).toList();
|
||||
@@ -212,10 +214,9 @@ class MultiServerManager {
|
||||
return getClient(serverId);
|
||||
}
|
||||
|
||||
/// Get the [PlexClient] for a server, or `null` if the server is Jellyfin
|
||||
/// (or not registered). Use for Plex-only flows (Live TV, server prefs,
|
||||
/// endpoint optimization) that don't yet have a backend-neutral
|
||||
/// equivalent on [MediaServerClient].
|
||||
/// Get the [PlexClient] for a server, or `null` if the server uses the
|
||||
/// MediaBrowser API (or is not registered). Use for Plex-only flows that
|
||||
/// don't yet have a backend-neutral equivalent on [MediaServerClient].
|
||||
PlexClient? getPlexClient(ServerId serverId) {
|
||||
final client = _clients[serverId];
|
||||
return client is PlexClient ? client : null;
|
||||
@@ -607,23 +608,23 @@ class MultiServerManager {
|
||||
return bound;
|
||||
}
|
||||
|
||||
/// Add a Jellyfin server backed by an authenticated [JellyfinConnection].
|
||||
/// Returns true on success.
|
||||
/// Add a MediaBrowser server backed by an authenticated
|
||||
/// [JellyfinConnection]. Returns true on success.
|
||||
///
|
||||
/// When a live client already exists for the same compound id and the
|
||||
/// connection is equivalent (see [canReuseJellyfinClient]), that client is
|
||||
/// reused instead of recreated — profile rebinds re-add unchanged
|
||||
/// connections routinely, and tearing the client down would abort its
|
||||
/// in-flight requests. A material change (token, deviceId, URL set) still
|
||||
/// replaces the client. This mirrors the Plex rebind path, where
|
||||
/// in-flight requests. A material change (dialect, token, deviceId, URL set)
|
||||
/// still replaces the client. This mirrors the Plex rebind path, where
|
||||
/// [refreshTokensForProfile] reuses the online client via an in-place
|
||||
/// token update.
|
||||
///
|
||||
/// Jellyfin clients use the shared endpoint-racing flow when multiple URLs
|
||||
/// are configured, then instantiate the client against the lowest-latency
|
||||
/// reachable URL.
|
||||
/// MediaBrowser clients use the shared endpoint-racing flow when multiple
|
||||
/// URLs are configured, then instantiate the client against the
|
||||
/// lowest-latency reachable URL.
|
||||
///
|
||||
/// Two users on the same Jellyfin server are tracked separately in
|
||||
/// Two users on the same MediaBrowser server are tracked separately in
|
||||
/// [_jellyfinByCompoundId]; only one is "active" per machineId at a time.
|
||||
/// Adding the second user's connection doesn't close the first user's
|
||||
/// client (preserves any in-flight operations on the prior profile).
|
||||
@@ -640,7 +641,7 @@ class MultiServerManager {
|
||||
var endpointSelectionValidated = false;
|
||||
if (connection.baseUrls.length > 1) {
|
||||
try {
|
||||
final endpoint = await JellyfinEndpointDiscovery().raceEndpoints(
|
||||
final endpoint = await JellyfinEndpointDiscovery(dialect: connection.dialect).raceEndpoints(
|
||||
connection.baseUrls,
|
||||
preferredUrl: connection.baseUrl,
|
||||
expectedMachineId: connection.serverMachineId,
|
||||
@@ -657,7 +658,7 @@ class MultiServerManager {
|
||||
endpointSelectionValidated = true;
|
||||
} catch (e, st) {
|
||||
appLogger.w(
|
||||
'Jellyfin endpoint race failed; using only the stored active endpoint',
|
||||
'${connection.dialect.productName} endpoint race failed; using only the stored active endpoint',
|
||||
error: e.runtimeType,
|
||||
stackTrace: st,
|
||||
);
|
||||
@@ -709,13 +710,20 @@ class MultiServerManager {
|
||||
_jellyfinHealthByCompoundId[compoundId] = health;
|
||||
_applyHealth(ServerId(machineId), health);
|
||||
|
||||
appLogger.i('Added Jellyfin server: ${resolvedConnection.serverName}${healthy ? '' : ' (unhealthy)'}');
|
||||
appLogger.i(
|
||||
'Added ${resolvedConnection.dialect.productName} server: '
|
||||
'${resolvedConnection.serverName}${healthy ? '' : ' (unhealthy)'}',
|
||||
);
|
||||
if (_connectivitySubscription == null && healthy) {
|
||||
_startNetworkMonitoring();
|
||||
}
|
||||
return healthy;
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e('Failed to add Jellyfin server ${connection.serverName}', error: e, stackTrace: stackTrace);
|
||||
appLogger.e(
|
||||
'Failed to add ${connection.dialect.productName} server ${connection.serverName}',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -723,6 +731,7 @@ class MultiServerManager {
|
||||
/// Whether the live client bound to [live] can serve [incoming] without
|
||||
/// being recreated. Recreation is required when a field baked into the
|
||||
/// client at construction time changes:
|
||||
/// - `dialect` controls route and capability behavior;
|
||||
/// - `accessToken` / `deviceId` are embedded in the auth headers when the
|
||||
/// HTTP client is built;
|
||||
/// - `baseUrls` fixes the failover candidate set. Compared as a set: both
|
||||
@@ -736,7 +745,8 @@ class MultiServerManager {
|
||||
/// precedes this check.
|
||||
@visibleForTesting
|
||||
static bool canReuseJellyfinClient({required JellyfinConnection live, required JellyfinConnection incoming}) {
|
||||
return live.accessToken == incoming.accessToken &&
|
||||
return live.dialect == incoming.dialect &&
|
||||
live.accessToken == incoming.accessToken &&
|
||||
live.deviceId == incoming.deviceId &&
|
||||
setEquals(live.baseUrls.toSet(), incoming.baseUrls.toSet());
|
||||
}
|
||||
@@ -857,8 +867,8 @@ class MultiServerManager {
|
||||
}
|
||||
|
||||
/// Test connection health for all servers. The probe is backend-defined:
|
||||
/// Plex hits `/identity` (HTTP 200), Jellyfin hits `/Users/Me` (auth-required)
|
||||
/// so a server with a revoked token is correctly reported as offline.
|
||||
/// Plex hits `/identity`; MediaBrowser uses the dialect's current-user route.
|
||||
/// Both are auth-required so a revoked token is reported as offline.
|
||||
Future<void> checkServerHealth() async {
|
||||
// Coalesce concurrent calls — return the in-flight future if one exists
|
||||
if (_activeHealthCheck != null) return _activeHealthCheck!;
|
||||
@@ -1104,17 +1114,19 @@ class MultiServerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt reconnection for a single offline Jellyfin server.
|
||||
/// Attempt reconnection for a single offline MediaBrowser server.
|
||||
///
|
||||
/// Jellyfin has a single fixed base URL — there's no connection-racing to
|
||||
/// run, just a health round-trip. The existing [JellyfinClient] is reused
|
||||
/// (the access token persists in [JellyfinConnection]); on success we flip
|
||||
/// the machine slot back to online so MediaServer-aware UI un-greys the
|
||||
/// Reuse the existing [JellyfinClient], whose request-level failover owns its
|
||||
/// endpoint set, and perform an authenticated health round-trip. On success,
|
||||
/// flip the machine slot back to online so MediaServer-aware UI un-greys the
|
||||
/// entry.
|
||||
Future<void> _reconnectJellyfinServer(String machineId, JellyfinClient client) async {
|
||||
final expectedCompoundId = client.connection.id;
|
||||
try {
|
||||
appLogger.d('Attempting reconnection for Jellyfin server ${client.connection.serverName}');
|
||||
appLogger.d(
|
||||
'Attempting reconnection for ${client.connection.dialect.productName} server '
|
||||
'${client.connection.serverName}',
|
||||
);
|
||||
final status = await client.checkHealth();
|
||||
_jellyfinHealthByCompoundId[expectedCompoundId] = status;
|
||||
if (!_isActiveJellyfin(machineId, expectedCompoundId)) {
|
||||
@@ -1211,9 +1223,9 @@ class MultiServerManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget safe: both backends' `checkHealth` catch every failure
|
||||
/// and fold it into a [HealthStatus], and the scheduled reconnection guards
|
||||
/// its own errors — this future must never complete with one.
|
||||
/// Fire-and-forget safe: both concrete clients' `checkHealth` implementations
|
||||
/// catch every failure and fold it into a [HealthStatus], and the scheduled
|
||||
/// reconnection guards its own errors — this future must never complete with one.
|
||||
Future<void> _verifyServerEndpointsExhausted(ServerId serverId) async {
|
||||
final client = _clients[serverId];
|
||||
if (client == null || !_endpointHealthChecks.add(serverId)) return;
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/download_operations.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
@@ -25,8 +24,8 @@ import 'watch_state_resolver.dart';
|
||||
|
||||
/// Service for managing offline watch progress and syncing it back to the
|
||||
/// owning server. Backend-neutral over [MediaServerClient] — Plex actions
|
||||
/// hit `/:/scrobble` and `/:/timeline`, Jellyfin actions hit
|
||||
/// `/UserPlayedItems/{id}` and `/Sessions/Playing*` through the same queue.
|
||||
/// hit `/:/scrobble` and `/:/timeline`, while MediaBrowser actions use their
|
||||
/// dialect's played-item route and `/Sessions/Playing*` through the same queue.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Queuing progress updates when offline
|
||||
@@ -53,7 +52,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
|
||||
/// Get watched threshold for a server. Cascades:
|
||||
/// 1. Plex's fetched server prefs (`/:/prefs`)
|
||||
/// 2. Jellyfin's fixed [MediaServerClient.watchedThreshold] (0.9)
|
||||
/// 2. MediaBrowser's fixed [MediaServerClient.watchedThreshold] (0.9)
|
||||
/// 3. Cached value in SettingsService (mirrored by PlexClient.fetchServerPrefs)
|
||||
/// 4. Default 90%
|
||||
double getWatchedThreshold(ServerId serverId) {
|
||||
@@ -61,9 +60,9 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
if (client is PlexClient && client.serverPrefs.isNotEmpty) {
|
||||
return client.watchedThreshold;
|
||||
}
|
||||
if (client != null && client.backend != MediaBackend.plex) {
|
||||
// Jellyfin (and any future neutral backend) — the client exposes a
|
||||
// fixed threshold that mirrors the wire-protocol behaviour.
|
||||
if (client != null && client.backend.usesMediaBrowserApi) {
|
||||
// MediaBrowser clients expose the fixed threshold that mirrors their
|
||||
// wire-protocol behaviour.
|
||||
return client.watchedThreshold;
|
||||
}
|
||||
// No client bound (offline) or Plex prefs not loaded yet — use the
|
||||
@@ -455,9 +454,9 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
}
|
||||
final client = _serverManager.getClient(ServerId(action.serverId));
|
||||
if (client == null) return null;
|
||||
if (client.backend == MediaBackend.jellyfin && client.cacheServerId != action.serverId) {
|
||||
if (client.backend.usesMediaBrowserApi && client.cacheServerId != action.serverId) {
|
||||
appLogger.w(
|
||||
'Refusing to sync unscoped Jellyfin action ${action.id} for ${action.serverId}:${action.ratingKey}; '
|
||||
'Refusing to sync unscoped MediaBrowser action ${action.id} for ${action.serverId}:${action.ratingKey}; '
|
||||
'no queued client scope is available',
|
||||
);
|
||||
return null;
|
||||
@@ -574,15 +573,15 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
// Push resumable progress, or a completed offline playback. Jellyfin's
|
||||
// Push resumable progress, or a completed offline playback.
|
||||
// `/Sessions/Playing/Stopped` ignores events without an open session
|
||||
// row, so non-Plex backends still get a lightweight Started call.
|
||||
// row, so MediaBrowser backends still get a lightweight Started call.
|
||||
if (action.viewOffset != null) {
|
||||
final duration = action.duration == null ? null : Duration(milliseconds: action.duration!);
|
||||
final position = action.shouldMarkWatched && duration != null
|
||||
? duration
|
||||
: Duration(milliseconds: action.viewOffset!);
|
||||
if (!action.shouldMarkWatched || client.backend != MediaBackend.plex) {
|
||||
if (!action.shouldMarkWatched || client.backend.usesMediaBrowserApi) {
|
||||
try {
|
||||
await client.reportPlaybackStarted(itemId: action.ratingKey, position: position, duration: duration);
|
||||
} catch (e) {
|
||||
@@ -603,8 +602,8 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// If progress exceeded threshold, also mark as watched. On backends
|
||||
// that mark played from the stopped report above (Jellyfin) this only
|
||||
// emits the local watch event — an explicit markWatched would
|
||||
// that mark played from the stopped report above (MediaBrowser), this
|
||||
// only emits the local watch event — an explicit markWatched would
|
||||
// double-scrobble via the Trakt plugin (#1287).
|
||||
if (action.shouldMarkWatched) {
|
||||
await client.markWatchedFromPlaybackStop(item);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user