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:
edde746
2026-08-05 06:09:26 +02:00
parent f36e20bcad
commit 05fd622968
128 changed files with 4917 additions and 1429 deletions
+62 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_browser_dialect.dart';
/// Backend-agnostic [Connection] sealed-class tests. The
/// `connection_registry_test` already covers DB persistence; these focus on
@@ -16,12 +17,22 @@ void main() {
});
test('fromId throws on unknown id (no silent fallback)', () {
expect(() => ConnectionKind.fromId('emby'), throwsA(isA<ArgumentError>()));
expect(() => ConnectionKind.fromId('kodi'), throwsA(isA<ArgumentError>()));
});
test('backend mapping is total', () {
expect(ConnectionKind.plex.backend, MediaBackend.plex);
expect(ConnectionKind.jellyfin.backend, MediaBackend.jellyfin);
expect(ConnectionKind.emby.backend, MediaBackend.emby);
});
test('dialect maps only the MediaBrowser kinds and round-trips', () {
expect(ConnectionKind.plex.dialect, isNull);
expect(ConnectionKind.jellyfin.dialect, MediaBrowserDialect.jellyfin);
expect(ConnectionKind.emby.dialect, MediaBrowserDialect.emby);
for (final dialect in MediaBrowserDialect.values) {
expect(ConnectionKind.fromDialect(dialect).dialect, dialect);
}
});
});
@@ -178,9 +189,59 @@ void main() {
});
test('kind and backend match Jellyfin', () {
expect(base.dialect, MediaBrowserDialect.jellyfin);
expect(base.kind, ConnectionKind.jellyfin);
expect(base.backend, MediaBackend.jellyfin);
});
test('an Emby dialect drives kind, backend and the persisted discriminator', () {
final emby = base.copyWith(dialect: MediaBrowserDialect.emby);
expect(emby.kind, ConnectionKind.emby);
expect(emby.kind.id, 'emby');
expect(emby.backend, MediaBackend.emby);
// The dialect lives in the `connections.kind` column, never in the
// encrypted config payload, so exactly one discriminator is on disk.
expect(emby.toConfigJson().containsKey('dialect'), isFalse);
});
test('fromConfigJson restores the dialect handed in by the registry', () {
final restored = JellyfinConnection.fromConfigJson(
id: 'srv-1/user-1',
json: base.toConfigJson(),
status: ConnectionStatus.online,
createdAt: base.createdAt,
dialect: MediaBrowserDialect.emby,
);
expect(restored.dialect, MediaBrowserDialect.emby);
expect(restored.kind, ConnectionKind.emby);
expect(restored.accessToken, 'tok-abc');
});
test('fromConfigJson defaults to Jellyfin for rows written before Emby support', () {
final restored = JellyfinConnection.fromConfigJson(
id: 'legacy',
json: const {'baseUrl': 'https://jellyfin.example.com'},
status: ConnectionStatus.unknown,
createdAt: DateTime.utc(2026),
);
expect(restored.dialect, MediaBrowserDialect.jellyfin);
expect(restored.kind, ConnectionKind.jellyfin);
});
test('empty-payload serverName falls back to the dialect product name', () {
final emby = JellyfinConnection.fromConfigJson(
id: 'orphan',
json: const {},
status: ConnectionStatus.unknown,
createdAt: DateTime.utc(2026),
dialect: MediaBrowserDialect.emby,
);
expect(emby.serverName, 'Emby');
});
});
group('PlexAccountConnection serialization', () {
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/services/credential_vault.dart';
import 'package:plezy/services/plex_auth_service.dart';
@@ -34,6 +35,21 @@ JellyfinConnection _jellyfin({String id = 'srv-1', String userName = 'edde', int
);
}
JellyfinConnection _emby({String id = 'emby-1', String accessToken = 'emby-token', int createdAtMs = 1_000_000}) {
return JellyfinConnection(
id: id,
baseUrl: 'https://emby.local',
serverName: 'Emby Home',
serverMachineId: 'emby-machine-$id',
userId: 'user-$id',
userName: 'edde',
accessToken: accessToken,
deviceId: 'dev-1',
dialect: MediaBrowserDialect.emby,
createdAt: DateTime.fromMillisecondsSinceEpoch(createdAtMs),
);
}
PlexAccountConnection _plex({String id = 'plex-1'}) {
return PlexAccountConnection(
id: id,
@@ -105,18 +121,49 @@ void main() {
expect((jelly as JellyfinConnection).baseUrl, 'https://jellyfin.local');
});
test('Emby upsert preserves its persisted discriminator and connection dialect', () async {
await registry.upsert(_emby(id: 'e'));
final restored = await registry.get('e') as JellyfinConnection;
final row = await (db.select(db.connections)..where((table) => table.id.equals('e'))).getSingle();
expect(restored.dialect, MediaBrowserDialect.emby);
expect(restored.kind, ConnectionKind.emby);
expect(restored.kind.id, 'emby');
expect(row.kind, 'emby');
});
test('Jellyfin and Emby rows coexist and round-trip to their own dialects', () async {
await registry.upsert(_jellyfin(id: 'j'));
await registry.upsert(_emby(id: 'e'));
final jellyfin = await registry.get('j') as JellyfinConnection;
final emby = await registry.get('e') as JellyfinConnection;
final rows = await db.select(db.connections).get();
final kindById = {for (final row in rows) row.id: row.kind};
expect(jellyfin.dialect, MediaBrowserDialect.jellyfin);
expect(jellyfin.kind, ConnectionKind.jellyfin);
expect(emby.dialect, MediaBrowserDialect.emby);
expect(emby.kind, ConnectionKind.emby);
expect(kindById, {'j': 'jellyfin', 'e': 'emby'});
});
test('upsert encrypts tokens at rest and decrypts on read', () async {
await registry.upsert(_plex(id: 'p'));
await registry.upsert(_jellyfin(id: 'j'));
await registry.upsert(_emby(id: 'e', accessToken: 'emby-raw-token'));
final rows = await db.select(db.connections).get();
expect(rows.singleWhere((r) => r.id == 'p').configJson, isNot(contains('tok-p')));
expect(rows.singleWhere((r) => r.id == 'p').configJson, isNot(contains('server-token-p')));
expect(rows.singleWhere((r) => r.id == 'j').configJson, isNot(contains('tok-j')));
expect(rows.singleWhere((r) => r.id == 'e').configJson, isNot(contains('emby-raw-token')));
expect((await registry.get('p') as PlexAccountConnection).accountToken, 'tok-p');
expect((await registry.get('p') as PlexAccountConnection).servers.single.accessToken, 'server-token-p');
expect((await registry.get('j') as JellyfinConnection).accessToken, 'tok-j');
expect((await registry.get('e') as JellyfinConnection).accessToken, 'emby-raw-token');
});
test('read migrates legacy plaintext Plex server tokens', () async {
+140
View File
@@ -0,0 +1,140 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_browser_dialect.dart';
/// Contract tests for the Jellyfin/Emby dialect discriminator.
///
/// The detection fixtures are verbatim `/System/Info/Public` bodies captured
/// from Jellyfin 10.10.7 and Emby 4.9.5, so a shape change on either server
/// surfaces here rather than as a mis-labelled connection.
void main() {
group('MediaBrowserDialect ids', () {
test('id round-trips through fromId', () {
for (final dialect in MediaBrowserDialect.values) {
expect(MediaBrowserDialect.fromId(dialect.id), dialect);
}
});
test('fromId throws on an unknown id', () {
expect(() => MediaBrowserDialect.fromId('plex'), throwsA(isA<ArgumentError>()));
});
test('ids match the MediaBackend ids they map to', () {
for (final dialect in MediaBrowserDialect.values) {
expect(dialect.backend.id, dialect.id);
expect(dialect.backend.dialect, dialect);
}
});
test('fromIdOrJellyfin tolerates legacy rows that carry no dialect', () {
expect(MediaBrowserDialect.fromIdOrJellyfin(null), MediaBrowserDialect.jellyfin);
expect(MediaBrowserDialect.fromIdOrJellyfin(''), MediaBrowserDialect.jellyfin);
expect(MediaBrowserDialect.fromIdOrJellyfin('nonsense'), MediaBrowserDialect.jellyfin);
expect(MediaBrowserDialect.fromIdOrJellyfin('emby'), MediaBrowserDialect.emby);
});
});
group('MediaBrowserDialect capabilities', () {
test('Jellyfin-only features are off for Emby', () {
expect(MediaBrowserDialect.jellyfin.supportsQuickConnect, isTrue);
expect(MediaBrowserDialect.emby.supportsQuickConnect, isFalse);
expect(MediaBrowserDialect.jellyfin.supportsTrickplay, isTrue);
expect(MediaBrowserDialect.emby.supportsTrickplay, isFalse);
expect(MediaBrowserDialect.jellyfin.supportsMediaSegments, isTrue);
expect(MediaBrowserDialect.emby.supportsMediaSegments, isFalse);
// Emby resolves /Audio/{id}/Lyrics to audio streaming with `Lyrics` as
// the container and starts a failing ffmpeg process, so this gate is
// load-bearing rather than cosmetic.
expect(MediaBrowserDialect.jellyfin.supportsLyrics, isTrue);
expect(MediaBrowserDialect.emby.supportsLyrics, isFalse);
expect(MediaBrowserDialect.jellyfin.supportsAggregateItemFilters, isTrue);
expect(MediaBrowserDialect.emby.supportsAggregateItemFilters, isFalse);
});
test('only Emby needs the pre-10.9 user-scoped item routes', () {
expect(MediaBrowserDialect.emby.requiresUserScopedItemRoutes, isTrue);
expect(MediaBrowserDialect.jellyfin.requiresUserScopedItemRoutes, isFalse);
});
test('LAN discovery payloads are distinct so the datagram identifies the dialect', () {
expect(MediaBrowserDialect.jellyfin.lanDiscoveryMessage, 'who is JellyfinServer?');
expect(MediaBrowserDialect.emby.lanDiscoveryMessage, 'who is EmbyServer?');
});
test('Emby adds its 8920 HTTPS default to the port guesses', () {
expect(MediaBrowserDialect.jellyfin.httpsPortGuesses, [8096]);
expect(MediaBrowserDialect.emby.httpsPortGuesses, contains(8920));
expect(MediaBrowserDialect.emby.httpsPortGuesses, contains(8096));
});
test('product names are the untranslated brand names', () {
expect(MediaBrowserDialect.jellyfin.productName, 'Jellyfin');
expect(MediaBrowserDialect.emby.productName, 'Emby');
});
});
group('MediaBrowserDialect.detectFromPublicSystemInfo', () {
test('identifies a real Jellyfin 10.10.7 body by ProductName', () {
expect(
MediaBrowserDialect.detectFromPublicSystemInfo(const {
'LocalAddress': 'http://172.17.0.3:8096',
'ServerName': '0c1d332b2f44',
'Version': '10.10.7',
'ProductName': 'Jellyfin Server',
'OperatingSystem': '',
'Id': 'c88f271ded7e42cf87e6b12c287906ac',
'StartupWizardCompleted': true,
}),
MediaBrowserDialect.jellyfin,
);
});
test('identifies a real Emby 4.9.5 body by its RemoteAddresses array', () {
expect(
MediaBrowserDialect.detectFromPublicSystemInfo(const {
'LocalAddresses': <String>[],
'RemoteAddresses': <String>[],
'ServerName': '7befeeb2e8c9',
'Version': '4.9.5.0',
'Id': '9b6b1ea5ad4c4409a89f0f5e40607022',
}),
MediaBrowserDialect.emby,
);
});
test('an explicit Emby ProductName wins over shape sniffing', () {
expect(
MediaBrowserDialect.detectFromPublicSystemInfo(const {'ProductName': 'Emby Server', 'Id': 'x'}),
MediaBrowserDialect.emby,
);
});
test('returns null when neither signal is present so the caller keeps the user choice', () {
expect(MediaBrowserDialect.detectFromPublicSystemInfo(const {'ServerName': 'x', 'Id': 'y'}), isNull);
expect(MediaBrowserDialect.detectFromPublicSystemInfo(const {'ProductName': ''}), isNull);
});
});
group('MediaBackend MediaBrowser predicate', () {
test('usesMediaBrowserApi covers Jellyfin and Emby but not Plex', () {
expect(MediaBackend.plex.usesMediaBrowserApi, isFalse);
expect(MediaBackend.jellyfin.usesMediaBrowserApi, isTrue);
expect(MediaBackend.emby.usesMediaBrowserApi, isTrue);
expect(MediaBackend.plex.dialect, isNull);
});
test('emby round-trips through the persisted id helpers', () {
expect(MediaBackend.emby.id, 'emby');
expect(MediaBackend.fromId('emby'), MediaBackend.emby);
expect(MediaBackend.fromString('emby'), MediaBackend.emby);
});
test('a missing backend id still falls back to Plex for pre-Jellyfin cache rows', () {
expect(MediaBackend.fromString(null), MediaBackend.plex);
});
});
}
+41
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_part.dart';
@@ -639,6 +640,46 @@ void main() {
expect(decoded.id, 'legacy');
expect(decoded.kind, MediaKind.movie);
});
test('an Emby item persists its own backend id and restores the dialect', () {
const original = JellyfinMediaItem(
dialect: MediaBrowserDialect.emby,
// Emby item ids are short numeric strings, not GUIDs.
id: '7330',
kind: MediaKind.movie,
title: 'Movie 001',
playlistItemId: 'entry-1',
);
final json = original.toJson();
final decoded = MediaItem.fromJson(json);
// One discriminator on the wire: the union key carries the resolved
// backend and the dialect is rebuilt from it.
expect(json['backend'], 'emby');
expect(json.containsKey('dialect'), isFalse);
expect(decoded, isA<JellyfinMediaItem>());
expect(decoded.backend, MediaBackend.emby);
expect((decoded as JellyfinMediaItem).dialect, MediaBrowserDialect.emby);
expect(decoded.playlistItemId, 'entry-1');
expect(decoded.id, '7330');
});
test('the compat factory routes both MediaBrowser backends to one variant', () {
final emby = MediaItem(id: 'e1', backend: MediaBackend.emby, kind: MediaKind.movie);
final jellyfin = MediaItem(id: 'j1', backend: MediaBackend.jellyfin, kind: MediaKind.movie);
expect(emby, isA<JellyfinMediaItem>());
expect(jellyfin, isA<JellyfinMediaItem>());
expect(emby.backend, MediaBackend.emby);
expect(jellyfin.backend, MediaBackend.jellyfin);
});
test('copyWith preserves the Emby dialect', () {
final emby = MediaItem(id: 'e1', backend: MediaBackend.emby, kind: MediaKind.movie) as JellyfinMediaItem;
expect(emby.copyWith(title: 'renamed').backend, MediaBackend.emby);
});
});
group('MediaItem.displayTitle', () {
@@ -0,0 +1,169 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/metadata_edit/jellyfin_metadata_edit_adapter.dart';
import 'package:plezy/services/jellyfin_client.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/http_fixtures.dart';
import '../test_helpers/media_items.dart';
void main() {
test('Emby save mirrors genres and tags into the name-pair arrays', () async {
final postedBodies = <String>[];
final client = _clientForDto(connection: testEmbyConnection(), dto: _embyItem(), postedBodies: postedBodies);
addTearDown(client.close);
final adapter = JellyfinMetadataEditAdapter(client);
final draft = await adapter.load(_sourceItem(MediaBackend.emby));
draft.setValue('genre', ['Adventure', 'Comedy']);
draft.setValue('label', ['family', 'favorite']);
expect(await adapter.save(draft), isTrue);
expect(postedBodies, hasLength(1));
final body = jsonDecode(postedBodies.single) as Map<String, dynamic>;
expect(body['GenreItems'], [
{'Name': 'Adventure'},
{'Name': 'Comedy'},
]);
expect(body['TagItems'], [
{'Name': 'family'},
{'Name': 'favorite'},
]);
});
test('Jellyfin save does not send the name-pair arrays', () async {
final postedBodies = <String>[];
final client = _clientForDto(connection: _jellyfinConnection(), dto: _jellyfinItem(), postedBodies: postedBodies);
addTearDown(client.close);
final adapter = JellyfinMetadataEditAdapter(client);
final draft = await adapter.load(_sourceItem(MediaBackend.jellyfin));
draft.setValue('genre', ['Adventure', 'Comedy']);
draft.setValue('label', ['family', 'favorite']);
expect(await adapter.save(draft), isTrue);
expect(postedBodies, hasLength(1));
final body = jsonDecode(postedBodies.single) as Map<String, dynamic>;
expect(body['Genres'], ['Adventure', 'Comedy']);
expect(body['Tags'], ['family', 'favorite']);
expect(body.containsKey('GenreItems'), isFalse);
expect(body.containsKey('TagItems'), isFalse);
});
test("an Emby DTO's tags are read from TagItems", () async {
final client = _clientForDto(connection: testEmbyConnection(), dto: _embyItem(), postedBodies: <String>[]);
addTearDown(client.close);
final adapter = JellyfinMetadataEditAdapter(client);
final draft = await adapter.load(_sourceItem(MediaBackend.emby));
expect(draft.values['label'], ['archive']);
expect(draft.values['genre'], ['Action']);
});
test('a save that does not touch tags preserves them', () async {
final postedBodies = <String>[];
final client = _clientForDto(connection: testEmbyConnection(), dto: _embyItem(), postedBodies: postedBodies);
addTearDown(client.close);
final adapter = JellyfinMetadataEditAdapter(client);
final draft = await adapter.load(_sourceItem(MediaBackend.emby));
draft.setValue('summary', 'Updated summary');
expect(await adapter.save(draft), isTrue);
expect(postedBodies, hasLength(1));
final body = jsonDecode(postedBodies.single) as Map<String, dynamic>;
expect(body['TagItems'], [
{'Name': 'archive'},
]);
});
test("the adapter reports the dialect's backend", () {
final embyClient = _clientForDto(connection: testEmbyConnection(), dto: _embyItem(), postedBodies: <String>[]);
final jellyfinClient = _clientForDto(
connection: _jellyfinConnection(),
dto: _jellyfinItem(),
postedBodies: <String>[],
);
addTearDown(embyClient.close);
addTearDown(jellyfinClient.close);
expect(JellyfinMetadataEditAdapter(embyClient).backend, MediaBackend.emby);
expect(JellyfinMetadataEditAdapter(jellyfinClient).backend, MediaBackend.jellyfin);
});
}
JellyfinClient _clientForDto({
required JellyfinConnection connection,
required Map<String, dynamic> dto,
required List<String> postedBodies,
}) {
return JellyfinClient.forTesting(
connection: connection,
httpClient: MockClient((request) async {
if (request.method == 'GET' && request.url.path == '/Users/${connection.userId}/Items/item-1') {
return jsonResponse(dto);
}
if (request.method == 'POST' && request.url.path == '/Items/item-1') {
postedBodies.add(request.body);
return http.Response('', 204);
}
return http.Response('Unexpected ${request.method} ${request.url}', 500);
}),
);
}
JellyfinConnection _jellyfinConnection() {
return JellyfinConnection(
id: 'srv-1/user-1',
baseUrl: 'https://jf.example.com',
serverName: 'Home',
serverMachineId: 'srv-1',
userId: 'user-1',
userName: 'User',
accessToken: 'token',
deviceId: 'device-1',
isAdministrator: false,
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
}
MediaItem _sourceItem(MediaBackend backend) {
return testMediaItem(id: 'item-1', backend: backend, kind: MediaKind.movie);
}
Map<String, dynamic> _embyItem() {
return {
'Id': 'item-1',
'Name': 'Movie',
'Type': 'Movie',
'Overview': 'Original summary',
'ProviderIds': <String, String>{},
'Genres': <String>[],
'GenreItems': [
{'Name': 'Action', 'Id': 3},
],
'TagItems': [
{'Name': 'archive', 'Id': 7},
],
};
}
Map<String, dynamic> _jellyfinItem() {
return {
'Id': 'item-1',
'Name': 'Movie',
'Type': 'Movie',
'Overview': 'Original summary',
'ProviderIds': <String, String>{},
'Genres': ['Drama'],
'Tags': ['Favorite'],
};
}
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_wrapper.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/screens/settings/add_connection_screen.dart';
import 'package:plezy/screens/settings/add_jellyfin_screen.dart';
import 'package:plezy/screens/settings/add_plex_account_screen.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/backend_badge.dart';
/// The "Add connection" picker is the only route to a new server, so a backend
/// missing from this list is unreachable no matter how complete its client is.
void main() {
Widget app(Widget home) => MaterialApp(
theme: monoTheme(dark: true),
home: InputModeTracker(child: home),
);
Profile profile(String id) =>
Profile.local(id: id, displayName: id, sortOrder: 0, createdAt: DateTime.fromMillisecondsSinceEpoch(0));
testWidgets('offers Plex, Jellyfin and Emby, each with its own badge', (tester) async {
await tester.pumpWidget(app(const AddConnectionScreen()));
await tester.pumpAndSettle();
expect(find.text('Sign in with Plex'), findsOneWidget);
expect(find.text('Connect to Jellyfin'), findsOneWidget);
expect(find.text('Connect to Emby'), findsOneWidget);
final badges = tester.widgetList<BackendBadge>(find.byType(BackendBadge)).map((b) => b.backend).toList();
expect(badges, containsAll(<MediaBackend>[MediaBackend.plex, MediaBackend.jellyfin, MediaBackend.emby]));
});
/// The pushed sign-in screen starts a 2s LAN discovery sweep and a
/// platform package-info read, neither of which settles under
/// `pumpAndSettle`. Bounded frames are enough: the route's widget exists as
/// soon as the push completes.
Future<AddJellyfinScreen> tapCard(WidgetTester tester, String label) async {
await tester.tap(find.text(label));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
return tester.widget<AddJellyfinScreen>(find.byType(AddJellyfinScreen));
}
testWidgets('the Emby card opens the sign-in screen bound to the Emby dialect', (tester) async {
await tester.pumpWidget(app(const AddConnectionScreen()));
await tester.pumpAndSettle();
final screen = await tapCard(tester, 'Connect to Emby');
expect(screen.dialect, MediaBrowserDialect.emby);
expect(screen.targetProfile, isNull);
expect(find.text('Add Emby server'), findsOneWidget);
});
testWidgets('the Jellyfin card still opens the Jellyfin dialect', (tester) async {
await tester.pumpWidget(app(const AddConnectionScreen()));
await tester.pumpAndSettle();
final screen = await tapCard(tester, 'Connect to Jellyfin');
expect(screen.dialect, MediaBrowserDialect.jellyfin);
expect(find.text('Add Jellyfin server'), findsOneWidget);
});
testWidgets('the Plex card is unaffected by the new option', (tester) async {
await tester.pumpWidget(app(const AddConnectionScreen()));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with Plex'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.byType(AddPlexAccountScreen), findsOneWidget);
expect(find.byType(AddJellyfinScreen), findsNothing);
});
testWidgets('a scoped Emby card names the profile it will bind to', (tester) async {
final target = profile('Living Room');
await tester.pumpWidget(app(AddConnectionScreen(targetProfile: target)));
await tester.pumpAndSettle();
expect(find.text('Sign in to your Emby server. Binds to Living Room.'), findsOneWidget);
expect(find.text('Sign in to your Jellyfin server. Binds to Living Room.'), findsOneWidget);
final screen = await tapCard(tester, 'Connect to Emby');
expect(screen.dialect, MediaBrowserDialect.emby);
expect(screen.targetProfile?.id, target.id);
});
testWidgets('the D-pad steps through all three backend cards', (tester) async {
await tester.pumpWidget(app(const AddConnectionScreen()));
await tester.pumpAndSettle();
// The cards share a debugLabel, so track focus-node identity instead.
final visited = <FocusNode>{};
for (var i = 0; i < 6; i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
final focused = FocusManager.instance.primaryFocus;
if (focused != null) visited.add(focused);
}
expect(find.byType(FocusableWrapper), findsNWidgets(3));
expect(visited.length, greaterThanOrEqualTo(3), reason: 'D-pad did not reach every backend card');
});
}
@@ -12,6 +12,7 @@ import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/profiles/active_profile_binder.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
@@ -400,7 +401,12 @@ void main() {
child: _testApp(
AddJellyfinScreen(
localDiscoveryFactory: () async => [
DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'),
DiscoveredJellyfinServer(
address: 'http://192.168.1.20:8096',
id: 'srv-1',
name: 'Home',
dialect: MediaBrowserDialect.jellyfin,
),
],
),
),
@@ -582,6 +588,56 @@ void main() {
expect(find.text('Home'), findsOneWidget);
});
testWidgets('the Emby dialect renames the screen and never offers Quick Connect', (tester) async {
resetSharedPreferencesForTest();
// The same handler advertises Quick Connect as enabled. Emby has no
// /QuickConnect/* routes at all, so the affordance must be gated on the
// dialect rather than on what the server claims.
await tester.pumpWidget(
_testApp(
AddJellyfinScreen(
dialect: MediaBrowserDialect.emby,
authServiceFactory: () => _jellyfinAuthService(quickConnectEnabled: true),
localDiscoveryFactory: _noLocalServers,
),
),
);
await tester.pump();
expect(find.text('Add Emby server'), findsOneWidget);
expect(find.text('Add Jellyfin server'), findsNothing);
final urlField = tester.widget<TextField>(find.byType(TextField).first);
expect(urlField.decoration?.hintText, 'https://emby.example.com');
await tester.enterText(find.byType(TextField).first, 'https://emby.example.com');
await tester.testTextInput.receiveAction(TextInputAction.go);
await tester.pumpAndSettle();
expect(find.text('Use Quick Connect'), findsNothing);
// The password form is still reachable — Emby's only sign-in path.
expect(find.text('Sign in'), findsOneWidget);
});
testWidgets('the Jellyfin dialect still offers Quick Connect when the server has it', (tester) async {
resetSharedPreferencesForTest();
await tester.pumpWidget(
_testApp(
AddJellyfinScreen(
authServiceFactory: () => _jellyfinAuthService(quickConnectEnabled: true),
localDiscoveryFactory: _noLocalServers,
),
),
);
await tester.pump();
expect(find.text('Add Jellyfin server'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, 'https://jf.example.com');
await tester.testTextInput.receiveAction(TextInputAction.go);
await tester.pumpAndSettle();
expect(find.text('Use Quick Connect'), findsOneWidget);
});
testWidgets('Quick Connect shows the code prominently and cancel returns to the form', (tester) async {
resetSharedPreferencesForTest();
await tester.pumpWidget(
@@ -639,7 +695,12 @@ void main() {
authServiceFactory: () =>
_jellyfinAuthService(quickConnectEnabled: true, initiateDelay: const Duration(milliseconds: 50)),
localDiscoveryFactory: () async => [
DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'),
DiscoveredJellyfinServer(
address: 'http://192.168.1.20:8096',
id: 'srv-1',
name: 'Home',
dialect: MediaBrowserDialect.jellyfin,
),
],
),
),
@@ -690,7 +751,12 @@ void main() {
AddJellyfinScreen(
authServiceFactory: () => _jellyfinAuthService(),
localDiscoveryFactory: () async => [
DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'),
DiscoveredJellyfinServer(
address: 'http://192.168.1.20:8096',
id: 'srv-1',
name: 'Home',
dialect: MediaBrowserDialect.jellyfin,
),
],
),
),
@@ -713,8 +779,18 @@ void main() {
child: _testApp(
AddJellyfinScreen(
localDiscoveryFactory: () async => [
DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'),
DiscoveredJellyfinServer(address: 'http://192.168.1.30:8096', id: 'srv-2', name: 'Office'),
DiscoveredJellyfinServer(
address: 'http://192.168.1.20:8096',
id: 'srv-1',
name: 'Home',
dialect: MediaBrowserDialect.jellyfin,
),
DiscoveredJellyfinServer(
address: 'http://192.168.1.30:8096',
id: 'srv-2',
name: 'Office',
dialect: MediaBrowserDialect.jellyfin,
),
],
),
),
@@ -64,7 +64,8 @@ void main() {
expect(find.text(t.auth.localDataRecoveryRequired), findsOneWidget);
expect(find.text(t.auth.signInWithPlex), findsOneWidget);
expect(find.text(t.auth.connectToJellyfin), findsOneWidget);
expect(find.text(t.auth.connectToMediaBrowser(product: 'Jellyfin')), findsOneWidget);
expect(find.text(t.auth.connectToMediaBrowser(product: 'Emby')), findsOneWidget);
});
testWidgets('fresh AuthScreen has normal actions without recovery notice', (tester) async {
@@ -73,6 +74,7 @@ void main() {
expect(find.text(t.auth.localDataRecoveryRequired), findsNothing);
expect(find.text(t.auth.signInWithPlex), findsOneWidget);
expect(find.text(t.auth.connectToJellyfin), findsOneWidget);
expect(find.text(t.auth.connectToMediaBrowser(product: 'Jellyfin')), findsOneWidget);
expect(find.text(t.auth.connectToMediaBrowser(product: 'Emby')), findsOneWidget);
});
}
+200 -2
View File
@@ -7,6 +7,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/services/jellyfin_auth_service.dart';
import 'package:plezy/services/jellyfin_endpoint_discovery.dart';
import 'package:plezy/utils/log_redaction_manager.dart';
@@ -22,18 +23,26 @@ http.Response _bareOk(String body) => http.Response(body, 200, headers: {'conten
http.Response _status(int code, [Object? json]) =>
http.Response(json == null ? '' : jsonEncode(json), code, headers: {'content-type': 'application/json'});
JellyfinConnection _existingConn({String accessToken = 'tok-old'}) => testJellyfinConnection(
JellyfinConnection _existingConn({
String accessToken = 'tok-old',
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
}) => testJellyfinConnection(
userName: 'edde',
accessToken: accessToken,
deviceId: 'dev-xyz',
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
dialect: dialect,
);
JellyfinConnectionAuthService _service({required _Handler handler}) {
JellyfinConnectionAuthService _service({
required _Handler handler,
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
}) {
return JellyfinConnectionAuthService(
clientName: 'Plezy',
clientVersion: 'test',
deviceName: 'TestDevice',
dialect: dialect,
testHttpClientFactory: () => MockClient((req) async => handler(req)),
);
}
@@ -766,6 +775,195 @@ void main() {
});
});
group('Emby dialect', () {
test('validate uses the user-scoped current-user route instead of /Users/Me', () async {
final paths = <String>[];
final svc = _service(
dialect: MediaBrowserDialect.emby,
handler: (req) {
paths.add(req.url.path);
if (req.url.path == '/Users/Me') {
return _status(500, {'error': 'Unrecognized Guid format'});
}
return _ok({'Id': 'user-1'});
},
);
expect(await svc.validate(_existingConn(dialect: MediaBrowserDialect.emby)), isTrue);
expect(paths, ['/Users/user-1']);
});
test('checking Quick Connect support sends no unsupported Emby request', () async {
final paths = <String>[];
final svc = _service(
dialect: MediaBrowserDialect.emby,
handler: (req) {
paths.add(req.url.path);
expect(req.url.path, isNot(startsWith('/QuickConnect/')));
return _status(404);
},
);
expect(await svc.isQuickConnectEnabled('https://emby.example.com'), isFalse);
expect(paths, isEmpty);
});
test('initiating Quick Connect rejects locally without an Emby request', () async {
final paths = <String>[];
final svc = _service(
dialect: MediaBrowserDialect.emby,
handler: (req) {
paths.add(req.url.path);
expect(req.url.path, isNot(startsWith('/QuickConnect/')));
return _status(404);
},
);
final error = await _captureError(
svc.initiateQuickConnect(baseUrl: 'https://emby.example.com', deviceId: 'dev-xyz'),
);
expect(
error,
isA<MediaServerAuthException>()
.having((exception) => exception.message, 'message', 'Quick Connect rejected by server')
.having((exception) => exception.statusCode, 'statusCode', isNull),
);
expect(paths, isEmpty);
});
test('authenticating by Quick Connect rejects locally without an Emby request', () async {
final paths = <String>[];
final svc = _service(
dialect: MediaBrowserDialect.emby,
handler: (req) {
paths.add(req.url.path);
expect(req.url.path, isNot(startsWith('/QuickConnect/')));
return _status(404);
},
);
final error = await _captureError(
svc.authenticateByQuickConnect(
baseUrl: 'https://emby.example.com',
secret: 'quick-secret',
deviceId: 'dev-xyz',
),
);
expect(
error,
isA<MediaServerAuthException>()
.having((exception) => exception.message, 'message', 'Quick Connect rejected by server')
.having((exception) => exception.statusCode, 'statusCode', isNull),
);
expect(paths, isEmpty);
});
test('password authentication builds an Emby-persisted connection discriminator', () async {
final svc = _service(
dialect: MediaBrowserDialect.emby,
handler: (req) {
expect(req.url.path, '/Users/AuthenticateByName');
return _ok({
'AccessToken': 'tok-new',
'User': {'Id': 'user-7', 'Name': 'edde'},
});
},
);
final connection = await svc.authenticateByName(
baseUrl: 'https://emby.example.com',
username: 'edde',
password: 'pw',
deviceId: 'dev-xyz',
serverInfo: _serverInfo,
);
expect(connection.dialect, MediaBrowserDialect.emby);
expect(connection.kind, ConnectionKind.emby);
expect(connection.kind.id, 'emby');
});
test('detected server dialect overrides the picker and an unknown response preserves it', () async {
Future<JellyfinConnection> authenticate(Map<String, Object?> publicInfo) {
final svc = _service(
handler: (req) {
if (req.url.path == '/System/Info/Public') return _ok(publicInfo);
if (req.url.path == '/Users/AuthenticateByName') {
return _ok({
'AccessToken': 'tok-new',
'User': {'Id': 'user-7', 'Name': 'edde'},
});
}
return _status(404);
},
);
return svc.authenticateByName(
baseUrl: 'https://server.example.com',
username: 'edde',
password: 'pw',
deviceId: 'dev-xyz',
);
}
final detected = await authenticate({
'LocalAddresses': <String>[],
'RemoteAddresses': <String>[],
'ServerName': 'Emby Home',
'Version': '4.9.5.0',
'Id': 'emby-server',
});
final unknown = await authenticate({'ServerName': 'Unknown Home', 'Id': 'unknown-server', 'Version': '4.9.5.0'});
expect(detected.dialect, MediaBrowserDialect.emby);
expect(detected.kind, ConnectionKind.emby);
expect(unknown.dialect, MediaBrowserDialect.jellyfin);
expect(unknown.kind, ConnectionKind.jellyfin);
});
test('password authentication and logout remain wire-identical to Jellyfin', () async {
Future<List<(String, String, String)>> capture(MediaBrowserDialect dialect) async {
final requests = <(String, String, String)>[];
final svc = _service(
dialect: dialect,
handler: (req) {
final request = req as http.Request;
requests.add((request.method, request.url.path, request.body));
if (request.url.path == '/Users/AuthenticateByName') {
return _ok({
'AccessToken': 'tok-new',
'User': {'Id': 'user-7', 'Name': 'edde'},
});
}
if (request.url.path == '/Sessions/Logout') return _ok({});
return _status(404);
},
);
final connection = await svc.authenticateByName(
baseUrl: 'https://server.example.com',
username: 'edde',
password: 'pw',
deviceId: 'dev-xyz',
serverInfo: _serverInfo,
);
await svc.signOut(connection);
return requests;
}
final jellyfinRequests = await capture(MediaBrowserDialect.jellyfin);
final embyRequests = await capture(MediaBrowserDialect.emby);
final expected = <(String, String, String)>[
('POST', '/Users/AuthenticateByName', '{"Username":"edde","Pw":"pw"}'),
('POST', '/Sessions/Logout', ''),
];
expect(jellyfinRequests, expected);
expect(embyRequests, expected);
expect(embyRequests, jellyfinRequests);
});
});
group('Jellyfin authentication request identity', () {
test('password login sends the complete MediaBrowser header', () async {
late http.BaseRequest request;
File diff suppressed because it is too large Load Diff
+14 -6
View File
@@ -4486,32 +4486,40 @@ void main() {
expect(requests[1].queryParameters['imageUrl'], 'https://img.example/poster.jpg');
});
test('uploadItemImage sends binary image body and image content type', () async {
test('uploadItemImage sends the image as base64 text with the image content type', () async {
// This asserted a raw binary body until the transport was exercised
// against real servers: both dialects answer HTTP 500 for binary
// (Emby 4.9.5: `The input is not a valid Base-64 string`; Jellyfin 10.11:
// `Error processing request.`) and 204 for the base64 form. The
// `Content-Type` still names the image type — that is how the server
// picks the on-disk extension.
Uri? capturedUri;
List<int>? capturedBody;
String? capturedBody;
Map<String, String>? capturedHeaders;
final client = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
capturedUri = request.url;
capturedBody = request.bodyBytes;
capturedBody = request.body;
capturedHeaders = request.headers;
return http.Response('', 204);
}),
);
addTearDown(client.close);
const bytes = [0xff, 0xd8, 0xff, 0x00];
final success = await client.uploadItemImage(
'item-1',
imageType: 'Primary',
bytes: [0xff, 0xd8, 0xff, 0x00],
bytes: bytes,
contentType: 'image/jpeg',
);
expect(success, isTrue);
expect(capturedUri!.path, '/Items/item-1/Images/Primary');
expect(capturedBody, [0xff, 0xd8, 0xff, 0x00]);
expect(capturedHeaders!['Content-Type'] ?? capturedHeaders!['content-type'], 'image/jpeg');
expect(capturedBody, base64Encode(bytes));
expect(base64Decode(capturedBody!), bytes);
expect(capturedHeaders!['Content-Type'] ?? capturedHeaders!['content-type'], contains('image/jpeg'));
});
test('smart=true returns empty without network I/O', () async {
@@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/jellyfin_display_metadata.dart';
/// HDR/Dolby Vision classification from a MediaBrowser `MediaStreams[]` entry.
///
/// The fixtures are verbatim video-stream shapes captured from Jellyfin 10.11
/// and Emby 4.9.5 for the same HDR10 HEVC file. They differ: Jellyfin sends
/// `VideoRangeType`, Emby does not — it sends `VideoRange: 'HDR 10'` plus its
/// own `ExtendedVideoType`. Detection must not depend on the Jellyfin-only
/// field, which is why both shapes are pinned here.
void main() {
group('HDR detection across MediaBrowser dialects', () {
// Emby 4.9.5, HDR10 HEVC. Note the absent VideoRangeType.
const embyHdr10 = <String, dynamic>{
'Type': 'Video',
'Codec': 'hevc',
'Profile': 'Main 10',
'BitDepth': 10,
'VideoRange': 'HDR 10',
'ColorTransfer': 'smpte2084',
'ColorPrimaries': 'bt2020',
'ColorSpace': 'bt2020nc',
'ExtendedVideoType': 'Hdr10',
'Width': 640,
'Height': 360,
'AverageFrameRate': 24,
};
// Jellyfin 10.11 shape for the same content.
const jellyfinHdr10 = <String, dynamic>{
'Type': 'Video',
'Codec': 'hevc',
'Profile': 'Main 10',
'BitDepth': 10,
'VideoRange': 'HDR',
'VideoRangeType': 'HDR10',
'ColorTransfer': 'smpte2084',
'ColorPrimaries': 'bt2020',
'ColorSpace': 'bt2020nc',
'Width': 640,
'Height': 360,
'AverageFrameRate': 24,
};
const sdr = <String, dynamic>{
'Type': 'Video',
'Codec': 'h264',
'VideoRange': 'SDR',
'Width': 640,
'Height': 360,
'AverageFrameRate': 24,
};
test('Emby HDR10 is detected without the Jellyfin-only VideoRangeType', () {
expect(embyHdr10.containsKey('VideoRangeType'), isFalse, reason: 'fixture must reflect the real Emby shape');
expect(jellyfinVideoStreamIsHdr(const {}, embyHdr10), isTrue);
final criteria = jellyfinDisplayCriteriaFromStream(const {}, embyHdr10);
expect(criteria, isNotNull);
expect(criteria!.isHdr, isTrue);
expect(criteria.transfer, 'smpte2084');
expect(criteria.primaries, 'bt2020');
});
test('Jellyfin HDR10 is detected from its own field set', () {
expect(jellyfinVideoStreamIsHdr(const {}, jellyfinHdr10), isTrue);
expect(jellyfinDisplayCriteriaFromStream(const {}, jellyfinHdr10)!.isHdr, isTrue);
});
test('an SDR stream is not misreported as HDR on either dialect', () {
expect(jellyfinVideoStreamIsHdr(const {}, sdr), isFalse);
expect(jellyfinVideoStreamIsHdr(const {}, {...sdr, 'VideoRangeType': 'SDR'}), isFalse);
});
test('Dolby Vision is detected from the Dv* fields both dialects share', () {
const dovi = <String, dynamic>{
'Type': 'Video',
'Codec': 'hevc',
'VideoRange': 'HDR',
'DvProfile': 8,
'DvBlSignalCompatibilityId': 1,
'DvVersionMajor': 1,
'Width': 3840,
'Height': 2160,
};
expect(jellyfinVideoStreamIsDolbyVision(dovi), isTrue);
expect(jellyfinDolbyVisionProfile(dovi), 8);
expect(jellyfinVideoStreamIsHdr(const {}, dovi), isTrue);
});
test('a stream carrying no range signal at all is treated as SDR, not unknown', () {
const bare = <String, dynamic>{'Type': 'Video', 'Codec': 'h264', 'Width': 1920, 'Height': 1080};
expect(jellyfinVideoStreamIsHdr(const {}, bare), isFalse);
expect(jellyfinVideoStreamIsDolbyVision(bare), isFalse);
});
});
}
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/services/jellyfin_endpoint_discovery.dart';
http.Response _info({required String id, String name = 'Home'}) => http.Response(
@@ -53,13 +54,73 @@ void main() {
expect(JellyfinEndpointDiscovery.normalizeBaseUrl('jf.example.com/'), 'jf.example.com');
});
test('expands bare host input into Jellyfin URL candidates', () {
expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('jf.example.com'), [
'http://jf.example.com:8096',
'https://jf.example.com',
'https://jf.example.com:8096',
'http://jf.example.com',
]);
test('Jellyfin bare host expansion preserves its ordered URL candidates', () {
const expected = ['http://host.lan:8096', 'https://host.lan', 'https://host.lan:8096', 'http://host.lan'];
expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('host.lan'), expected);
expect(JellyfinEndpointDiscovery.buildUserInputCandidates(['host.lan']).probeBaseUrls, expected);
});
test('Emby bare host expansion includes the 8920 HTTPS candidate in order', () {
const expected = [
'http://host.lan:8096',
'https://host.lan',
'https://host.lan:8920',
'https://host.lan:8096',
'http://host.lan',
];
expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('host.lan', dialect: MediaBrowserDialect.emby), expected);
expect(
JellyfinEndpointDiscovery.buildUserInputCandidates([
'host.lan',
], dialect: MediaBrowserDialect.emby).probeBaseUrls,
expected,
);
});
test('explicit URLs are never expanded for either dialect', () {
const explicitUrl = 'https://host.lan:9443/emby';
for (final dialect in MediaBrowserDialect.values) {
expect(JellyfinEndpointDiscovery.expandInputToBaseUrls(explicitUrl, dialect: dialect), [
explicitUrl,
], reason: dialect.id);
final candidates = JellyfinEndpointDiscovery.buildUserInputCandidates([explicitUrl], dialect: dialect);
expect(candidates.probeBaseUrls, [explicitUrl], reason: dialect.id);
expect(candidates.explicitBaseUrls, [explicitUrl], reason: dialect.id);
}
});
test('probe records Jellyfin, Emby, and unknown public-info dialects', () async {
Future<JellyfinServerInfo> probe(Map<String, Object?> publicInfo) {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((request) async {
expect(request.url.path, '/System/Info/Public');
return http.Response(jsonEncode(publicInfo), 200, headers: {'content-type': 'application/json'});
}),
);
return discovery.probe('https://server.example.com');
}
final jellyfin = await probe({
'Id': 'jellyfin-server',
'ServerName': 'Jellyfin Home',
'Version': '10.10.7',
'ProductName': 'Jellyfin Server',
});
final emby = await probe({
'Id': 'emby-server',
'ServerName': 'Emby Home',
'Version': '4.9.5.0',
'LocalAddresses': <String>[],
'RemoteAddresses': <String>[],
});
final unknown = await probe({'Id': 'unknown-server', 'ServerName': 'Unknown Home', 'Version': '1.0.0'});
expect(jellyfin.dialect, MediaBrowserDialect.jellyfin);
expect(emby.dialect, MediaBrowserDialect.emby);
expect(unknown.dialect, isNull);
});
test('expands host and port input without changing the port', () {
@@ -271,6 +332,39 @@ void main() {
);
});
test('Emby empty-input errors name the selected product', () async {
final discovery = JellyfinEndpointDiscovery(dialect: MediaBrowserDialect.emby);
await expectLater(
discovery.raceEndpoints(const []),
throwsA(
isA<MediaServerUrlException>().having(
(exception) => exception.message,
'message',
'Enter at least one Emby server URL',
),
),
);
});
test('Emby machine-id mismatch errors name the selected product', () async {
final discovery = JellyfinEndpointDiscovery(
dialect: MediaBrowserDialect.emby,
testHttpClientFactory: () => MockClient((_) async => _info(id: 'srv-2')),
);
await expectLater(
discovery.raceEndpoints(['https://emby.example.com'], expectedMachineId: 'srv-1', baseUrlsToValidate: const []),
throwsA(
isA<MediaServerUrlException>().having(
(exception) => exception.message,
'message',
'The URL does not match this Emby server',
),
),
);
});
test('expected machine ID retains candidates that returned no identity', () async {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((request) async {
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/services/jellyfin_lan_discovery_service.dart';
import 'package:plezy/utils/udp_broadcast_sockets.dart';
@@ -10,40 +11,86 @@ void main() {
test('parses Jellyfin UDP discovery responses', () {
final server = JellyfinLanDiscoveryService.parseDiscoveryResponse(
utf8.encode(jsonEncode({'Address': 'http://192.168.1.20:8096/', 'Id': 'srv-1', 'Name': 'Home'})),
dialect: MediaBrowserDialect.jellyfin,
);
expect(server, isNotNull);
expect(server!.address, 'http://192.168.1.20:8096');
expect(server.id, 'srv-1');
expect(server.name, 'Home');
expect(server.dialect, MediaBrowserDialect.jellyfin);
});
test('stamps the asked-for dialect onto an Emby reply', () {
// Emby 4.9.5 answers `who is EmbyServer?` with the same three keys, so
// the dialect can only come from which payload was sent.
final server = JellyfinLanDiscoveryService.parseDiscoveryResponse(
utf8.encode(jsonEncode({'Address': 'http://127.0.0.1:8096', 'Id': 'emby-1', 'Name': '7befeeb2e8c9'})),
dialect: MediaBrowserDialect.emby,
);
expect(server?.dialect, MediaBrowserDialect.emby);
expect(server?.address, 'http://127.0.0.1:8096');
expect(server?.name, '7befeeb2e8c9');
});
test('does not expand bare discovery addresses while parsing', () {
final server = JellyfinLanDiscoveryService.parseDiscoveryResponse(
utf8.encode(jsonEncode({'Address': '192.168.1.20', 'Id': 'srv-1', 'Name': 'Home'})),
dialect: MediaBrowserDialect.jellyfin,
);
expect(server?.address, '192.168.1.20');
});
test('ignores malformed discovery responses', () {
expect(JellyfinLanDiscoveryService.parseDiscoveryResponse(utf8.encode('not json')), isNull);
expect(
JellyfinLanDiscoveryService.parseDiscoveryResponse(utf8.encode(jsonEncode({'Address': 'http://x'}))),
JellyfinLanDiscoveryService.parseDiscoveryResponse(
utf8.encode('not json'),
dialect: MediaBrowserDialect.jellyfin,
),
isNull,
);
expect(
JellyfinLanDiscoveryService.parseDiscoveryResponse(
utf8.encode(jsonEncode({'Address': 'http://x'})),
dialect: MediaBrowserDialect.jellyfin,
),
isNull,
);
});
test('sorts discovered servers deterministically', () {
final sorted = JellyfinLanDiscoveryService.sortDiscoveredServers([
DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-2', name: 'Home'),
DiscoveredJellyfinServer(address: 'http://192.168.1.10:8096', id: 'srv-3', name: 'Office'),
DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'),
DiscoveredJellyfinServer(
address: 'http://192.168.1.20:8096',
id: 'srv-2',
name: 'Home',
dialect: MediaBrowserDialect.jellyfin,
),
DiscoveredJellyfinServer(
address: 'http://192.168.1.10:8096',
id: 'srv-3',
name: 'Office',
dialect: MediaBrowserDialect.jellyfin,
),
DiscoveredJellyfinServer(
address: 'http://192.168.1.20:8096',
id: 'srv-1',
name: 'Home',
dialect: MediaBrowserDialect.emby,
),
]);
expect(sorted.map((server) => server.id), ['srv-1', 'srv-2', 'srv-3']);
});
test('discovery messages are the two distinct measured payloads', () {
expect(MediaBrowserDialect.jellyfin.lanDiscoveryMessage, 'who is JellyfinServer?');
expect(MediaBrowserDialect.emby.lanDiscoveryMessage, 'who is EmbyServer?');
expect(JellyfinLanDiscoveryService.discoveryPort, 7359);
});
test('listenDatagrams receives queued loopback datagrams', () async {
final receiver = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
+32
View File
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_stream.dart';
@@ -96,6 +97,27 @@ void main() {
expect(item.serverName, 'Home');
});
test('Emby dialect stamps backend and preserves opaque item and media source ids', () {
final item = JellyfinMappers.mediaItem(
{
'Id': '7330',
'Name': 'Movie',
'Type': 'Movie',
'MediaSources': [
{'Id': 'mediasource_7330', 'MediaStreams': <Map<String, dynamic>>[]},
],
},
serverId: ServerId(_serverId),
absolutizer: null,
dialect: MediaBrowserDialect.emby,
)!;
expect(item.id, '7330');
expect(item.backend, MediaBackend.emby);
expect(item.mediaVersions!.single.id, 'mediasource_7330');
expect(item.mediaVersions!.single.parts.single.id, 'mediasource_7330');
});
test('divides the Tomatometer rather than range-sniffing it', () {
// A CriticRating of 9 means 9%, not 9.0/10 — folding by magnitude would
// silently promote a rotten score to fresh.
@@ -556,6 +578,16 @@ void main() {
}
});
test('Emby dialect stamps the library backend', () {
final library = JellyfinMappers.library(
{'Id': 'view-movies', 'Name': 'Movies', 'CollectionType': 'movies'},
serverId: ServerId(_serverId),
dialect: MediaBrowserDialect.emby,
)!;
expect(library.backend, MediaBackend.emby);
});
test('maps content-type-less collection folders to a movie and show root browse', () {
for (final view in [
{'Id': 'view-missing-type', 'Name': 'Mixed', 'Type': 'CollectionFolder', 'IsFolder': true},
@@ -0,0 +1,53 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/services/media_browser_paths.dart';
/// Route table for the endpoints where the two MediaBrowser dialects diverge.
///
/// Every Emby expectation below was measured against Emby 4.9.5; the Jellyfin
/// spelling of the same route returns 404 there (500 for `/Users/Me`, which
/// binds `Me` as a user id). These are the exact strings the client sends, so a
/// regression here is a silent loss of watch state or Continue Watching.
void main() {
const jellyfin = MediaBrowserPaths(dialect: MediaBrowserDialect.jellyfin, userId: 'user-1');
const emby = MediaBrowserPaths(dialect: MediaBrowserDialect.emby, userId: 'user-1');
group('Jellyfin uses the 10.9+ unprefixed routes', () {
test('current user', () => expect(jellyfin.currentUser, '/Users/Me'));
test('resume', () => expect(jellyfin.resumeItems, '/UserItems/Resume'));
test('played', () => expect(jellyfin.playedItem('item-9'), '/UserPlayedItems/item-9'));
test('favorite', () => expect(jellyfin.favoriteItem('item-9'), '/UserFavoriteItems/item-9'));
test('rating', () => expect(jellyfin.itemRating('item-9'), '/UserItems/item-9/Rating'));
test('trailers', () => expect(jellyfin.localTrailers('item-9'), '/Items/item-9/LocalTrailers'));
test('extras', () => expect(jellyfin.specialFeatures('item-9'), '/Items/item-9/SpecialFeatures'));
});
group('Emby uses the original user-scoped routes', () {
test('current user', () => expect(emby.currentUser, '/Users/user-1'));
test('resume', () => expect(emby.resumeItems, '/Users/user-1/Items/Resume'));
test('played', () => expect(emby.playedItem('item-9'), '/Users/user-1/PlayedItems/item-9'));
test('favorite', () => expect(emby.favoriteItem('item-9'), '/Users/user-1/FavoriteItems/item-9'));
test('rating', () => expect(emby.itemRating('item-9'), '/Users/user-1/Items/item-9/Rating'));
test('trailers', () => expect(emby.localTrailers('item-9'), '/Users/user-1/Items/item-9/LocalTrailers'));
test('extras', () => expect(emby.specialFeatures('item-9'), '/Users/user-1/Items/item-9/SpecialFeatures'));
});
group('path segment encoding', () {
test('item ids are percent-encoded so a hostile id cannot escape the path', () {
expect(emby.playedItem('a/b?c'), '/Users/user-1/PlayedItems/a%2Fb%3Fc');
expect(jellyfin.playedItem('a/b?c'), '/UserPlayedItems/a%2Fb%3Fc');
});
test('user ids are percent-encoded in the user-scoped forms', () {
const hostile = MediaBrowserPaths(dialect: MediaBrowserDialect.emby, userId: 'u/1');
expect(hostile.currentUser, '/Users/u%2F1');
expect(hostile.resumeItems, '/Users/u%2F1/Items/Resume');
});
test('Emby item ids are opaque numeric strings and pass through unchanged', () {
// Emby ids look like "7330"; Jellyfin's are 32-char hex GUIDs. Both are
// treated as opaque.
expect(emby.playedItem('7330'), '/Users/user-1/PlayedItems/7330');
});
});
}
+6 -1
View File
@@ -74,6 +74,8 @@ void main() {
// Register the other backend last; cleanup must not depend on whichever
// concrete singleton happened to initialize most recently.
JellyfinApiCache.initialize(db);
final mediaBrowserCache = ApiCache.forBackend(MediaBackend.jellyfin);
expect(identical(ApiCache.forBackend(MediaBackend.emby), mediaBrowserCache), isTrue);
await ApiCache.clearRegisteredVolatile();
expect(await cache.get(ServerId('srv'), '/volatile'), isNull);
@@ -85,7 +87,10 @@ void main() {
JellyfinApiCache.initialize(newDb);
expect(() => ApiCache.forBackend(MediaBackend.plex), throwsStateError);
expect(identical(ApiCache.forBackend(MediaBackend.jellyfin).database, newDb), isTrue);
final replacement = JellyfinApiCache.instance;
expect(identical(ApiCache.forBackend(MediaBackend.jellyfin), replacement), isTrue);
expect(identical(ApiCache.forBackend(MediaBackend.emby), replacement), isTrue);
expect(identical(replacement.database, newDb), isTrue);
await newDb.close();
});
@@ -2,11 +2,15 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/utils/active_client_scope.dart';
/// A MediaBrowser-family connection fixture. Defaults to the Jellyfin dialect;
/// pass `dialect: MediaBrowserDialect.emby` (or use [testEmbyConnection]) to
/// exercise the Emby routes.
JellyfinConnection testJellyfinConnection({
String machineId = 'srv-1',
String userId = 'user-1',
@@ -21,6 +25,7 @@ JellyfinConnection testJellyfinConnection({
ConnectionStatus status = ConnectionStatus.unknown,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
}) {
return JellyfinConnection(
id: id ?? '$machineId/$userId',
@@ -32,6 +37,7 @@ JellyfinConnection testJellyfinConnection({
userName: userName,
accessToken: accessToken,
deviceId: deviceId,
dialect: dialect,
isAdministrator: isAdministrator,
status: status,
createdAt: createdAt ?? DateTime.utc(2024),
@@ -39,6 +45,42 @@ JellyfinConnection testJellyfinConnection({
);
}
/// Emby-dialect twin of [testJellyfinConnection]. Same field defaults so a
/// suite can be parameterized over both dialects and assert only the route
/// differences.
JellyfinConnection testEmbyConnection({
String machineId = 'srv-1',
String userId = 'user-1',
String? id,
String baseUrl = 'https://emby.example.com',
List<String>? baseUrls,
String serverName = 'Home',
String userName = 'User',
String accessToken = 'token',
String deviceId = 'device-1',
bool isAdministrator = false,
ConnectionStatus status = ConnectionStatus.unknown,
DateTime? createdAt,
DateTime? lastAuthenticatedAt,
}) {
return testJellyfinConnection(
machineId: machineId,
userId: userId,
id: id,
baseUrl: baseUrl,
baseUrls: baseUrls,
serverName: serverName,
userName: userName,
accessToken: accessToken,
deviceId: deviceId,
isAdministrator: isAdministrator,
status: status,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
dialect: MediaBrowserDialect.emby,
);
}
PlexConfig testPlexConfig({
String baseUrl = 'https://plex.example.com',
String? token = 'token',
@@ -81,6 +123,22 @@ JellyfinClient testJellyfinClient({
);
}
/// Emby-dialect twin of [testJellyfinClient] — same `JellyfinClient` class, an
/// Emby connection underneath.
JellyfinClient testEmbyClient({
JellyfinConnection? connection,
http.Client? httpClient,
Future<http.Response> Function(http.Request request)? handler,
void Function()? onAllEndpointsExhausted,
}) {
return testJellyfinClient(
connection: connection ?? testEmbyConnection(),
httpClient: httpClient,
handler: handler,
onAllEndpointsExhausted: onAllEndpointsExhausted,
);
}
PlexClient testPlexClient({
PlexConfig? config,
String baseUrl = 'https://plex.example.com',
+30
View File
@@ -126,6 +126,36 @@ void main() {
);
});
test('Emby follows the server answer, not the admin bit', () {
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.emby,
resolvedItemPermission: false,
isAdminActionAllowed: true,
),
isFalse,
);
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.emby,
resolvedItemPermission: true,
isAdminActionAllowed: false,
),
isTrue,
);
});
test('Emby fails closed when the permission is unknown', () {
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.emby,
resolvedItemPermission: null,
isAdminActionAllowed: true,
),
isFalse,
);
});
test('Plex keeps its account-level gate', () {
expect(
isMediaDeletionAllowed(