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,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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user