mirror of
https://github.com/jorenn92/Maintainerr.git
synced 2026-06-01 18:48:13 +02:00
feat(media-server): add Emby as a third supported media server (#2911)
Build Development / guard_manual_run (push) Has been cancelled
Build Development / Build linux/arm64 (push) Has been cancelled
Build Development / Build linux/amd64 (push) Has been cancelled
Build Development / Merge Docker digests and push (push) Has been cancelled
Release 1 - Prepare PR / guard_manual_run (push) Has been cancelled
Release 1 - Prepare PR / Guard release branch (push) Has been cancelled
Release 1 - Prepare PR / Create or update release PR (push) Has been cancelled
Build Development / guard_manual_run (push) Has been cancelled
Build Development / Build linux/arm64 (push) Has been cancelled
Build Development / Build linux/amd64 (push) Has been cancelled
Build Development / Merge Docker digests and push (push) Has been cancelled
Release 1 - Prepare PR / guard_manual_run (push) Has been cancelled
Release 1 - Prepare PR / Guard release branch (push) Has been cancelled
Release 1 - Prepare PR / Create or update release PR (push) Has been cancelled
This commit is contained in:
+9
-7
@@ -43,6 +43,7 @@ flowchart LR
|
||||
API --> Media["Media-server abstraction"]
|
||||
Media --> Plex["Plex adapter"]
|
||||
Media --> Jellyfin["Jellyfin adapter"]
|
||||
Media --> Emby["Emby adapter"]
|
||||
API --> Servarr["Radarr / Sonarr"]
|
||||
API --> Seerr["Seerr"]
|
||||
API --> Tautulli["Tautulli"]
|
||||
@@ -90,8 +91,9 @@ integrations, and production static serving.
|
||||
server switching.
|
||||
- `src/modules/api/media-server/` provides the server-agnostic media server
|
||||
interface, factory, controller, and shared utilities.
|
||||
- `src/modules/api/media-server/plex/` and
|
||||
`src/modules/api/media-server/jellyfin/` contain server-specific adapters,
|
||||
- `src/modules/api/media-server/plex/`,
|
||||
`src/modules/api/media-server/jellyfin/`, and
|
||||
`src/modules/api/media-server/emby/` contain server-specific adapters,
|
||||
constants, mappers, caching, and SDK/API calls.
|
||||
- Other `src/modules/api/` submodules wrap integration clients and helper
|
||||
APIs, including Plex legacy routes, Servarr, Seerr, Tautulli, TMDB, TVDB,
|
||||
@@ -141,7 +143,7 @@ runtime assets. The Docker image exposes `/opt/data` as a volume.
|
||||
|
||||
Maintainerr integrates with:
|
||||
|
||||
- Plex and Jellyfin through the media-server abstraction.
|
||||
- Plex, Jellyfin, and Emby through the media-server abstraction.
|
||||
- Radarr and Sonarr for unmonitoring, deleting, and quality profile actions.
|
||||
- Seerr-compatible services for request cleanup.
|
||||
- Tautulli for Plex analytics and rule data.
|
||||
@@ -219,8 +221,8 @@ See `CONTRIBUTING.md` for setup, branching, and pull request expectations.
|
||||
|
||||
- Keep `modules/api/media-server/` server-agnostic. The shared interface,
|
||||
factory, controller, and utilities must not import Plex or Jellyfin types.
|
||||
- Put Plex-specific logic under `plex/` and Jellyfin-specific logic under
|
||||
`jellyfin/`.
|
||||
- Put server-specific logic under the matching adapter directory (`plex/`,
|
||||
`jellyfin/`, or `emby/`).
|
||||
- Use `supportsFeature()` for conditional media-server capabilities.
|
||||
- Implement every new media-server interface method for all supported media
|
||||
servers. Put partial support behind feature checks, not optional interface
|
||||
@@ -251,8 +253,8 @@ See `CONTRIBUTING.md` for setup, branching, and pull request expectations.
|
||||
- Data directory: Runtime directory containing the SQLite database and local
|
||||
files such as logs, posters, and overlays.
|
||||
- Media-server abstraction: Server-side interface that lets Maintainerr support
|
||||
Plex and Jellyfin without leaking their implementation details into shared
|
||||
code.
|
||||
Plex, Jellyfin, and Emby without leaking their implementation details into
|
||||
shared code.
|
||||
- Rule group: A configured set of rules that selects media and links it to a
|
||||
Maintainerr collection.
|
||||
- Seerr: The request-management integration family covering Overseerr,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddEmbySupport1779021820174 implements MigrationInterface {
|
||||
name = 'AddEmbySupport1779021820174';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "temporary_settings" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"clientId" varchar,
|
||||
"applicationTitle" varchar NOT NULL DEFAULT ('Maintainerr'),
|
||||
"applicationUrl" varchar NOT NULL DEFAULT ('localhost'),
|
||||
"apikey" varchar,
|
||||
"locale" varchar NOT NULL DEFAULT ('en'),
|
||||
"plex_name" varchar,
|
||||
"plex_hostname" varchar,
|
||||
"plex_port" integer DEFAULT (32400),
|
||||
"plex_ssl" integer,
|
||||
"plex_auth_token" varchar,
|
||||
"collection_handler_job_cron" varchar NOT NULL DEFAULT ('0 0-23/12 * * *'),
|
||||
"rules_handler_job_cron" varchar NOT NULL DEFAULT ('0 0-23/8 * * *'),
|
||||
"tautulli_url" varchar,
|
||||
"tautulli_api_key" varchar,
|
||||
"media_server_type" varchar,
|
||||
"jellyfin_url" varchar,
|
||||
"jellyfin_api_key" varchar,
|
||||
"jellyfin_user_id" varchar,
|
||||
"jellyfin_server_name" varchar,
|
||||
"seerr_url" varchar,
|
||||
"seerr_api_key" varchar,
|
||||
"tmdb_api_key" varchar,
|
||||
"tvdb_api_key" varchar,
|
||||
"metadata_provider_preference" varchar NOT NULL DEFAULT ('tmdb_primary'),
|
||||
"plex_machine_id" varchar,
|
||||
"plex_manual_mode" integer DEFAULT (0),
|
||||
"emby_url" varchar,
|
||||
"emby_api_key" varchar,
|
||||
"emby_user_id" varchar,
|
||||
"emby_server_name" varchar
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "temporary_settings"(
|
||||
"id",
|
||||
"clientId",
|
||||
"applicationTitle",
|
||||
"applicationUrl",
|
||||
"apikey",
|
||||
"locale",
|
||||
"plex_name",
|
||||
"plex_hostname",
|
||||
"plex_port",
|
||||
"plex_ssl",
|
||||
"plex_auth_token",
|
||||
"collection_handler_job_cron",
|
||||
"rules_handler_job_cron",
|
||||
"tautulli_url",
|
||||
"tautulli_api_key",
|
||||
"media_server_type",
|
||||
"jellyfin_url",
|
||||
"jellyfin_api_key",
|
||||
"jellyfin_user_id",
|
||||
"jellyfin_server_name",
|
||||
"seerr_url",
|
||||
"seerr_api_key",
|
||||
"tmdb_api_key",
|
||||
"tvdb_api_key",
|
||||
"metadata_provider_preference",
|
||||
"plex_machine_id",
|
||||
"plex_manual_mode"
|
||||
)
|
||||
SELECT "id",
|
||||
"clientId",
|
||||
"applicationTitle",
|
||||
"applicationUrl",
|
||||
"apikey",
|
||||
"locale",
|
||||
"plex_name",
|
||||
"plex_hostname",
|
||||
"plex_port",
|
||||
"plex_ssl",
|
||||
"plex_auth_token",
|
||||
"collection_handler_job_cron",
|
||||
"rules_handler_job_cron",
|
||||
"tautulli_url",
|
||||
"tautulli_api_key",
|
||||
"media_server_type",
|
||||
"jellyfin_url",
|
||||
"jellyfin_api_key",
|
||||
"jellyfin_user_id",
|
||||
"jellyfin_server_name",
|
||||
"seerr_url",
|
||||
"seerr_api_key",
|
||||
"tmdb_api_key",
|
||||
"tvdb_api_key",
|
||||
"metadata_provider_preference",
|
||||
"plex_machine_id",
|
||||
"plex_manual_mode"
|
||||
FROM "settings"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TABLE "settings"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "temporary_settings"
|
||||
RENAME TO "settings"
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "settings"
|
||||
RENAME TO "temporary_settings"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "settings" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"clientId" varchar,
|
||||
"applicationTitle" varchar NOT NULL DEFAULT ('Maintainerr'),
|
||||
"applicationUrl" varchar NOT NULL DEFAULT ('localhost'),
|
||||
"apikey" varchar,
|
||||
"locale" varchar NOT NULL DEFAULT ('en'),
|
||||
"plex_name" varchar,
|
||||
"plex_hostname" varchar,
|
||||
"plex_port" integer DEFAULT (32400),
|
||||
"plex_ssl" integer,
|
||||
"plex_auth_token" varchar,
|
||||
"collection_handler_job_cron" varchar NOT NULL DEFAULT ('0 0-23/12 * * *'),
|
||||
"rules_handler_job_cron" varchar NOT NULL DEFAULT ('0 0-23/8 * * *'),
|
||||
"tautulli_url" varchar,
|
||||
"tautulli_api_key" varchar,
|
||||
"media_server_type" varchar,
|
||||
"jellyfin_url" varchar,
|
||||
"jellyfin_api_key" varchar,
|
||||
"jellyfin_user_id" varchar,
|
||||
"jellyfin_server_name" varchar,
|
||||
"seerr_url" varchar,
|
||||
"seerr_api_key" varchar,
|
||||
"tmdb_api_key" varchar,
|
||||
"tvdb_api_key" varchar,
|
||||
"metadata_provider_preference" varchar NOT NULL DEFAULT ('tmdb_primary'),
|
||||
"plex_machine_id" varchar,
|
||||
"plex_manual_mode" integer DEFAULT (0)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "settings"(
|
||||
"id",
|
||||
"clientId",
|
||||
"applicationTitle",
|
||||
"applicationUrl",
|
||||
"apikey",
|
||||
"locale",
|
||||
"plex_name",
|
||||
"plex_hostname",
|
||||
"plex_port",
|
||||
"plex_ssl",
|
||||
"plex_auth_token",
|
||||
"collection_handler_job_cron",
|
||||
"rules_handler_job_cron",
|
||||
"tautulli_url",
|
||||
"tautulli_api_key",
|
||||
"media_server_type",
|
||||
"jellyfin_url",
|
||||
"jellyfin_api_key",
|
||||
"jellyfin_user_id",
|
||||
"jellyfin_server_name",
|
||||
"seerr_url",
|
||||
"seerr_api_key",
|
||||
"tmdb_api_key",
|
||||
"tvdb_api_key",
|
||||
"metadata_provider_preference",
|
||||
"plex_machine_id",
|
||||
"plex_manual_mode"
|
||||
)
|
||||
SELECT "id",
|
||||
"clientId",
|
||||
"applicationTitle",
|
||||
"applicationUrl",
|
||||
"apikey",
|
||||
"locale",
|
||||
"plex_name",
|
||||
"plex_hostname",
|
||||
"plex_port",
|
||||
"plex_ssl",
|
||||
"plex_auth_token",
|
||||
"collection_handler_job_cron",
|
||||
"rules_handler_job_cron",
|
||||
"tautulli_url",
|
||||
"tautulli_api_key",
|
||||
"media_server_type",
|
||||
"jellyfin_url",
|
||||
"jellyfin_api_key",
|
||||
"jellyfin_user_id",
|
||||
"jellyfin_server_name",
|
||||
"seerr_url",
|
||||
"seerr_api_key",
|
||||
"tmdb_api_key",
|
||||
"tvdb_api_key",
|
||||
"metadata_provider_preference",
|
||||
"plex_machine_id",
|
||||
"plex_manual_mode"
|
||||
FROM "temporary_settings"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TABLE "temporary_settings"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
|
||||
interface EmbyApiOptions {
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
authHeader: string;
|
||||
timeout?: number;
|
||||
extraHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around `axios.create` for talking to a user-configured Emby
|
||||
* server. Mirrors the helper pattern used elsewhere in this repo (e.g.
|
||||
* `apps/server/src/modules/api/tautulli-api/helpers/tautulli-api.helper.ts`)
|
||||
* so HTTP-client construction lives in `modules/api/<server>/` rather than
|
||||
* inside the media-server adapter.
|
||||
*
|
||||
* Maintainerr is intentionally self-hosted: the URL handed in here is the
|
||||
* URL the user typed into the Emby settings page (LAN host, ULA, link-local,
|
||||
* or public DNS as the user chooses). The same data flow exists for the Plex
|
||||
* and Jellyfin adapters; theirs route through external SDKs so the analysis
|
||||
* boundary differs.
|
||||
*/
|
||||
export class EmbyApi {
|
||||
readonly axios: AxiosInstance;
|
||||
|
||||
constructor(options: EmbyApiOptions) {
|
||||
let baseURL = options.url;
|
||||
while (baseURL.endsWith('/')) baseURL = baseURL.slice(0, -1);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'X-Emby-Authorization': options.authHeader,
|
||||
...options.extraHeaders,
|
||||
};
|
||||
if (options.apiKey) {
|
||||
headers['X-Emby-Token'] = options.apiKey;
|
||||
headers['X-MediaBrowser-Token'] = options.apiKey;
|
||||
}
|
||||
|
||||
this.axios = axios.create({
|
||||
baseURL,
|
||||
timeout: options.timeout ?? 30000,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ type AvailableCacheIds =
|
||||
| 'plexcommunity'
|
||||
| 'tautulli'
|
||||
| 'github'
|
||||
| 'jellyfin';
|
||||
| 'jellyfin'
|
||||
| 'emby';
|
||||
|
||||
type CacheType = AvailableCacheIds | 'radarr' | 'sonarr';
|
||||
|
||||
@@ -75,6 +76,7 @@ class CacheManager {
|
||||
checkPeriod: 60 * 60, // Check every hour
|
||||
}),
|
||||
jellyfin: new Cache('jellyfin', 'Jellyfin API', 'jellyfin'),
|
||||
emby: new Cache('emby', 'Emby API', 'emby'),
|
||||
};
|
||||
|
||||
public createCache(
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { MediaServerFeature } from '@maintainerr/contracts';
|
||||
import { AxiosError } from 'axios';
|
||||
import cacheManager from '../../lib/cache';
|
||||
import { EmbyAdapterService } from './emby-adapter.service';
|
||||
|
||||
jest.mock('../../lib/cache', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getCache: jest.fn().mockReturnValue({
|
||||
flush: jest.fn(),
|
||||
data: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
has: jest.fn(),
|
||||
del: jest.fn(),
|
||||
keys: jest.fn(),
|
||||
flushAll: jest.fn(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('EmbyAdapterService', () => {
|
||||
let service: EmbyAdapterService;
|
||||
let http: {
|
||||
get: jest.Mock;
|
||||
post: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
let logger: {
|
||||
setContext: jest.Mock;
|
||||
debug: jest.Mock;
|
||||
log: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
};
|
||||
|
||||
const createResponseError = (status: number): AxiosError => {
|
||||
const error = new AxiosError(`request failed with status ${status}`);
|
||||
Object.assign(error, {
|
||||
response: {
|
||||
status,
|
||||
statusText: status === 404 ? 'Not Found' : 'Bad Gateway',
|
||||
data: {},
|
||||
headers: {},
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
return error;
|
||||
};
|
||||
|
||||
const setHttp = (userId = 'user-1') => {
|
||||
(service as unknown as { http: typeof http }).http = http as any;
|
||||
(service as unknown as { embyUserId?: string }).embyUserId = userId;
|
||||
(service as unknown as { initialized: boolean }).initialized = true;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
logger = {
|
||||
setContext: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
log: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
http = {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
service = new EmbyAdapterService(
|
||||
{
|
||||
emby_url: 'http://emby.test:8096',
|
||||
emby_api_key: 'key',
|
||||
emby_user_id: 'user-1',
|
||||
} as any,
|
||||
logger as any,
|
||||
);
|
||||
setHttp();
|
||||
});
|
||||
|
||||
it('reports bulk-collection-create capability for Emby', () => {
|
||||
expect(
|
||||
service.supportsFeature(MediaServerFeature.BULK_COLLECTION_CREATE),
|
||||
).toBe(true);
|
||||
expect(cacheManager.getCache).toHaveBeenCalledWith('emby');
|
||||
});
|
||||
|
||||
describe('createCollection', () => {
|
||||
it('passes initial item ids on create and hydrates the created collection', async () => {
|
||||
http.post.mockResolvedValueOnce({ data: { Id: 'collection-1' } });
|
||||
http.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
Id: 'collection-1',
|
||||
Name: 'Seeded Collection',
|
||||
Overview: 'summary',
|
||||
ChildCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.createCollection({
|
||||
libraryId: 'library-1',
|
||||
title: 'Seeded Collection',
|
||||
type: 'show',
|
||||
initialItemIds: ['item-1', 'item-2'],
|
||||
});
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith('/Collections', null, {
|
||||
params: {
|
||||
Name: 'Seeded Collection',
|
||||
ParentId: 'library-1',
|
||||
Ids: 'item-1,item-2',
|
||||
IsLocked: true,
|
||||
},
|
||||
});
|
||||
expect(http.get).toHaveBeenCalledWith('/Users/user-1/Items/collection-1');
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'collection-1',
|
||||
title: 'Seeded Collection',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the metadata follow-up when sortTitle is provided without summary', async () => {
|
||||
const updateCollection = jest
|
||||
.spyOn(service, 'updateCollection')
|
||||
.mockResolvedValue({ id: 'collection-1', title: 'Sorted' } as any);
|
||||
http.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
Id: 'collection-1',
|
||||
Name: 'Sorted',
|
||||
ChildCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await service.createCollection({
|
||||
libraryId: 'library-1',
|
||||
title: 'Sorted',
|
||||
type: 'movie',
|
||||
sortTitle: 'A Sorted Title',
|
||||
});
|
||||
|
||||
expect(updateCollection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collectionId: 'collection-1',
|
||||
sortTitle: 'A Sorted Title',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCollection', () => {
|
||||
it('persists ForcedSortName when sortTitle is provided', async () => {
|
||||
http.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
Id: 'collection-1',
|
||||
Name: 'Current',
|
||||
Overview: 'Existing',
|
||||
ForcedSortName: 'Old Sort',
|
||||
},
|
||||
});
|
||||
http.post.mockResolvedValueOnce({ data: undefined });
|
||||
jest.spyOn(service, 'getCollection').mockResolvedValue({
|
||||
id: 'collection-1',
|
||||
title: 'Current',
|
||||
smart: false,
|
||||
} as any);
|
||||
|
||||
await service.updateCollection({
|
||||
libraryId: 'library-1',
|
||||
collectionId: 'collection-1',
|
||||
sortTitle: 'New Sort',
|
||||
});
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/Items/collection-1',
|
||||
expect.objectContaining({ ForcedSortName: 'New Sort' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllIdsForContextAction', () => {
|
||||
it('resolves show context to episode ids via seasons', async () => {
|
||||
const getChildrenMetadata = jest
|
||||
.spyOn(service, 'getChildrenMetadata')
|
||||
.mockResolvedValueOnce([{ id: 'season-1' } as any])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'episode-1' } as any,
|
||||
{ id: 'episode-2' } as any,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.getAllIdsForContextAction(
|
||||
'episode',
|
||||
{ type: 'show', id: 'show-1' },
|
||||
'show-1',
|
||||
),
|
||||
).resolves.toEqual(['episode-1', 'episode-2']);
|
||||
|
||||
expect(getChildrenMetadata).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'show-1',
|
||||
'season',
|
||||
);
|
||||
expect(getChildrenMetadata).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'season-1',
|
||||
'episode',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupCollectionForLibrary', () => {
|
||||
it('checks ancestor membership before removing items from a shared collection', async () => {
|
||||
jest
|
||||
.spyOn(service, 'getCollectionChildren')
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'item-1' } as any,
|
||||
{ id: 'item-2' } as any,
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'item-2' } as any]);
|
||||
const removeBatchFromCollection = jest
|
||||
.spyOn(service, 'removeBatchFromCollection')
|
||||
.mockResolvedValue([]);
|
||||
const deleteCollection = jest
|
||||
.spyOn(service, 'deleteCollection')
|
||||
.mockResolvedValue(undefined);
|
||||
http.get.mockImplementation(async (path: string) => {
|
||||
if (path === '/Items/item-1/Ancestors') {
|
||||
return { data: [{ Id: 'library-1' }] };
|
||||
}
|
||||
if (path === '/Items/item-2/Ancestors') {
|
||||
return { data: [{ Id: 'other-library' }] };
|
||||
}
|
||||
throw new Error(`Unexpected path ${path}`);
|
||||
});
|
||||
|
||||
await service.cleanupCollectionForLibrary(
|
||||
'collection-1',
|
||||
'library-1',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(removeBatchFromCollection).toHaveBeenCalledWith('collection-1', [
|
||||
'item-1',
|
||||
]);
|
||||
expect(deleteCollection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWatchHistory', () => {
|
||||
it('skips individual user visibility misses but keeps other users', async () => {
|
||||
http.get.mockImplementation(async (path: string) => {
|
||||
if (path === '/Users/Query') {
|
||||
return {
|
||||
data: [
|
||||
{ Id: 'user-1', Name: 'Alice' },
|
||||
{ Id: 'user-2', Name: 'Bob' },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (path === '/Users/user-1/Items/item-1') {
|
||||
throw new Error('forbidden');
|
||||
}
|
||||
if (path === '/Users/user-2/Items/item-1') {
|
||||
return {
|
||||
data: {
|
||||
Id: 'item-1',
|
||||
UserData: {
|
||||
Played: true,
|
||||
LastPlayedDate: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected path ${path}`);
|
||||
});
|
||||
|
||||
await expect(service.getWatchHistory('item-1')).resolves.toEqual([
|
||||
expect.objectContaining({ userId: 'user-2', itemId: 'item-1' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rethrows top-level user lookup failures instead of treating them as empty history', async () => {
|
||||
const error = createResponseError(502);
|
||||
http.get.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(service.getWatchHistory('item-1')).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('itemExists', () => {
|
||||
it('returns true when Emby returns the item', async () => {
|
||||
http.get.mockResolvedValueOnce({ data: { Id: '42' } });
|
||||
|
||||
await expect(service.itemExists('42')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on a 404 from Emby', async () => {
|
||||
http.get.mockRejectedValueOnce(createResponseError(404));
|
||||
|
||||
await expect(service.itemExists('42')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rethrows non-404 errors so overlay revert callers preserve backups', async () => {
|
||||
const error = createResponseError(500);
|
||||
http.get.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(service.itemExists('42')).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
// Emby cache and batching tuning. Values mirror Jellyfin defaults until tuned
|
||||
// against a live Emby server.
|
||||
|
||||
export const EMBY_CACHE_TTL = {
|
||||
WATCH_HISTORY: 300000,
|
||||
USER_DATA: 300000,
|
||||
PLAYED_THRESHOLD: 300000,
|
||||
USERS: 1800000,
|
||||
LIBRARIES: 1800000,
|
||||
STATUS: 60000,
|
||||
COLLECTIONS: 600,
|
||||
} as const;
|
||||
|
||||
export const EMBY_BATCH_SIZE = {
|
||||
USER_WATCH_HISTORY: 5,
|
||||
COLLECTION_MUTATION: 8,
|
||||
DEFAULT_PAGE_SIZE: 100,
|
||||
MAX_PAGE_SIZE: 500,
|
||||
} as const;
|
||||
|
||||
export const EMBY_CACHE_KEYS = {
|
||||
USERS: 'emby:users',
|
||||
LIBRARIES: 'emby:libraries',
|
||||
STATUS: 'emby:status',
|
||||
} as const;
|
||||
|
||||
// Emby uses the same .NET DateTime tick convention as Jellyfin.
|
||||
// 1 tick = 100 nanoseconds; 1 millisecond = 10,000 ticks.
|
||||
export const EMBY_TICKS_PER_MS = 10000;
|
||||
|
||||
// Emby's authorization header requires a pinned client Version string of
|
||||
// '1.0.0'. Newer values are rejected by some endpoints. See Jellyseerr
|
||||
// server/api/jellyfin.ts where mediaServerType === 'emby' hardcodes the same.
|
||||
export const EMBY_CLIENT_INFO = {
|
||||
name: 'Maintainerr',
|
||||
version: '1.0.0',
|
||||
} as const;
|
||||
|
||||
export const EMBY_DEVICE_INFO = {
|
||||
name: 'Maintainerr-Server',
|
||||
idPrefix: 'maintainerr',
|
||||
} as const;
|
||||
@@ -0,0 +1,455 @@
|
||||
import { EmbyMapper } from './emby.mapper';
|
||||
import type { EmbyBaseItemDto, EmbyUserDto } from './emby.types';
|
||||
|
||||
/**
|
||||
* EmbyMapper is a pure synchronous transform from Emby BaseItemDto into
|
||||
* Maintainerr's MediaItem contract. Emby's API and Jellyfin's API share the
|
||||
* same .NET-derived BaseItemDto field shape (Jellyfin forked Emby in 2018),
|
||||
* so the synthetic fixtures below mirror the ones in jellyfin.mapper.spec.ts
|
||||
* — they assert how the mapper transforms a known input, not what Emby
|
||||
* returns over the wire.
|
||||
*/
|
||||
describe('EmbyMapper', () => {
|
||||
describe('toMediaItemType', () => {
|
||||
it.each([
|
||||
['Movie', 'movie'],
|
||||
['Series', 'show'],
|
||||
['Season', 'season'],
|
||||
['Episode', 'episode'],
|
||||
[undefined, 'movie'],
|
||||
['Unknown', 'movie'],
|
||||
])('maps %s to %s', (input, expected) => {
|
||||
expect(EmbyMapper.toMediaItemType(input as any)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toEmbyItemKind', () => {
|
||||
it.each([
|
||||
['movie', 'Movie'],
|
||||
['show', 'Series'],
|
||||
['season', 'Season'],
|
||||
['episode', 'Episode'],
|
||||
])('maps %s to %s', (input, expected) => {
|
||||
expect(EmbyMapper.toEmbyItemKind(input as any)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toEmbyItemKinds', () => {
|
||||
it('returns Movie and Series for an empty array', () => {
|
||||
expect(EmbyMapper.toEmbyItemKinds([])).toEqual(['Movie', 'Series']);
|
||||
});
|
||||
|
||||
it('returns Movie and Series for undefined', () => {
|
||||
expect(EmbyMapper.toEmbyItemKinds(undefined)).toEqual([
|
||||
'Movie',
|
||||
'Series',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps multiple types correctly', () => {
|
||||
expect(EmbyMapper.toEmbyItemKinds(['movie', 'show'])).toEqual([
|
||||
'Movie',
|
||||
'Series',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractProviderIds', () => {
|
||||
it('extracts IMDB id', () => {
|
||||
expect(EmbyMapper.extractProviderIds({ Imdb: 'tt1234567' })).toEqual({
|
||||
imdb: ['tt1234567'],
|
||||
tmdb: [],
|
||||
tvdb: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts TMDB id', () => {
|
||||
expect(EmbyMapper.extractProviderIds({ Tmdb: '12345' })).toEqual({
|
||||
imdb: [],
|
||||
tmdb: ['12345'],
|
||||
tvdb: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts TVDB id', () => {
|
||||
expect(EmbyMapper.extractProviderIds({ Tvdb: '67890' })).toEqual({
|
||||
imdb: [],
|
||||
tmdb: [],
|
||||
tvdb: ['67890'],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null', () => {
|
||||
expect(EmbyMapper.extractProviderIds(null)).toEqual({
|
||||
imdb: [],
|
||||
tmdb: [],
|
||||
tvdb: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles undefined', () => {
|
||||
expect(EmbyMapper.extractProviderIds(undefined)).toEqual({
|
||||
imdb: [],
|
||||
tmdb: [],
|
||||
tvdb: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty object', () => {
|
||||
expect(EmbyMapper.extractProviderIds({})).toEqual({
|
||||
imdb: [],
|
||||
tmdb: [],
|
||||
tvdb: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMediaItem parent/grandparent semantics', () => {
|
||||
it('episode parent is the season, grandparent is the show', () => {
|
||||
const item: EmbyBaseItemDto = {
|
||||
Id: 'episode-1',
|
||||
ParentId: 'season-1',
|
||||
SeasonId: 'season-1',
|
||||
SeriesId: 'show-1',
|
||||
Name: 'E1',
|
||||
SeasonName: 'S1',
|
||||
SeriesName: 'Show A',
|
||||
Type: 'Episode',
|
||||
};
|
||||
|
||||
const result = EmbyMapper.toMediaItem(item);
|
||||
|
||||
expect(result.parentId).toBe('season-1');
|
||||
expect(result.grandparentId).toBe('show-1');
|
||||
expect(result.parentTitle).toBe('S1');
|
||||
expect(result.grandparentTitle).toBe('Show A');
|
||||
expect(result.type).toBe('episode');
|
||||
});
|
||||
|
||||
it('season parent is the show (SeriesId), not the library', () => {
|
||||
const item: EmbyBaseItemDto = {
|
||||
Id: 'season-1',
|
||||
ParentId: 'library-1',
|
||||
SeriesId: 'show-1',
|
||||
Name: 'S1',
|
||||
SeriesName: 'Show A',
|
||||
Type: 'Season',
|
||||
IndexNumber: 1,
|
||||
};
|
||||
|
||||
const result = EmbyMapper.toMediaItem(item);
|
||||
|
||||
expect(result.parentId).toBe('show-1');
|
||||
expect(result.grandparentId).toBeUndefined();
|
||||
expect(result.type).toBe('season');
|
||||
expect(result.index).toBe(1);
|
||||
});
|
||||
|
||||
it('show parent is the library', () => {
|
||||
const item: EmbyBaseItemDto = {
|
||||
Id: 'show-1',
|
||||
ParentId: 'library-1',
|
||||
Name: 'Show A',
|
||||
Type: 'Series',
|
||||
};
|
||||
|
||||
const result = EmbyMapper.toMediaItem(item);
|
||||
|
||||
expect(result.parentId).toBe('library-1');
|
||||
expect(result.grandparentId).toBeUndefined();
|
||||
expect(result.type).toBe('show');
|
||||
});
|
||||
|
||||
it('movie parent is the library', () => {
|
||||
const item: EmbyBaseItemDto = {
|
||||
Id: 'movie-1',
|
||||
ParentId: 'library-1',
|
||||
Name: 'Movie A',
|
||||
Type: 'Movie',
|
||||
};
|
||||
|
||||
const result = EmbyMapper.toMediaItem(item);
|
||||
|
||||
expect(result.parentId).toBe('library-1');
|
||||
expect(result.grandparentId).toBeUndefined();
|
||||
expect(result.type).toBe('movie');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMediaItem field conversion', () => {
|
||||
const baseItem: EmbyBaseItemDto = {
|
||||
Id: 'movie-1',
|
||||
ParentId: 'library-1',
|
||||
Name: 'Movie A',
|
||||
Type: 'Movie',
|
||||
DateCreated: '2021-01-01T00:00:00.000Z',
|
||||
Overview: 'A short description',
|
||||
ProductionYear: 2021,
|
||||
PremiereDate: '2021-01-01T00:00:00.000Z',
|
||||
RunTimeTicks: 72000000000, // 2 hours in 100-ns ticks
|
||||
ProviderIds: { Imdb: 'tt1234567', Tmdb: '12345' },
|
||||
MediaSources: [
|
||||
{
|
||||
Id: 'source-1',
|
||||
RunTimeTicks: 72000000000,
|
||||
Bitrate: 5000000,
|
||||
Container: 'mkv',
|
||||
Size: 4_000_000_000,
|
||||
MediaStreams: [
|
||||
{
|
||||
Type: 'Video',
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
Codec: 'h264',
|
||||
AspectRatio: '16:9',
|
||||
},
|
||||
{
|
||||
Type: 'Audio',
|
||||
Channels: 6,
|
||||
Codec: 'aac',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
UserData: {
|
||||
PlayCount: 5,
|
||||
LastPlayedDate: '2021-01-03T00:00:00.000Z',
|
||||
},
|
||||
CommunityRating: 8.5,
|
||||
Genres: ['Drama', 'Mystery'],
|
||||
People: [
|
||||
{
|
||||
Id: 'actor-1',
|
||||
Name: 'Performer One',
|
||||
Type: 'Actor',
|
||||
Role: 'Lead',
|
||||
PrimaryImageTag: 'tag-1',
|
||||
},
|
||||
{
|
||||
Id: 'director-1',
|
||||
Name: 'Director One',
|
||||
Type: 'Director',
|
||||
},
|
||||
],
|
||||
Tags: ['HD', '4K'],
|
||||
};
|
||||
|
||||
it('converts ISO timestamps to Date objects', () => {
|
||||
const result = EmbyMapper.toMediaItem(baseItem);
|
||||
|
||||
expect(result.addedAt).toEqual(new Date('2021-01-01T00:00:00.000Z'));
|
||||
expect(result.lastViewedAt).toEqual(new Date('2021-01-03T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('converts RunTimeTicks (100-ns) to milliseconds', () => {
|
||||
const result = EmbyMapper.toMediaItem(baseItem);
|
||||
|
||||
// 72_000_000_000 ticks / 10_000 ticks-per-ms = 7_200_000 ms = 2h
|
||||
expect(result.durationMs).toBe(7_200_000);
|
||||
expect(result.mediaSources[0].duration).toBe(7_200_000);
|
||||
});
|
||||
|
||||
it('converts media sources', () => {
|
||||
const result = EmbyMapper.toMediaItem(baseItem);
|
||||
|
||||
expect(result.mediaSources).toHaveLength(1);
|
||||
expect(result.mediaSources[0]).toMatchObject({
|
||||
id: 'source-1',
|
||||
bitrate: 5000000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
videoCodec: 'h264',
|
||||
audioCodec: 'aac',
|
||||
audioChannels: 6,
|
||||
container: 'mkv',
|
||||
sizeBytes: 4_000_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses AspectRatio expressed as a fraction', () => {
|
||||
const result = EmbyMapper.toMediaItem(baseItem);
|
||||
|
||||
expect(result.mediaSources[0].aspectRatio).toBeCloseTo(16 / 9);
|
||||
});
|
||||
|
||||
it('filters People down to actors and preserves order', () => {
|
||||
const result = EmbyMapper.toMediaItem(baseItem);
|
||||
|
||||
expect(result.actors).toHaveLength(1);
|
||||
expect(result.actors![0]).toMatchObject({
|
||||
name: 'Performer One',
|
||||
role: 'Lead',
|
||||
});
|
||||
});
|
||||
|
||||
it('hashes genre names into deterministic, stable ids', () => {
|
||||
const result = EmbyMapper.toMediaItem(baseItem);
|
||||
|
||||
const again = EmbyMapper.toMediaItem(baseItem);
|
||||
expect(result.genres).toHaveLength(2);
|
||||
expect(result.genres![0].name).toBe('Drama');
|
||||
expect(result.genres![0].id).toBe(again.genres![0].id);
|
||||
});
|
||||
|
||||
it('extracts community rating as audience and critic when present', () => {
|
||||
const result = EmbyMapper.toMediaItem({
|
||||
...baseItem,
|
||||
CriticRating: 85,
|
||||
} as any);
|
||||
|
||||
expect(result.ratings).toContainEqual({
|
||||
source: 'community',
|
||||
value: 8.5,
|
||||
type: 'audience',
|
||||
});
|
||||
// CriticRating is on a 0-100 scale; normalised to 0-10
|
||||
expect(result.ratings).toContainEqual({
|
||||
source: 'critic',
|
||||
value: 8.5,
|
||||
type: 'critic',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns labels from Tags', () => {
|
||||
const result = EmbyMapper.toMediaItem(baseItem);
|
||||
expect(result.labels).toEqual(['HD', '4K']);
|
||||
});
|
||||
|
||||
it('handles minimal items without crashing', () => {
|
||||
const result = EmbyMapper.toMediaItem({
|
||||
Id: 'minimal-1',
|
||||
Name: 'Minimal',
|
||||
Type: 'Movie',
|
||||
});
|
||||
|
||||
expect(result.id).toBe('minimal-1');
|
||||
expect(result.title).toBe('Minimal');
|
||||
expect(result.parentId).toBeUndefined();
|
||||
expect(result.providerIds).toEqual({ imdb: [], tmdb: [], tvdb: [] });
|
||||
expect(result.mediaSources).toEqual([]);
|
||||
expect(result.genres).toEqual([]);
|
||||
expect(result.actors).toEqual([]);
|
||||
expect(result.ratings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMediaLibrary', () => {
|
||||
it.each([
|
||||
['movies', 'movie'],
|
||||
['tvshows', 'show'],
|
||||
['music', 'movie'], // default
|
||||
[undefined, 'movie'],
|
||||
])('maps CollectionType %s to MediaLibrary.type %s', (input, expected) => {
|
||||
const result = EmbyMapper.toMediaLibrary({
|
||||
Id: 'lib-1',
|
||||
Name: 'Library',
|
||||
CollectionType: input as any,
|
||||
});
|
||||
|
||||
expect(result.type).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMediaUser', () => {
|
||||
it('builds the thumbnail path when PrimaryImageTag is present', () => {
|
||||
const user: EmbyUserDto = {
|
||||
Id: 'user-1',
|
||||
Name: 'Owner',
|
||||
PrimaryImageTag: 'tag',
|
||||
};
|
||||
const result = EmbyMapper.toMediaUser(user);
|
||||
|
||||
expect(result.thumb).toBe('/Users/user-1/Images/Primary');
|
||||
});
|
||||
|
||||
it('returns undefined thumb when no image tag exists', () => {
|
||||
const user: EmbyUserDto = { Id: 'user-2', Name: 'NoImage' };
|
||||
|
||||
expect(EmbyMapper.toMediaUser(user).thumb).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMediaCollection', () => {
|
||||
it('flags every collection as non-smart (Emby has no smart collections)', () => {
|
||||
const result = EmbyMapper.toMediaCollection({
|
||||
Id: 'col-1',
|
||||
Name: 'Bundle',
|
||||
ParentId: 'lib-1',
|
||||
ChildCount: 3,
|
||||
ImageTags: { Primary: 'tag' },
|
||||
});
|
||||
|
||||
expect(result.smart).toBe(false);
|
||||
expect(result.thumb).toBe('/Items/col-1/Images/Primary');
|
||||
expect(result.libraryId).toBe('lib-1');
|
||||
expect(result.childCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMediaPlaylist', () => {
|
||||
it('converts RunTimeTicks to durationMs and reports itemCount from ChildCount', () => {
|
||||
const result = EmbyMapper.toMediaPlaylist({
|
||||
Id: 'pl-1',
|
||||
Name: 'Playlist',
|
||||
ChildCount: 25,
|
||||
RunTimeTicks: 36000000000, // 1 hour
|
||||
});
|
||||
|
||||
expect(result.itemCount).toBe(25);
|
||||
expect(result.durationMs).toBe(3_600_000);
|
||||
expect(result.smart).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMediaServerStatus', () => {
|
||||
it('passes through server name and platform', () => {
|
||||
const result = EmbyMapper.toMediaServerStatus(
|
||||
'machine-1',
|
||||
'4.9.3.0',
|
||||
'Server',
|
||||
'Linux',
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
machineId: 'machine-1',
|
||||
version: '4.9.3.0',
|
||||
name: 'Server',
|
||||
platform: 'Linux',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats null optional fields as undefined', () => {
|
||||
const result = EmbyMapper.toMediaServerStatus(
|
||||
'machine-1',
|
||||
'4.9.3.0',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result.name).toBeUndefined();
|
||||
expect(result.platform).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toWatchRecord', () => {
|
||||
it('defaults progress to 100 when not provided', () => {
|
||||
const result = EmbyMapper.toWatchRecord(
|
||||
'user-1',
|
||||
'item-1',
|
||||
new Date('2021-01-01T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
userId: 'user-1',
|
||||
itemId: 'item-1',
|
||||
watchedAt: new Date('2021-01-01T00:00:00.000Z'),
|
||||
progress: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves watchedAt undefined when lastPlayedDate is missing', () => {
|
||||
const result = EmbyMapper.toWatchRecord('user-1', 'item-1');
|
||||
expect(result.watchedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import {
|
||||
type MediaActor,
|
||||
type MediaCollection,
|
||||
type MediaGenre,
|
||||
type MediaItem,
|
||||
type MediaItemType,
|
||||
type MediaLibrary,
|
||||
type MediaPlaylist,
|
||||
type MediaProviderIds,
|
||||
type MediaRating,
|
||||
type MediaServerStatus,
|
||||
type MediaSource,
|
||||
type MediaUser,
|
||||
type WatchRecord,
|
||||
} from '@maintainerr/contracts';
|
||||
import { EMBY_TICKS_PER_MS } from './emby.constants';
|
||||
import type {
|
||||
EmbyBaseItemDto,
|
||||
EmbyMediaSource,
|
||||
EmbyPerson,
|
||||
EmbyProviderIds,
|
||||
EmbyUserDto,
|
||||
} from './emby.types';
|
||||
|
||||
interface EmbyItemDtoWithExtras extends EmbyBaseItemDto {
|
||||
DateLastSaved?: string;
|
||||
CriticRating?: number;
|
||||
}
|
||||
|
||||
export class EmbyMapper {
|
||||
static toMediaItemType(kind?: string): MediaItemType {
|
||||
switch (kind) {
|
||||
case 'Movie':
|
||||
return 'movie';
|
||||
case 'Series':
|
||||
return 'show';
|
||||
case 'Season':
|
||||
return 'season';
|
||||
case 'Episode':
|
||||
return 'episode';
|
||||
default:
|
||||
return 'movie';
|
||||
}
|
||||
}
|
||||
|
||||
static toEmbyItemKind(type: MediaItemType): string {
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
return 'Movie';
|
||||
case 'show':
|
||||
return 'Series';
|
||||
case 'season':
|
||||
return 'Season';
|
||||
case 'episode':
|
||||
return 'Episode';
|
||||
default:
|
||||
return 'Movie';
|
||||
}
|
||||
}
|
||||
|
||||
static toEmbyItemKinds(types?: MediaItemType[]): string[] {
|
||||
if (!types?.length) {
|
||||
return ['Movie', 'Series'];
|
||||
}
|
||||
return types.map((type) => EmbyMapper.toEmbyItemKind(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Emby stores provider IDs with capitalized keys, identical to Jellyfin.
|
||||
*/
|
||||
static extractProviderIds(
|
||||
providerIds?: EmbyProviderIds | null,
|
||||
): MediaProviderIds {
|
||||
const result: MediaProviderIds = {
|
||||
imdb: [],
|
||||
tmdb: [],
|
||||
tvdb: [],
|
||||
};
|
||||
|
||||
if (!providerIds) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (providerIds.Imdb) {
|
||||
result.imdb.push(providerIds.Imdb);
|
||||
}
|
||||
if (providerIds.Tmdb) {
|
||||
result.tmdb.push(providerIds.Tmdb);
|
||||
}
|
||||
if (providerIds.Tvdb) {
|
||||
result.tvdb.push(providerIds.Tvdb);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintain Plex-consistent parent semantics:
|
||||
* - Season's parent is the show (SeriesId), not the library
|
||||
* - Episode's parent is the season (SeasonId)
|
||||
* - Other items use ParentId
|
||||
*/
|
||||
private static getParentId(item: EmbyBaseItemDto): string | undefined {
|
||||
const itemType = EmbyMapper.toMediaItemType(item.Type);
|
||||
|
||||
if (itemType === 'season') {
|
||||
return item.SeriesId || item.ParentId || undefined;
|
||||
}
|
||||
|
||||
if (itemType === 'episode') {
|
||||
return item.SeasonId || item.ParentId || undefined;
|
||||
}
|
||||
|
||||
return item.ParentId || undefined;
|
||||
}
|
||||
|
||||
private static getGrandparentId(item: EmbyBaseItemDto): string | undefined {
|
||||
const itemType = EmbyMapper.toMediaItemType(item.Type);
|
||||
|
||||
if (itemType === 'episode') {
|
||||
return item.SeriesId || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static toMediaItem(item: EmbyBaseItemDto): MediaItem {
|
||||
const parentId = EmbyMapper.getParentId(item);
|
||||
const grandparentId = EmbyMapper.getGrandparentId(item);
|
||||
const extras = item as EmbyItemDtoWithExtras;
|
||||
|
||||
return {
|
||||
id: item.Id || '',
|
||||
parentId: parentId,
|
||||
grandparentId: grandparentId,
|
||||
title: item.Name || '',
|
||||
parentTitle: item.SeasonName || item.SeriesName || undefined,
|
||||
grandparentTitle: item.SeriesName || undefined,
|
||||
guid: item.Id || '',
|
||||
parentGuid: parentId,
|
||||
grandparentGuid: grandparentId,
|
||||
type: EmbyMapper.toMediaItemType(item.Type),
|
||||
addedAt: item.DateCreated ? new Date(item.DateCreated) : new Date(),
|
||||
updatedAt: extras.DateLastSaved
|
||||
? new Date(extras.DateLastSaved)
|
||||
: undefined,
|
||||
providerIds: EmbyMapper.extractProviderIds(item.ProviderIds),
|
||||
mediaSources: EmbyMapper.toMediaSources(item.MediaSources),
|
||||
library: {
|
||||
id: item.ParentId || '',
|
||||
title: '',
|
||||
},
|
||||
summary: item.Overview || undefined,
|
||||
viewCount: item.UserData?.PlayCount || undefined,
|
||||
skipCount: undefined,
|
||||
lastViewedAt: item.UserData?.LastPlayedDate
|
||||
? new Date(item.UserData.LastPlayedDate)
|
||||
: undefined,
|
||||
year: item.ProductionYear || undefined,
|
||||
durationMs: item.RunTimeTicks
|
||||
? Math.floor(item.RunTimeTicks / EMBY_TICKS_PER_MS)
|
||||
: undefined,
|
||||
originallyAvailableAt: item.PremiereDate
|
||||
? new Date(item.PremiereDate)
|
||||
: undefined,
|
||||
contentRating: item.OfficialRating || undefined,
|
||||
ratings: EmbyMapper.toMediaRatings(item),
|
||||
userRating: undefined,
|
||||
genres: EmbyMapper.toMediaGenres(item.Genres),
|
||||
actors: EmbyMapper.toMediaActors(item.People),
|
||||
childCount: item.ChildCount || undefined,
|
||||
watchedChildCount: undefined,
|
||||
index: item.IndexNumber ?? undefined,
|
||||
indexEnd: item.IndexNumberEnd ?? undefined,
|
||||
parentIndex: item.ParentIndexNumber ?? undefined,
|
||||
collections: undefined,
|
||||
labels: item.Tags || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
static toMediaLibrary(item: EmbyBaseItemDto): MediaLibrary {
|
||||
return {
|
||||
id: item.Id || '',
|
||||
title: item.Name || '',
|
||||
type: EmbyMapper.toLibraryType(item.CollectionType),
|
||||
agent: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
static toLibraryType(collectionType?: string | null): 'movie' | 'show' {
|
||||
switch (collectionType?.toLowerCase()) {
|
||||
case 'movies':
|
||||
return 'movie';
|
||||
case 'tvshows':
|
||||
return 'show';
|
||||
default:
|
||||
return 'movie';
|
||||
}
|
||||
}
|
||||
|
||||
static toMediaUser(user: EmbyUserDto): MediaUser {
|
||||
return {
|
||||
id: user.Id || '',
|
||||
name: user.Name || '',
|
||||
thumb: user.PrimaryImageTag
|
||||
? `/Users/${user.Id}/Images/Primary`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
static toWatchRecord(
|
||||
userId: string,
|
||||
itemId: string,
|
||||
lastPlayedDate?: Date,
|
||||
progress?: number,
|
||||
): WatchRecord {
|
||||
return {
|
||||
userId,
|
||||
itemId,
|
||||
watchedAt: lastPlayedDate,
|
||||
progress: progress ?? 100,
|
||||
};
|
||||
}
|
||||
|
||||
static toMediaCollection(item: EmbyBaseItemDto): MediaCollection {
|
||||
const extras = item as EmbyItemDtoWithExtras;
|
||||
return {
|
||||
id: item.Id || '',
|
||||
title: item.Name || '',
|
||||
summary: item.Overview || undefined,
|
||||
thumb: item.ImageTags?.Primary
|
||||
? `/Items/${item.Id}/Images/Primary`
|
||||
: undefined,
|
||||
childCount: item.ChildCount || 0,
|
||||
addedAt: item.DateCreated ? new Date(item.DateCreated) : undefined,
|
||||
updatedAt: extras.DateLastSaved
|
||||
? new Date(extras.DateLastSaved)
|
||||
: undefined,
|
||||
// Emby has no native smart collections — only manual BoxSets and the
|
||||
// TheMovieDb-driven "Automatic Creation of Collections" (movie franchise
|
||||
// grouping, not filter rules). Always false, matching the Jellyfin mapper.
|
||||
smart: false,
|
||||
libraryId: item.ParentId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
static toMediaPlaylist(item: EmbyBaseItemDto): MediaPlaylist {
|
||||
const extras = item as EmbyItemDtoWithExtras;
|
||||
return {
|
||||
id: item.Id || '',
|
||||
title: item.Name || '',
|
||||
summary: item.Overview || undefined,
|
||||
smart: false,
|
||||
itemCount: item.ChildCount || 0,
|
||||
durationMs: item.RunTimeTicks
|
||||
? Math.floor(item.RunTimeTicks / EMBY_TICKS_PER_MS)
|
||||
: undefined,
|
||||
addedAt: item.DateCreated ? new Date(item.DateCreated) : undefined,
|
||||
updatedAt: extras.DateLastSaved
|
||||
? new Date(extras.DateLastSaved)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
static toMediaServerStatus(
|
||||
machineId: string,
|
||||
version: string,
|
||||
serverName?: string | null,
|
||||
platform?: string | null,
|
||||
url?: string | null,
|
||||
): MediaServerStatus {
|
||||
return {
|
||||
machineId,
|
||||
version,
|
||||
name: serverName || undefined,
|
||||
platform: platform || undefined,
|
||||
url: url || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private static toMediaSources(
|
||||
sources?: EmbyMediaSource[] | null,
|
||||
): MediaSource[] {
|
||||
if (!sources || !Array.isArray(sources)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sources.map((source) => {
|
||||
const videoStream = source.MediaStreams?.find((s) => s.Type === 'Video');
|
||||
const audioStream = source.MediaStreams?.find((s) => s.Type === 'Audio');
|
||||
|
||||
return {
|
||||
id: source.Id || '',
|
||||
duration: source.RunTimeTicks
|
||||
? Math.floor(source.RunTimeTicks / EMBY_TICKS_PER_MS)
|
||||
: 0,
|
||||
bitrate: source.Bitrate || undefined,
|
||||
width: videoStream?.Width || undefined,
|
||||
height: videoStream?.Height || undefined,
|
||||
aspectRatio: videoStream?.AspectRatio
|
||||
? videoStream.AspectRatio.includes(':')
|
||||
? parseFloat(videoStream.AspectRatio.split(':')[0]) /
|
||||
parseFloat(videoStream.AspectRatio.split(':')[1])
|
||||
: parseFloat(videoStream.AspectRatio)
|
||||
: undefined,
|
||||
audioChannels: audioStream?.Channels || undefined,
|
||||
audioCodec: audioStream?.Codec || undefined,
|
||||
videoCodec: videoStream?.Codec || undefined,
|
||||
videoResolution: videoStream?.Width
|
||||
? `${videoStream.Width}x${videoStream.Height}`
|
||||
: undefined,
|
||||
container: source.Container || undefined,
|
||||
sizeBytes: source.Size || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private static toMediaGenres(genres?: string[] | null): MediaGenre[] {
|
||||
if (!genres || !Array.isArray(genres)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return genres.map((genre) => ({
|
||||
id: EmbyMapper.hashString(genre),
|
||||
name: genre,
|
||||
}));
|
||||
}
|
||||
|
||||
private static hashString(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
private static toMediaActors(people?: EmbyPerson[] | null): MediaActor[] {
|
||||
if (!people || !Array.isArray(people)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return people
|
||||
.filter((person) => person.Type === 'Actor')
|
||||
.map((actor) => ({
|
||||
id: actor.Id || undefined,
|
||||
name: actor.Name || '',
|
||||
role: actor.Role || undefined,
|
||||
thumb: actor.PrimaryImageTag
|
||||
? `/Items/${actor.Id}/Images/Primary`
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
private static toMediaRatings(item: EmbyBaseItemDto): MediaRating[] {
|
||||
const ratings: MediaRating[] = [];
|
||||
const extras = item as EmbyItemDtoWithExtras;
|
||||
|
||||
if (item.CommunityRating !== undefined && item.CommunityRating !== null) {
|
||||
ratings.push({
|
||||
source: 'community',
|
||||
value: item.CommunityRating,
|
||||
type: 'audience',
|
||||
});
|
||||
}
|
||||
|
||||
if (extras.CriticRating !== undefined && extras.CriticRating !== null) {
|
||||
ratings.push({
|
||||
source: 'critic',
|
||||
value: extras.CriticRating / 10,
|
||||
type: 'critic',
|
||||
});
|
||||
}
|
||||
|
||||
return ratings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { SettingsModule } from '../../../settings/settings.module';
|
||||
import { EmbyAdapterService } from './emby-adapter.service';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => SettingsModule)],
|
||||
providers: [EmbyAdapterService],
|
||||
exports: [EmbyAdapterService],
|
||||
})
|
||||
export class EmbyModule {}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Minimal Emby HTTP response shapes.
|
||||
//
|
||||
// Emby and Jellyfin share a common API ancestor, so the JSON payload shapes
|
||||
// are largely the same as @jellyfin/sdk's BaseItemDto. These types document
|
||||
// only the fields Maintainerr touches. Extend as adapter methods are
|
||||
// implemented and verified against a real Emby server.
|
||||
|
||||
export interface EmbyProviderIds {
|
||||
Imdb?: string;
|
||||
Tmdb?: string;
|
||||
Tvdb?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface EmbyUserItemData {
|
||||
Played?: boolean;
|
||||
PlayCount?: number;
|
||||
IsFavorite?: boolean;
|
||||
PlaybackPositionTicks?: number;
|
||||
LastPlayedDate?: string;
|
||||
Key?: string;
|
||||
}
|
||||
|
||||
export interface EmbyMediaSource {
|
||||
Id?: string;
|
||||
Path?: string;
|
||||
Container?: string;
|
||||
Size?: number;
|
||||
Bitrate?: number;
|
||||
RunTimeTicks?: number;
|
||||
MediaStreams?: EmbyMediaStream[];
|
||||
}
|
||||
|
||||
export interface EmbyMediaStream {
|
||||
Codec?: string;
|
||||
Type?: 'Video' | 'Audio' | 'Subtitle' | string;
|
||||
Width?: number;
|
||||
Height?: number;
|
||||
AspectRatio?: string;
|
||||
BitRate?: number;
|
||||
Channels?: number;
|
||||
DisplayTitle?: string;
|
||||
}
|
||||
|
||||
export interface EmbyGenre {
|
||||
Id?: string;
|
||||
Name?: string;
|
||||
}
|
||||
|
||||
export interface EmbyPerson {
|
||||
Id?: string;
|
||||
Name?: string;
|
||||
Role?: string;
|
||||
Type?: string;
|
||||
PrimaryImageTag?: string;
|
||||
}
|
||||
|
||||
export interface EmbyBaseItemDto {
|
||||
Id: string;
|
||||
Name?: string;
|
||||
OriginalTitle?: string;
|
||||
SortName?: string;
|
||||
ForcedSortName?: string;
|
||||
ServerId?: string;
|
||||
Etag?: string;
|
||||
Type?: string;
|
||||
ParentId?: string;
|
||||
SeriesId?: string;
|
||||
SeasonId?: string;
|
||||
SeriesName?: string;
|
||||
SeasonName?: string;
|
||||
IndexNumber?: number;
|
||||
IndexNumberEnd?: number;
|
||||
ParentIndexNumber?: number;
|
||||
RunTimeTicks?: number;
|
||||
ProductionYear?: number;
|
||||
PremiereDate?: string;
|
||||
DateCreated?: string;
|
||||
CommunityRating?: number;
|
||||
OfficialRating?: string;
|
||||
Overview?: string;
|
||||
ProviderIds?: EmbyProviderIds;
|
||||
ImageTags?: Record<string, string>;
|
||||
BackdropImageTags?: string[];
|
||||
UserData?: EmbyUserItemData;
|
||||
MediaSources?: EmbyMediaSource[];
|
||||
Genres?: string[];
|
||||
GenreItems?: EmbyGenre[];
|
||||
Tags?: string[];
|
||||
TagItems?: EmbyGenre[];
|
||||
People?: EmbyPerson[];
|
||||
CollectionType?: string;
|
||||
IsLocked?: boolean;
|
||||
IsFolder?: boolean;
|
||||
ChildCount?: number;
|
||||
RecursiveItemCount?: number;
|
||||
Path?: string;
|
||||
ParentLogoItemId?: string;
|
||||
ParentLogoImageTag?: string;
|
||||
ParentBackdropItemId?: string;
|
||||
ParentBackdropImageTags?: string[];
|
||||
LocationType?: string;
|
||||
}
|
||||
|
||||
export interface EmbyUserDto {
|
||||
Id: string;
|
||||
Name?: string;
|
||||
ServerId?: string;
|
||||
Policy?: {
|
||||
IsAdministrator?: boolean;
|
||||
IsDisabled?: boolean;
|
||||
EnabledFolders?: string[];
|
||||
EnableAllFolders?: boolean;
|
||||
};
|
||||
Configuration?: Record<string, unknown>;
|
||||
HasPassword?: boolean;
|
||||
PrimaryImageTag?: string;
|
||||
}
|
||||
|
||||
export interface EmbyItemsQueryResponse<T = EmbyBaseItemDto> {
|
||||
Items: T[];
|
||||
TotalRecordCount?: number;
|
||||
StartIndex?: number;
|
||||
}
|
||||
|
||||
export interface EmbyLibraryFolder {
|
||||
Id: string;
|
||||
Name: string;
|
||||
CollectionType?: string;
|
||||
Path?: string;
|
||||
}
|
||||
|
||||
export interface EmbySystemInfo {
|
||||
Id?: string;
|
||||
ServerName?: string;
|
||||
Version?: string;
|
||||
ProductName?: string;
|
||||
OperatingSystem?: string;
|
||||
WebSocketPortNumber?: number;
|
||||
LocalAddress?: string;
|
||||
WanAddress?: string;
|
||||
}
|
||||
|
||||
export interface EmbyAuthenticationResult {
|
||||
User: EmbyUserDto;
|
||||
AccessToken: string;
|
||||
ServerId?: string;
|
||||
SessionInfo?: {
|
||||
Id?: string;
|
||||
DeviceId?: string;
|
||||
Client?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmbyAuthKey {
|
||||
AccessToken: string;
|
||||
AppName?: string;
|
||||
DateCreated?: string;
|
||||
}
|
||||
|
||||
export interface EmbyCollectionCreatedResult {
|
||||
Id: string;
|
||||
}
|
||||
|
||||
export interface EmbyPlaylistDto {
|
||||
Id?: string;
|
||||
Name?: string;
|
||||
ChildCount?: number;
|
||||
RunTimeTicks?: number;
|
||||
DateCreated?: string;
|
||||
}
|
||||
|
||||
export function hasProviderIds(
|
||||
item: EmbyBaseItemDto,
|
||||
): item is EmbyBaseItemDto & { ProviderIds: NonNullable<EmbyProviderIds> } {
|
||||
return item.ProviderIds !== undefined && item.ProviderIds !== null;
|
||||
}
|
||||
|
||||
export function hasUserData(
|
||||
item: EmbyBaseItemDto,
|
||||
): item is EmbyBaseItemDto & { UserData: NonNullable<EmbyUserItemData> } {
|
||||
return item.UserData !== undefined && item.UserData !== null;
|
||||
}
|
||||
|
||||
export function hasMediaSources(
|
||||
item: EmbyBaseItemDto,
|
||||
): item is EmbyBaseItemDto & { MediaSources: NonNullable<EmbyMediaSource[]> } {
|
||||
return (
|
||||
item.MediaSources !== undefined &&
|
||||
item.MediaSources !== null &&
|
||||
item.MediaSources.length > 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './emby-adapter.service';
|
||||
export * from './emby.module';
|
||||
@@ -1454,14 +1454,37 @@ describe('JellyfinAdapterService', () => {
|
||||
isLocked: true,
|
||||
}),
|
||||
);
|
||||
// When no initialItemIds are supplied, `ids` is forwarded as undefined
|
||||
// rather than omitted — the SDK signature accepts undefined and the
|
||||
// server treats it the same as absent.
|
||||
expect(collectionApiMocks.createCollection).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
ids: expect.anything(),
|
||||
}),
|
||||
expect.objectContaining({ ids: undefined }),
|
||||
);
|
||||
expect(result.id).toBe('collection-1');
|
||||
});
|
||||
|
||||
it('forwards initialItemIds to the Jellyfin SDK as ids', async () => {
|
||||
collectionApiMocks.createCollection.mockResolvedValueOnce({
|
||||
data: { Id: 'collection-2' },
|
||||
});
|
||||
|
||||
await service.createCollection({
|
||||
libraryId: 'library-1',
|
||||
title: 'Seeded',
|
||||
type: 'movie',
|
||||
initialItemIds: ['item-1', 'item-2'],
|
||||
});
|
||||
|
||||
expect(collectionApiMocks.createCollection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Seeded',
|
||||
parentId: 'library-1',
|
||||
isLocked: true,
|
||||
ids: ['item-1', 'item-2'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a batch of items in one Jellyfin request', async () => {
|
||||
await expect(
|
||||
service.addBatchToCollection('collection-1', ['item-1', 'item-2']),
|
||||
|
||||
@@ -1402,6 +1402,7 @@ export class JellyfinAdapterService implements IMediaServerService {
|
||||
try {
|
||||
const response = await getCollectionApi(this.api).createCollection({
|
||||
name: params.title,
|
||||
ids: params.initialItemIds,
|
||||
parentId: params.libraryId,
|
||||
// isLocked enables composite image generation from collection items
|
||||
isLocked: true,
|
||||
|
||||
@@ -39,6 +39,13 @@ export function isLikelyJellyfinId(value: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Emby shares Jellyfin's .NET-derived ID conventions (32-char hex or
|
||||
// 36-char dashed UUID), so the same heuristic applies. Wrapper kept for
|
||||
// call-site clarity at sites that branch by server type.
|
||||
export function isLikelyEmbyId(value: string): boolean {
|
||||
return isLikelyJellyfinId(value);
|
||||
}
|
||||
|
||||
export function isJellyfinEmptyGuid(value: string): boolean {
|
||||
return (
|
||||
value === JELLYFIN_EMPTY_GUID_DASHED ||
|
||||
@@ -62,6 +69,11 @@ export function isForeignServerId(
|
||||
return isLikelyJellyfinId(value);
|
||||
}
|
||||
|
||||
if (serverType === MediaServerType.EMBY) {
|
||||
// Emby IDs share Jellyfin's shape; a Plex numeric ID is foreign to Emby.
|
||||
return isLikelyPlexId(value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -85,6 +97,10 @@ export function shouldRefreshMetadataItemId(
|
||||
return isLikelyJellyfinId(value) && !isJellyfinEmptyGuid(value);
|
||||
}
|
||||
|
||||
if (serverType === MediaServerType.EMBY) {
|
||||
return isLikelyEmbyId(value) && !isJellyfinEmptyGuid(value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MaintainerrLogger } from '../../logging/logs.service';
|
||||
import { Settings } from '../../settings/entities/settings.entities';
|
||||
import { MediaServerSwitchService } from '../../settings/media-server-switch.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
import { EmbyAdapterService } from './emby/emby-adapter.service';
|
||||
import { JellyfinAdapterService } from './jellyfin/jellyfin-adapter.service';
|
||||
import { MediaServerFactory } from './media-server.factory';
|
||||
import { PlexAdapterService } from './plex/plex-adapter.service';
|
||||
@@ -37,6 +38,14 @@ describe('MediaServerFactory', () => {
|
||||
testConnection: jest.fn(),
|
||||
} as unknown as jest.Mocked<JellyfinAdapterService>;
|
||||
|
||||
const embyAdapter = {
|
||||
isSetup: jest.fn(),
|
||||
initialize: jest.fn(),
|
||||
uninitialize: jest.fn(),
|
||||
testConnection: jest.fn(),
|
||||
loginWithCredentials: jest.fn(),
|
||||
} as unknown as jest.Mocked<EmbyAdapterService>;
|
||||
|
||||
const createSettings = (overrides: Partial<Settings> = {}): Settings =>
|
||||
Object.assign(new Settings(), {
|
||||
media_server_type: null,
|
||||
@@ -46,6 +55,8 @@ describe('MediaServerFactory', () => {
|
||||
plex_auth_token: null,
|
||||
jellyfin_url: null,
|
||||
jellyfin_api_key: null,
|
||||
emby_url: null,
|
||||
emby_api_key: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -56,12 +67,14 @@ describe('MediaServerFactory', () => {
|
||||
mediaServerSwitchService,
|
||||
plexAdapter,
|
||||
jellyfinAdapter,
|
||||
embyAdapter,
|
||||
logger,
|
||||
);
|
||||
|
||||
mediaServerSwitchService.isSwitching.mockReturnValue(false);
|
||||
plexAdapter.isSetup.mockReturnValue(true);
|
||||
jellyfinAdapter.isSetup.mockReturnValue(true);
|
||||
embyAdapter.isSetup.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('throws ServiceUnavailableException while switch is in progress', async () => {
|
||||
@@ -162,18 +175,57 @@ describe('MediaServerFactory', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns and initializes Emby adapter when configured', async () => {
|
||||
settingsService.getSettings.mockResolvedValue(
|
||||
createSettings({ media_server_type: MediaServerType.EMBY }),
|
||||
);
|
||||
embyAdapter.isSetup.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
|
||||
const service = await factory.getService();
|
||||
|
||||
expect(embyAdapter.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(service).toBe(embyAdapter);
|
||||
});
|
||||
|
||||
it('throws when Emby remains uninitialized after initialize', async () => {
|
||||
settingsService.getSettings.mockResolvedValue(
|
||||
createSettings({ media_server_type: MediaServerType.EMBY }),
|
||||
);
|
||||
embyAdapter.isSetup.mockReturnValue(false);
|
||||
|
||||
await expect(factory.getService()).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('infers Emby when only Emby credentials exist and type is unset', async () => {
|
||||
settingsService.getSettings.mockResolvedValue(
|
||||
createSettings({
|
||||
media_server_type: null,
|
||||
emby_url: 'http://emby.local:8096',
|
||||
emby_api_key: 'key',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(factory.getConfiguredServerType()).resolves.toBe(
|
||||
MediaServerType.EMBY,
|
||||
);
|
||||
});
|
||||
|
||||
it('uninitializes the correct adapter by server type', () => {
|
||||
factory.uninitializeServer(MediaServerType.PLEX);
|
||||
factory.uninitializeServer(MediaServerType.JELLYFIN);
|
||||
factory.uninitializeServer(MediaServerType.EMBY);
|
||||
|
||||
expect(plexAdapter.uninitialize).toHaveBeenCalledTimes(1);
|
||||
expect(jellyfinAdapter.uninitialize).toHaveBeenCalledTimes(1);
|
||||
expect(embyAdapter.uninitialize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws for unsupported type in getServiceByType', async () => {
|
||||
await expect(
|
||||
factory.getServiceByType('EMBY' as unknown as MediaServerType),
|
||||
).rejects.toThrow('Unsupported media server type: EMBY');
|
||||
factory.getServiceByType('unknown' as unknown as MediaServerType),
|
||||
).rejects.toThrow('Unsupported media server type: unknown');
|
||||
});
|
||||
|
||||
it('initialize does not throw when server type is not configured', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MaintainerrLogger } from '../../logging/logs.service';
|
||||
import { Settings } from '../../settings/entities/settings.entities';
|
||||
import { MediaServerSwitchService } from '../../settings/media-server-switch.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
import { EmbyAdapterService } from './emby/emby-adapter.service';
|
||||
import { JellyfinAdapterService } from './jellyfin/jellyfin-adapter.service';
|
||||
import { IMediaServerService } from './media-server.interface';
|
||||
import { PlexAdapterService } from './plex/plex-adapter.service';
|
||||
@@ -38,6 +39,7 @@ export class MediaServerFactory {
|
||||
private readonly mediaServerSwitchService: MediaServerSwitchService,
|
||||
private readonly plexAdapter: PlexAdapterService,
|
||||
private readonly jellyfinAdapter: JellyfinAdapterService,
|
||||
private readonly embyAdapter: EmbyAdapterService,
|
||||
private readonly logger: MaintainerrLogger,
|
||||
) {
|
||||
this.logger.setContext(MediaServerFactory.name);
|
||||
@@ -99,6 +101,9 @@ export class MediaServerFactory {
|
||||
case MediaServerType.PLEX:
|
||||
return await this.ensureAdapterReady(serverType, this.plexAdapter);
|
||||
|
||||
case MediaServerType.EMBY:
|
||||
return await this.ensureAdapterReady(serverType, this.embyAdapter);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported media server type: ${serverType}`);
|
||||
}
|
||||
@@ -124,9 +129,11 @@ export class MediaServerFactory {
|
||||
settings.plex_port &&
|
||||
settings.plex_auth_token,
|
||||
);
|
||||
const embyConfigured = Boolean(settings.emby_url && settings.emby_api_key);
|
||||
const inferredType = this.resolveServerType(
|
||||
plexConfigured,
|
||||
jellyfinConfigured,
|
||||
embyConfigured,
|
||||
);
|
||||
|
||||
if (!configuredType) {
|
||||
@@ -156,6 +163,9 @@ export class MediaServerFactory {
|
||||
case MediaServerType.JELLYFIN:
|
||||
this.jellyfinAdapter.uninitialize();
|
||||
break;
|
||||
case MediaServerType.EMBY:
|
||||
this.embyAdapter.uninitialize();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported media server type: ${serverType}`);
|
||||
}
|
||||
@@ -178,19 +188,53 @@ export class MediaServerFactory {
|
||||
return this.jellyfinAdapter.testConnection(url, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test an Emby connection with the given credentials (API key flow).
|
||||
*/
|
||||
async testEmbyConnection(
|
||||
url: string,
|
||||
apiKey: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
serverName?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
users?: Array<{ id: string; name: string }>;
|
||||
}> {
|
||||
return this.embyAdapter.testConnection(url, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate against an Emby server with admin credentials, mirroring
|
||||
* the Plex-flavoured login flow. Returns the resulting access token plus
|
||||
* library/user lists for the post-login confirmation step.
|
||||
*/
|
||||
async loginEmbyWithCredentials(
|
||||
url: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
return this.embyAdapter.loginWithCredentials(url, username, password);
|
||||
}
|
||||
|
||||
private resolveServerType(
|
||||
plexConfigured: boolean,
|
||||
jellyfinConfigured: boolean,
|
||||
embyConfigured: boolean,
|
||||
): MediaServerType | null {
|
||||
if (jellyfinConfigured && !plexConfigured) {
|
||||
if (jellyfinConfigured && !plexConfigured && !embyConfigured) {
|
||||
return MediaServerType.JELLYFIN;
|
||||
}
|
||||
|
||||
if (plexConfigured && !jellyfinConfigured) {
|
||||
if (plexConfigured && !jellyfinConfigured && !embyConfigured) {
|
||||
return MediaServerType.PLEX;
|
||||
}
|
||||
|
||||
// Both configured or neither configured - can't infer
|
||||
if (embyConfigured && !plexConfigured && !jellyfinConfigured) {
|
||||
return MediaServerType.EMBY;
|
||||
}
|
||||
|
||||
// Multiple configured or none configured - can't infer
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { RuleGroup } from '../../rules/entities/rule-group.entities';
|
||||
import { Exclusion } from '../../rules/entities/exclusion.entities';
|
||||
import { SettingsModule } from '../../settings/settings.module';
|
||||
import { PlexApiModule } from '../plex-api/plex-api.module';
|
||||
import { EmbyAdapterService } from './emby/emby-adapter.service';
|
||||
import { EmbyModule } from './emby/emby.module';
|
||||
import { MediaServerSetupGuard } from './guards/media-server-setup.guard';
|
||||
import { JellyfinAdapterService } from './jellyfin/jellyfin-adapter.service';
|
||||
import { JellyfinModule } from './jellyfin/jellyfin.module';
|
||||
@@ -39,11 +41,13 @@ import { PlexAdapterService } from './plex/plex-adapter.service';
|
||||
PlexApiModule,
|
||||
forwardRef(() => SettingsModule),
|
||||
JellyfinModule,
|
||||
EmbyModule,
|
||||
],
|
||||
controllers: [MediaServerController],
|
||||
providers: [
|
||||
PlexAdapterService,
|
||||
JellyfinAdapterService,
|
||||
EmbyAdapterService,
|
||||
MediaServerFactory,
|
||||
MediaServerSetupGuard,
|
||||
MediaItemEnrichmentService,
|
||||
@@ -57,6 +61,8 @@ import { PlexAdapterService } from './plex/plex-adapter.service';
|
||||
// Jellyfin-specific methods not on IMediaServerService (analogous to
|
||||
// PlexApiModule exporting PlexApiService for PlexGetterService).
|
||||
JellyfinAdapterService,
|
||||
// EmbyAdapterService is exported for EmbyGetterService (mirrors Jellyfin pattern).
|
||||
EmbyAdapterService,
|
||||
MediaServerFactory,
|
||||
MediaServerSetupGuard,
|
||||
],
|
||||
|
||||
@@ -618,6 +618,40 @@ describe('PlexAdapterService', () => {
|
||||
).rejects.toThrow('Failed to create collection');
|
||||
});
|
||||
|
||||
it('forwards initialItemIds to PlexApiService for bulk create', async () => {
|
||||
plexApi.createCollection.mockResolvedValue(
|
||||
createPlexCollection({
|
||||
ratingKey: 'col456',
|
||||
key: '/library/collections/col456',
|
||||
guid: 'plex://collection/col456',
|
||||
title: 'Seeded',
|
||||
subtype: 'movie',
|
||||
summary: '',
|
||||
index: 0,
|
||||
ratingCount: 0,
|
||||
thumb: '/thumb/col456',
|
||||
addedAt: 1609459200,
|
||||
updatedAt: 1609459200,
|
||||
childCount: '2',
|
||||
maxYear: '2021',
|
||||
minYear: '2021',
|
||||
}),
|
||||
);
|
||||
|
||||
await service.createCollection({
|
||||
libraryId: 'lib1',
|
||||
title: 'Seeded',
|
||||
type: 'movie',
|
||||
initialItemIds: ['item-1', 'item-2'],
|
||||
});
|
||||
|
||||
expect(plexApi.createCollection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialItemIds: ['item-1', 'item-2'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delegate deleteCollection to PlexApiService', async () => {
|
||||
plexApi.deleteCollection.mockResolvedValue(undefined);
|
||||
await service.deleteCollection('col123');
|
||||
|
||||
@@ -300,6 +300,7 @@ export class PlexAdapterService implements IMediaServerService {
|
||||
title: params.title,
|
||||
summary: params.summary,
|
||||
sortTitle: params.sortTitle,
|
||||
initialItemIds: params.initialItemIds,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface CreateUpdateCollection {
|
||||
summary?: string;
|
||||
child?: string;
|
||||
sortTitle?: string;
|
||||
initialItemIds?: string[];
|
||||
}
|
||||
|
||||
export interface PlexPlaylist {
|
||||
|
||||
@@ -875,12 +875,19 @@ export class PlexApiService {
|
||||
|
||||
public async createCollection(params: CreateUpdateCollection) {
|
||||
try {
|
||||
// When initial items are supplied, seed them at create time using the
|
||||
// canonical server-URI form that python-plexapi's Collection.create()
|
||||
// uses. Saves a round trip and avoids a half-created empty collection
|
||||
// if the follow-up add were to fail.
|
||||
const itemsUri = params.initialItemIds?.length
|
||||
? `&uri=${this.buildCollectionItemsUri(params.initialItemIds)}`
|
||||
: '';
|
||||
const response = await this.plexClient.postQuery<any>({
|
||||
uri: `/library/collections?type=${
|
||||
params.type
|
||||
}&title=${encodeURIComponent(params.title)}§ionId=${
|
||||
params.libraryId
|
||||
}`,
|
||||
}${itemsUri}`,
|
||||
});
|
||||
const collection: PlexCollection = response.MediaContainer
|
||||
.Metadata[0] as PlexCollection;
|
||||
|
||||
@@ -1059,7 +1059,7 @@ describe('CollectionsService', () => {
|
||||
|
||||
expect(mediaServer.createCollection).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
itemIds: expect.anything(),
|
||||
initialItemIds: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(addChildrenToCollectionSpy).toHaveBeenCalledWith(
|
||||
@@ -1069,6 +1069,7 @@ describe('CollectionsService', () => {
|
||||
},
|
||||
media,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1767,6 +1768,49 @@ describe('CollectionsService', () => {
|
||||
expect(result[0].mediaData?.title).toBe('Fallback Movie');
|
||||
});
|
||||
|
||||
it('passes initial item ids on create for servers that support seeded collection creation', async () => {
|
||||
const collection = createCollection({
|
||||
id: 21,
|
||||
mediaServerId: null,
|
||||
manualCollection: false,
|
||||
libraryId: 'library-1',
|
||||
type: 'show',
|
||||
});
|
||||
|
||||
collectionRepo.findOne.mockResolvedValue(collection);
|
||||
collectionRepo.save.mockImplementation(async (entity) => entity as any);
|
||||
collectionMediaRepo.find.mockResolvedValue([]);
|
||||
collectionPosterService.loadStoredPoster.mockResolvedValue(null);
|
||||
mediaServer.supportsFeature.mockImplementation(
|
||||
(feature) => feature === MediaServerFeature.BULK_COLLECTION_CREATE,
|
||||
);
|
||||
jest
|
||||
.spyOn(service as any, 'checkAutomaticMediaServerLink')
|
||||
.mockResolvedValue(collection);
|
||||
const addChildrenToCollection = jest
|
||||
.spyOn(service as any, 'addChildrenToCollection')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await service.addToCollection(collection.id, [
|
||||
{ mediaServerId: 'episode-1' },
|
||||
{ mediaServerId: 'episode-2' },
|
||||
]);
|
||||
|
||||
expect(mediaServer.createCollection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialItemIds: ['episode-1', 'episode-2'],
|
||||
}),
|
||||
);
|
||||
expect(mediaServer.addBatchToCollection).not.toHaveBeenCalled();
|
||||
expect(addChildrenToCollection).toHaveBeenCalledWith(
|
||||
{ mediaServerId: 'remote-collection', dbId: collection.id },
|
||||
[{ mediaServerId: 'episode-1' }, { mediaServerId: 'episode-2' }],
|
||||
false,
|
||||
true,
|
||||
CollectionMediaManualMembershipSource.LOCAL,
|
||||
);
|
||||
});
|
||||
|
||||
it('reorders Plex collection items by collection_media.addDate, not media-server addedAt', async () => {
|
||||
// Regression for #2867: applyCollectionSort previously sorted by
|
||||
// MediaItem.addedAt (when the file was added to the Plex library).
|
||||
|
||||
@@ -1424,6 +1424,7 @@ export class CollectionsService {
|
||||
async createCollection(
|
||||
collection: ICollection,
|
||||
empty = true,
|
||||
initialItemIds?: string[],
|
||||
): Promise<
|
||||
| {
|
||||
dbCollection: addCollectionDbResponse;
|
||||
@@ -1446,6 +1447,7 @@ export class CollectionsService {
|
||||
summary: collection?.description,
|
||||
sortTitle: collection?.sortTitle,
|
||||
type: collection.type,
|
||||
initialItemIds,
|
||||
});
|
||||
|
||||
// Store the media server ID from the created collection
|
||||
@@ -1533,7 +1535,19 @@ export class CollectionsService {
|
||||
| undefined
|
||||
> {
|
||||
try {
|
||||
const createdCollection = await this.createCollection(collection, false);
|
||||
const mediaServer = await this.getMediaServer();
|
||||
const createWithItems = mediaServer.supportsFeature(
|
||||
MediaServerFeature.BULK_COLLECTION_CREATE,
|
||||
);
|
||||
const initialItemIds =
|
||||
createWithItems && media?.length
|
||||
? media.map((item) => item.mediaServerId)
|
||||
: undefined;
|
||||
const createdCollection = await this.createCollection(
|
||||
collection,
|
||||
false,
|
||||
initialItemIds,
|
||||
);
|
||||
|
||||
if (!createdCollection?.dbCollection) {
|
||||
return undefined;
|
||||
@@ -1549,6 +1563,7 @@ export class CollectionsService {
|
||||
},
|
||||
media,
|
||||
false,
|
||||
createWithItems && Boolean(initialItemIds?.length),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1969,6 +1984,21 @@ export class CollectionsService {
|
||||
!collection.mediaServerId &&
|
||||
(newMedia.length > 0 || collectionMedia.length > 0);
|
||||
|
||||
const createWithItems = mediaServer.supportsFeature(
|
||||
MediaServerFeature.BULK_COLLECTION_CREATE,
|
||||
);
|
||||
const initialItemIds = createWithItems
|
||||
? [
|
||||
...new Set([
|
||||
...collectionMedia.map(
|
||||
(existingMedia) => existingMedia.mediaServerId,
|
||||
),
|
||||
...newMedia.map((pendingMedia) => pendingMedia.mediaServerId),
|
||||
]),
|
||||
]
|
||||
: undefined;
|
||||
let seededOnCreate = false;
|
||||
|
||||
// Create media server collection if needed
|
||||
if (needsMediaServerCollection) {
|
||||
let newColl: MediaCollection | undefined = undefined;
|
||||
@@ -1990,7 +2020,9 @@ export class CollectionsService {
|
||||
summary: collection.description,
|
||||
sortTitle: collection.sortTitle,
|
||||
type: collection.type,
|
||||
initialItemIds,
|
||||
});
|
||||
seededOnCreate = Boolean(initialItemIds?.length);
|
||||
}
|
||||
}
|
||||
if (newColl?.id) {
|
||||
@@ -2029,7 +2061,7 @@ export class CollectionsService {
|
||||
}
|
||||
|
||||
// Check if we need to sync existing items to a newly created collection
|
||||
const needsResync = collectionMedia.length > 0;
|
||||
const needsResync = collectionMedia.length > 0 && !seededOnCreate;
|
||||
|
||||
// If we had existing collection_media items, sync them to the new media server collection
|
||||
if (needsResync) {
|
||||
@@ -2119,7 +2151,7 @@ export class CollectionsService {
|
||||
{ mediaServerId: collection.mediaServerId, dbId: collection.id },
|
||||
newMedia,
|
||||
manual,
|
||||
skipMediaServerAdd,
|
||||
skipMediaServerAdd || seededOnCreate,
|
||||
manualMembershipSource,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Mocked, TestBed } from '@suites/unit';
|
||||
import { EmbyAdapterService } from '../../api/media-server/emby/emby-adapter.service';
|
||||
import { EmbyOverlayProvider } from './emby-overlay.provider';
|
||||
|
||||
describe('EmbyOverlayProvider', () => {
|
||||
let provider: EmbyOverlayProvider;
|
||||
let emby: Mocked<EmbyAdapterService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { unit, unitRef } =
|
||||
await TestBed.solitary(EmbyOverlayProvider).compile();
|
||||
|
||||
provider = unit;
|
||||
emby = unitRef.get(EmbyAdapterService);
|
||||
});
|
||||
|
||||
describe('itemExists', () => {
|
||||
it('delegates to EmbyAdapterService.itemExists', async () => {
|
||||
emby.itemExists.mockResolvedValue(true);
|
||||
|
||||
await expect(provider.itemExists('42')).resolves.toBe(true);
|
||||
expect(emby.itemExists).toHaveBeenCalledWith('42');
|
||||
|
||||
emby.itemExists.mockResolvedValue(false);
|
||||
await expect(provider.itemExists('42')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('propagates errors so revert callers preserve state on transient failures', async () => {
|
||||
emby.itemExists.mockRejectedValue(new Error('5xx'));
|
||||
|
||||
await expect(provider.itemExists('42')).rejects.toThrow('5xx');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
OverlayLibrarySection,
|
||||
OverlayPreviewItem,
|
||||
} from '@maintainerr/contracts';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EmbyAdapterService } from '../../api/media-server/emby/emby-adapter.service';
|
||||
import { IOverlayProvider } from './overlay-provider.interface';
|
||||
|
||||
/**
|
||||
* Emby implementation of IOverlayProvider.
|
||||
*
|
||||
* Reads/writes the `Primary` image (poster for movies/shows, still for
|
||||
* episodes), mirroring the Jellyfin provider's choice. Emby's image endpoint
|
||||
* surface matches Jellyfin's (same .NET ancestor): GET/POST/DELETE
|
||||
* /Items/{id}/Images/{imageType}.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbyOverlayProvider implements IOverlayProvider {
|
||||
constructor(private readonly emby: EmbyAdapterService) {}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return this.emby.isSetup();
|
||||
}
|
||||
|
||||
async getSections(): Promise<OverlayLibrarySection[]> {
|
||||
const libs = await this.emby.getLibraries();
|
||||
const sections: OverlayLibrarySection[] = [];
|
||||
for (const l of libs) {
|
||||
if (l.type === 'movie' || l.type === 'show') {
|
||||
sections.push({ key: l.id, title: l.title, type: l.type });
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
async getRandomItem(
|
||||
sectionKeys?: string[],
|
||||
): Promise<OverlayPreviewItem | null> {
|
||||
const item = await this.emby.findRandomItem(sectionKeys, [
|
||||
'Movie',
|
||||
'Series',
|
||||
]);
|
||||
if (!item?.Id) return null;
|
||||
return { itemId: item.Id, title: item.Name ?? '' };
|
||||
}
|
||||
|
||||
async getRandomEpisode(
|
||||
sectionKeys?: string[],
|
||||
): Promise<OverlayPreviewItem | null> {
|
||||
const ep = await this.emby.findRandomEpisode(sectionKeys);
|
||||
if (!ep?.Id) return null;
|
||||
const name = ep.Name ?? '';
|
||||
const title = ep.SeriesName ? `${ep.SeriesName} — ${name}` : name;
|
||||
return { itemId: ep.Id, title };
|
||||
}
|
||||
|
||||
async downloadImage(itemId: string): Promise<Buffer | null> {
|
||||
return this.emby.getItemImageBuffer(itemId, 'Primary');
|
||||
}
|
||||
|
||||
async uploadImage(
|
||||
itemId: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
// Reuse the collection-image upload path: Emby's image upload endpoint
|
||||
// accepts a base64 body with the original Content-Type on POST.
|
||||
await this.emby.setCollectionImage(itemId, buffer, contentType);
|
||||
}
|
||||
|
||||
async itemExists(itemId: string): Promise<boolean> {
|
||||
return this.emby.itemExists(itemId);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MediaServerType } from '@maintainerr/contracts';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MediaServerFactory } from '../../api/media-server/media-server.factory';
|
||||
import { EmbyOverlayProvider } from './emby-overlay.provider';
|
||||
import { JellyfinOverlayProvider } from './jellyfin-overlay.provider';
|
||||
import { IOverlayProvider } from './overlay-provider.interface';
|
||||
import { PlexOverlayProvider } from './plex-overlay.provider';
|
||||
@@ -24,6 +25,7 @@ export class OverlayProviderFactory {
|
||||
private readonly mediaServerFactory: MediaServerFactory,
|
||||
private readonly plexProvider: PlexOverlayProvider,
|
||||
private readonly jellyfinProvider: JellyfinOverlayProvider,
|
||||
private readonly embyProvider: EmbyOverlayProvider,
|
||||
) {}
|
||||
|
||||
async getProvider(): Promise<IOverlayProvider | null> {
|
||||
@@ -33,6 +35,8 @@ export class OverlayProviderFactory {
|
||||
return this.plexProvider;
|
||||
case MediaServerType.JELLYFIN:
|
||||
return this.jellyfinProvider;
|
||||
case MediaServerType.EMBY:
|
||||
return this.embyProvider;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { MediaServerModule } from '../../api/media-server/media-server.module';
|
||||
import { PlexApiModule } from '../../api/plex-api/plex-api.module';
|
||||
import { LogsModule } from '../../logging/logs.module';
|
||||
import { EmbyOverlayProvider } from './emby-overlay.provider';
|
||||
import { JellyfinOverlayProvider } from './jellyfin-overlay.provider';
|
||||
import { OverlayProviderFactory } from './overlay-provider.factory';
|
||||
import { PlexOverlayProvider } from './plex-overlay.provider';
|
||||
@@ -20,6 +21,7 @@ import { PlexOverlayProvider } from './plex-overlay.provider';
|
||||
providers: [
|
||||
PlexOverlayProvider,
|
||||
JellyfinOverlayProvider,
|
||||
EmbyOverlayProvider,
|
||||
OverlayProviderFactory,
|
||||
],
|
||||
exports: [OverlayProviderFactory],
|
||||
|
||||
@@ -1386,4 +1386,25 @@ export class RuleConstants {
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
constructor() {
|
||||
// Emby shares Jellyfin's data model and rule properties. We mirror the
|
||||
// Jellyfin property list verbatim (same IDs and names) so rule migration
|
||||
// between Jellyfin and Emby is a no-op on the property side. The Emby
|
||||
// getter implements the same property names against Emby's HTTP endpoints.
|
||||
const jellyfinApp = this.applications.find(
|
||||
(a) => a.id === Application.JELLYFIN,
|
||||
);
|
||||
if (
|
||||
jellyfinApp &&
|
||||
!this.applications.some((a) => a.id === Application.EMBY)
|
||||
) {
|
||||
this.applications.push({
|
||||
id: Application.EMBY,
|
||||
name: 'Emby',
|
||||
mediaType: MediaType.BOTH,
|
||||
props: jellyfinApp.props,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { MediaServerFactory } from '../../api/media-server/media-server.factory'
|
||||
import { Application } from '../constants/rules.constants';
|
||||
import { RuleDto } from '../dtos/rule.dto';
|
||||
import { RulesDto } from '../dtos/rules.dto';
|
||||
import { EmbyGetterService } from './emby-getter.service';
|
||||
import { JellyfinGetterService } from './jellyfin-getter.service';
|
||||
import { PlexGetterService } from './plex-getter.service';
|
||||
import { RadarrGetterService } from './radarr-getter.service';
|
||||
@@ -25,6 +26,7 @@ export class ValueGetterService {
|
||||
private readonly seerrGetter: SeerrGetterService,
|
||||
private readonly tautulliGetter: TautulliGetterService,
|
||||
private readonly jellyfinGetter: JellyfinGetterService,
|
||||
private readonly embyGetter: EmbyGetterService,
|
||||
private readonly mediaServerFactory: MediaServerFactory,
|
||||
) {}
|
||||
|
||||
@@ -36,10 +38,13 @@ export class ValueGetterService {
|
||||
currentRule?: RuleDto,
|
||||
): Promise<RuleValueType> {
|
||||
switch (val1) {
|
||||
// Route both PLEX and JELLYFIN to the configured media server's getter
|
||||
// This handles community rules that may reference the wrong server type
|
||||
// Route Plex/Jellyfin/Emby Application IDs to the configured media
|
||||
// server's getter. This handles community rules that reference the
|
||||
// "wrong" server type — e.g. a rule authored with Application.JELLYFIN
|
||||
// can still evaluate against a configured Emby server.
|
||||
case Application.PLEX:
|
||||
case Application.JELLYFIN: {
|
||||
case Application.JELLYFIN:
|
||||
case Application.EMBY: {
|
||||
const serverType =
|
||||
await this.mediaServerFactory.getConfiguredServerType();
|
||||
|
||||
@@ -48,7 +53,9 @@ export class ValueGetterService {
|
||||
? this.jellyfinGetter
|
||||
: serverType === MediaServerType.PLEX
|
||||
? this.plexGetter
|
||||
: null;
|
||||
: serverType === MediaServerType.EMBY
|
||||
? this.embyGetter
|
||||
: null;
|
||||
|
||||
return getter?.get(val2, libItem, dataType, ruleGroup) ?? null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CommunityRuleKarma } from './entities/community-rule-karma.entities';
|
||||
import { Exclusion } from './entities/exclusion.entities';
|
||||
import { RuleGroup } from './entities/rule-group.entities';
|
||||
import { Rules } from './entities/rules.entities';
|
||||
import { EmbyGetterService } from './getter/emby-getter.service';
|
||||
import { ValueGetterService } from './getter/getter.service';
|
||||
import { JellyfinGetterService } from './getter/jellyfin-getter.service';
|
||||
import { SeerrGetterService } from './getter/seerr-getter.service';
|
||||
@@ -71,6 +72,7 @@ import { RuleMaintenanceService } from './tasks/rule-maintenance.service';
|
||||
ExclusionTypeCorrectorService,
|
||||
PlexGetterService,
|
||||
JellyfinGetterService,
|
||||
EmbyGetterService,
|
||||
RadarrGetterService,
|
||||
SonarrGetterService,
|
||||
SeerrGetterService,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { MediaServerType } from '@maintainerr/contracts';
|
||||
import { createMockLogger } from '../../../test/utils/data';
|
||||
import cacheManager, { Cache } from '../api/lib/cache';
|
||||
import { RulesDto } from './dtos/rules.dto';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
/**
|
||||
* Focused test for `resetCacheIfGroupUsesRuleThatRequiresIt` covering the
|
||||
* server-type dispatch into the cache registry. The method's logic is small
|
||||
* (three switch branches), but each branch flushes a different named cache,
|
||||
* so the per-server routing is the part worth pinning.
|
||||
*/
|
||||
describe('RulesService.resetCacheIfGroupUsesRuleThatRequiresIt', () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
type FactoryStub = {
|
||||
getConfiguredServerType: jest.Mock<Promise<MediaServerType>, []>;
|
||||
};
|
||||
|
||||
const createRulesService = (factory: FactoryStub) =>
|
||||
new RulesService(
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
factory as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
logger as any,
|
||||
);
|
||||
|
||||
const stubGetRuleConstants = (service: RulesService) => {
|
||||
// Single dummy application+property tree where the property used in the
|
||||
// mock rule below has cacheReset = true, forcing the flush branch.
|
||||
jest.spyOn(service, 'getRuleConstants').mockResolvedValue({
|
||||
applications: [
|
||||
{
|
||||
id: 99,
|
||||
name: 'TestApp',
|
||||
mediaType: 0 as any,
|
||||
props: [
|
||||
{
|
||||
id: 0,
|
||||
name: 'cachedProp',
|
||||
humanName: 'Cached Prop',
|
||||
mediaType: 0 as any,
|
||||
type: { key: 'number', possibilities: [] } as any,
|
||||
cacheReset: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
};
|
||||
|
||||
const ruleGroup = {
|
||||
rules: [
|
||||
{
|
||||
ruleJson: JSON.stringify({
|
||||
operator: null,
|
||||
action: 0,
|
||||
firstVal: [99, 0],
|
||||
customVal: { ruleTypeId: 0, value: '1' },
|
||||
section: 0,
|
||||
}),
|
||||
},
|
||||
],
|
||||
} as unknown as RulesDto;
|
||||
|
||||
const spyOnCache = (cacheId: 'plextv' | 'plexguid' | 'jellyfin' | 'emby') => {
|
||||
const cache = cacheManager.getCache(cacheId) as Cache;
|
||||
return jest.spyOn(cache, 'flush').mockImplementation(() => undefined);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('flushes the Emby cache when the configured server type is Emby', async () => {
|
||||
const flush = spyOnCache('emby');
|
||||
const factory: FactoryStub = {
|
||||
getConfiguredServerType: jest
|
||||
.fn()
|
||||
.mockResolvedValue(MediaServerType.EMBY),
|
||||
};
|
||||
const service = createRulesService(factory);
|
||||
stubGetRuleConstants(service);
|
||||
|
||||
const result =
|
||||
await service.resetCacheIfGroupUsesRuleThatRequiresIt(ruleGroup);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flushes the Jellyfin cache when the configured server type is Jellyfin', async () => {
|
||||
const flush = spyOnCache('jellyfin');
|
||||
const factory: FactoryStub = {
|
||||
getConfiguredServerType: jest
|
||||
.fn()
|
||||
.mockResolvedValue(MediaServerType.JELLYFIN),
|
||||
};
|
||||
const service = createRulesService(factory);
|
||||
stubGetRuleConstants(service);
|
||||
|
||||
await service.resetCacheIfGroupUsesRuleThatRequiresIt(ruleGroup);
|
||||
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flushes both Plex caches when the configured server type is Plex', async () => {
|
||||
const flushTv = spyOnCache('plextv');
|
||||
const flushGuid = spyOnCache('plexguid');
|
||||
const factory: FactoryStub = {
|
||||
getConfiguredServerType: jest
|
||||
.fn()
|
||||
.mockResolvedValue(MediaServerType.PLEX),
|
||||
};
|
||||
const service = createRulesService(factory);
|
||||
stubGetRuleConstants(service);
|
||||
|
||||
await service.resetCacheIfGroupUsesRuleThatRequiresIt(ruleGroup);
|
||||
|
||||
expect(flushTv).toHaveBeenCalledTimes(1);
|
||||
expect(flushGuid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1451,6 +1451,11 @@ export class RulesService {
|
||||
this.logger.log(
|
||||
`Flushed Plex cache because a rule in the group required it`,
|
||||
);
|
||||
} else if (serverType === MediaServerType.EMBY) {
|
||||
cacheManager.getCache('emby').flush();
|
||||
this.logger.log(
|
||||
`Flushed Emby cache because a rule in the group required it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -455,16 +455,17 @@ export class RuleExecutorService {
|
||||
}
|
||||
|
||||
// Handle manually removed items from collections
|
||||
// Jellyfin workaround: Skip removal check when children array is empty.
|
||||
// Unlike Plex, Jellyfin's collection API can return empty children during
|
||||
// brief sync delays after collection modifications, causing false positives
|
||||
// where valid items would be incorrectly flagged as "manually removed".
|
||||
// This workaround can be removed if Jellyfin improves collection sync consistency.
|
||||
// Jellyfin/Emby workaround: Skip removal check when children array is empty.
|
||||
// Unlike Plex, the .NET BoxSet collection API can return empty children
|
||||
// during brief sync delays after collection modifications, causing false
|
||||
// positives where valid items would be incorrectly flagged as "manually
|
||||
// removed". This workaround can be removed if the upstream improves
|
||||
// collection sync consistency.
|
||||
const isJellyfin =
|
||||
this.settings.media_server_type === MediaServerType.JELLYFIN;
|
||||
const shouldCheckRemovals = isJellyfin
|
||||
? children && children.length > 0
|
||||
: true;
|
||||
const isEmby = this.settings.media_server_type === MediaServerType.EMBY;
|
||||
const shouldCheckRemovals =
|
||||
isJellyfin || isEmby ? children && children.length > 0 : true;
|
||||
|
||||
if (
|
||||
collectionMedia &&
|
||||
|
||||
@@ -70,6 +70,19 @@ export class Settings implements SettingDto {
|
||||
@Column({ nullable: true })
|
||||
jellyfin_server_name?: string;
|
||||
|
||||
// Emby settings
|
||||
@Column({ nullable: true })
|
||||
emby_url?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
emby_api_key?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
emby_user_id?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
emby_server_name?: string;
|
||||
|
||||
// Seerr integration
|
||||
@Column({ nullable: true })
|
||||
seerr_api_key: string;
|
||||
|
||||
@@ -351,6 +351,80 @@ describe('MediaServerSwitchService', () => {
|
||||
jellyfin_server_name: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
from: MediaServerType.EMBY,
|
||||
to: MediaServerType.PLEX,
|
||||
existingSettings: {
|
||||
media_server_type: MediaServerType.EMBY,
|
||||
emby_url: 'http://emby.local:8096',
|
||||
emby_api_key: 'emby-key',
|
||||
emby_user_id: 'emby-user',
|
||||
emby_server_name: 'Emby',
|
||||
},
|
||||
clearedFields: {
|
||||
media_server_type: MediaServerType.PLEX,
|
||||
emby_url: null,
|
||||
emby_api_key: null,
|
||||
emby_user_id: null,
|
||||
emby_server_name: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
from: MediaServerType.EMBY,
|
||||
to: MediaServerType.JELLYFIN,
|
||||
existingSettings: {
|
||||
media_server_type: MediaServerType.EMBY,
|
||||
emby_url: 'http://emby.local:8096',
|
||||
emby_api_key: 'emby-key',
|
||||
emby_user_id: 'emby-user',
|
||||
emby_server_name: 'Emby',
|
||||
},
|
||||
clearedFields: {
|
||||
media_server_type: MediaServerType.JELLYFIN,
|
||||
emby_url: null,
|
||||
emby_api_key: null,
|
||||
emby_user_id: null,
|
||||
emby_server_name: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
from: MediaServerType.PLEX,
|
||||
to: MediaServerType.EMBY,
|
||||
existingSettings: {
|
||||
media_server_type: MediaServerType.PLEX,
|
||||
plex_name: 'My Plex',
|
||||
plex_hostname: 'plex.local',
|
||||
plex_port: 32400,
|
||||
plex_ssl: 1,
|
||||
plex_auth_token: 'plex-token',
|
||||
},
|
||||
clearedFields: {
|
||||
media_server_type: MediaServerType.EMBY,
|
||||
plex_name: null,
|
||||
plex_hostname: null,
|
||||
plex_port: null,
|
||||
plex_ssl: null,
|
||||
plex_auth_token: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
from: MediaServerType.JELLYFIN,
|
||||
to: MediaServerType.EMBY,
|
||||
existingSettings: {
|
||||
media_server_type: MediaServerType.JELLYFIN,
|
||||
jellyfin_url: 'http://jf.local:8096',
|
||||
jellyfin_api_key: 'jf-key',
|
||||
jellyfin_user_id: 'jf-user',
|
||||
jellyfin_server_name: 'Jellyfin',
|
||||
},
|
||||
clearedFields: {
|
||||
media_server_type: MediaServerType.EMBY,
|
||||
jellyfin_url: null,
|
||||
jellyfin_api_key: null,
|
||||
jellyfin_user_id: null,
|
||||
jellyfin_server_name: null,
|
||||
},
|
||||
},
|
||||
])(
|
||||
'should clear old $from credentials when switching from $from to $to',
|
||||
async ({ from, to, existingSettings, clearedFields }) => {
|
||||
|
||||
@@ -408,6 +408,11 @@ export class MediaServerSwitchService {
|
||||
updatedSettings.jellyfin_api_key = null;
|
||||
updatedSettings.jellyfin_user_id = null;
|
||||
updatedSettings.jellyfin_server_name = null;
|
||||
} else if (currentServerType === MediaServerType.EMBY) {
|
||||
updatedSettings.emby_url = null;
|
||||
updatedSettings.emby_api_key = null;
|
||||
updatedSettings.emby_user_id = null;
|
||||
updatedSettings.emby_server_name = null;
|
||||
}
|
||||
|
||||
await queryRunner.manager.save(Settings, updatedSettings);
|
||||
|
||||
@@ -810,5 +810,230 @@ describe('RuleMigrationService', () => {
|
||||
),
|
||||
).rejects.toThrow(/Unknown media server type/);
|
||||
});
|
||||
|
||||
it('should resolve EMBY to Application.EMBY when migrating', async () => {
|
||||
const originalRule: Partial<Rules> = {
|
||||
id: 1,
|
||||
ruleGroupId: 1,
|
||||
ruleJson: JSON.stringify({
|
||||
operator: null,
|
||||
action: RulePossibility.BIGGER,
|
||||
firstVal: [Application.PLEX, 0], // addDate
|
||||
customVal: { ruleTypeId: 1, value: '30' },
|
||||
section: 0,
|
||||
}),
|
||||
ruleGroup: { id: 1, name: 'Date Group' } as RuleGroup,
|
||||
};
|
||||
|
||||
rulesRepo.find.mockResolvedValue([originalRule as Rules]);
|
||||
rulesRepo.update.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
await service.migrateRules(
|
||||
MediaServerType.PLEX,
|
||||
MediaServerType.EMBY,
|
||||
true,
|
||||
);
|
||||
|
||||
const updatedJson = JSON.parse(
|
||||
rulesRepo.update.mock.calls[0][1].ruleJson as string,
|
||||
);
|
||||
expect(updatedJson.firstVal[0]).toBe(Application.EMBY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Emby migrations', () => {
|
||||
it('migrates Plex addDate rules to Emby', async () => {
|
||||
const mockRules: Partial<Rules>[] = [
|
||||
{
|
||||
id: 1,
|
||||
ruleGroupId: 1,
|
||||
ruleJson: JSON.stringify({
|
||||
operator: null,
|
||||
action: RulePossibility.BIGGER,
|
||||
firstVal: [Application.PLEX, 0],
|
||||
customVal: { ruleTypeId: 1, value: '30' },
|
||||
section: 0,
|
||||
}),
|
||||
ruleGroup: { id: 1, name: 'Date Group' } as RuleGroup,
|
||||
},
|
||||
];
|
||||
|
||||
rulesRepo.find.mockResolvedValue(mockRules as Rules[]);
|
||||
rulesRepo.update.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
const result = await service.migrateRules(
|
||||
MediaServerType.PLEX,
|
||||
MediaServerType.EMBY,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.migratedRules).toBe(1);
|
||||
expect(result.skippedRules).toBe(0);
|
||||
|
||||
const updatedJson = JSON.parse(
|
||||
rulesRepo.update.mock.calls[0][1].ruleJson as string,
|
||||
);
|
||||
expect(updatedJson.firstVal[0]).toBe(Application.EMBY);
|
||||
expect(updatedJson.firstVal[1]).toBe(0);
|
||||
});
|
||||
|
||||
it('migrates Emby rules back to Plex', async () => {
|
||||
const mockRules: Partial<Rules>[] = [
|
||||
{
|
||||
id: 1,
|
||||
ruleGroupId: 1,
|
||||
ruleJson: JSON.stringify({
|
||||
operator: null,
|
||||
action: RulePossibility.BIGGER,
|
||||
firstVal: [Application.EMBY, 0],
|
||||
customVal: { ruleTypeId: 1, value: '30' },
|
||||
section: 0,
|
||||
}),
|
||||
ruleGroup: { id: 1, name: 'Date Group' } as RuleGroup,
|
||||
},
|
||||
];
|
||||
|
||||
rulesRepo.find.mockResolvedValue(mockRules as Rules[]);
|
||||
rulesRepo.update.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
const result = await service.migrateRules(
|
||||
MediaServerType.EMBY,
|
||||
MediaServerType.PLEX,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.migratedRules).toBe(1);
|
||||
|
||||
const updatedJson = JSON.parse(
|
||||
rulesRepo.update.mock.calls[0][1].ruleJson as string,
|
||||
);
|
||||
expect(updatedJson.firstVal[0]).toBe(Application.PLEX);
|
||||
expect(updatedJson.firstVal[1]).toBe(0);
|
||||
});
|
||||
|
||||
it('migrates Plex watchlist rules into Emby as incompatible (skip + delete)', async () => {
|
||||
const mockRules: Partial<Rules>[] = [
|
||||
{
|
||||
id: 1,
|
||||
ruleGroupId: 1,
|
||||
ruleJson: JSON.stringify({
|
||||
operator: null,
|
||||
action: RulePossibility.EQUALS,
|
||||
firstVal: [Application.PLEX, 30], // watchlist_isWatchlisted
|
||||
customVal: { ruleTypeId: 3, value: 'true' },
|
||||
section: 0,
|
||||
}),
|
||||
ruleGroup: { id: 1, name: 'Watchlist Group' } as RuleGroup,
|
||||
},
|
||||
];
|
||||
|
||||
rulesRepo.find.mockResolvedValue(mockRules as Rules[]);
|
||||
rulesRepo.delete.mockResolvedValue({ affected: 1 } as any);
|
||||
ruleGroupRepo.delete.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
const result = await service.migrateRules(
|
||||
MediaServerType.PLEX,
|
||||
MediaServerType.EMBY,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.skippedRules).toBe(1);
|
||||
expect(result.skippedDetails[0].propertyName).toBe(
|
||||
'watchlist_isWatchlisted',
|
||||
);
|
||||
expect(rulesRepo.delete).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('migrates Jellyfin rules to Emby as a no-op property remap (shared props)', async () => {
|
||||
// Emby and Jellyfin share the same props[] array reference in
|
||||
// RuleConstants, so every property ID stays identical.
|
||||
const mockRules: Partial<Rules>[] = [
|
||||
{
|
||||
id: 1,
|
||||
ruleGroupId: 1,
|
||||
ruleJson: JSON.stringify({
|
||||
operator: null,
|
||||
action: RulePossibility.BIGGER,
|
||||
firstVal: [Application.JELLYFIN, 44], // rating_imdb in Jellyfin
|
||||
customVal: { ruleTypeId: 0, value: '7' },
|
||||
section: 0,
|
||||
}),
|
||||
ruleGroup: { id: 1, name: 'IMDb Group' } as RuleGroup,
|
||||
},
|
||||
];
|
||||
|
||||
rulesRepo.find.mockResolvedValue(mockRules as Rules[]);
|
||||
rulesRepo.update.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
const result = await service.migrateRules(
|
||||
MediaServerType.JELLYFIN,
|
||||
MediaServerType.EMBY,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.migratedRules).toBe(1);
|
||||
expect(result.skippedRules).toBe(0);
|
||||
|
||||
const updatedJson = JSON.parse(
|
||||
rulesRepo.update.mock.calls[0][1].ruleJson as string,
|
||||
);
|
||||
expect(updatedJson.firstVal[0]).toBe(Application.EMBY);
|
||||
// Property ID is unchanged because Emby and Jellyfin share props[].
|
||||
expect(updatedJson.firstVal[1]).toBe(44);
|
||||
});
|
||||
|
||||
it('migrates Emby rules to Jellyfin as a no-op property remap (shared props)', async () => {
|
||||
const mockRules: Partial<Rules>[] = [
|
||||
{
|
||||
id: 1,
|
||||
ruleGroupId: 1,
|
||||
ruleJson: JSON.stringify({
|
||||
operator: null,
|
||||
action: RulePossibility.CONTAINS,
|
||||
firstVal: [Application.EMBY, 6], // collections
|
||||
customVal: { ruleTypeId: 4, value: 'Movies I love' },
|
||||
section: 0,
|
||||
}),
|
||||
ruleGroup: { id: 1, name: 'Collections Group' } as RuleGroup,
|
||||
},
|
||||
];
|
||||
|
||||
rulesRepo.find.mockResolvedValue(mockRules as Rules[]);
|
||||
rulesRepo.update.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
const result = await service.migrateRules(
|
||||
MediaServerType.EMBY,
|
||||
MediaServerType.JELLYFIN,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.migratedRules).toBe(1);
|
||||
|
||||
const updatedJson = JSON.parse(
|
||||
rulesRepo.update.mock.calls[0][1].ruleJson as string,
|
||||
);
|
||||
expect(updatedJson.firstVal[0]).toBe(Application.JELLYFIN);
|
||||
expect(updatedJson.firstVal[1]).toBe(6);
|
||||
});
|
||||
|
||||
it('detects EMBY source apps when importing community rule DTOs', () => {
|
||||
const rules: RuleDto[] = [
|
||||
{
|
||||
operator: RuleOperators.AND,
|
||||
action: RulePossibility.BIGGER,
|
||||
firstVal: [Application.EMBY, 0],
|
||||
customVal: { ruleTypeId: 1, value: '30' },
|
||||
section: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const result = service.migrateImportedRuleDtos(
|
||||
rules,
|
||||
MediaServerType.PLEX,
|
||||
);
|
||||
|
||||
expect(result.migratedRules).toBe(1);
|
||||
expect(result.rules[0].firstVal?.[0]).toBe(Application.PLEX);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -389,6 +389,8 @@ export class RuleMigrationService {
|
||||
return Application.PLEX;
|
||||
case MediaServerType.JELLYFIN:
|
||||
return Application.JELLYFIN;
|
||||
case MediaServerType.EMBY:
|
||||
return Application.EMBY;
|
||||
default:
|
||||
throw new Error(`Unknown media server type: ${serverType}`);
|
||||
}
|
||||
@@ -453,11 +455,15 @@ export class RuleMigrationService {
|
||||
|
||||
private detectRuleSourceApp(
|
||||
rule: RuleDto,
|
||||
): Application.PLEX | Application.JELLYFIN | undefined {
|
||||
): Application.PLEX | Application.JELLYFIN | Application.EMBY | undefined {
|
||||
const firstApp = rule.firstVal?.[0];
|
||||
const lastApp = rule.lastVal?.[0];
|
||||
|
||||
if (firstApp !== Application.PLEX && firstApp !== Application.JELLYFIN) {
|
||||
if (
|
||||
firstApp !== Application.PLEX &&
|
||||
firstApp !== Application.JELLYFIN &&
|
||||
firstApp !== Application.EMBY
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { radarrSettingSchema } from '@maintainerr/contracts';
|
||||
import {
|
||||
embyLoginRequestSchema,
|
||||
radarrSettingSchema,
|
||||
} from '@maintainerr/contracts';
|
||||
import { StreamableFile } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { createReadStream } from 'fs';
|
||||
@@ -220,6 +223,25 @@ describe('SettingsController', () => {
|
||||
).toThrow('Validation failed');
|
||||
});
|
||||
|
||||
it('rejects invalid Emby login requests with the shared Zod schema', () => {
|
||||
const pipe = new ZodValidationPipe(embyLoginRequestSchema);
|
||||
|
||||
expect(() =>
|
||||
pipe.transform(
|
||||
{
|
||||
emby_url: 'emby.local',
|
||||
username: 'admin',
|
||||
password: 'secret',
|
||||
},
|
||||
{
|
||||
type: 'body',
|
||||
metatype: Object,
|
||||
data: '',
|
||||
},
|
||||
),
|
||||
).toThrow('Validation failed');
|
||||
});
|
||||
|
||||
it('delegates Plex connectivity testing to the settings service', async () => {
|
||||
settingsService.testPlex.mockResolvedValue({
|
||||
status: 'OK',
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
BasicResponseDto,
|
||||
EmbyLoginRequest,
|
||||
embyLoginRequestSchema,
|
||||
EmbySetting,
|
||||
embySettingSchema,
|
||||
JellyfinSetting,
|
||||
jellyfinSettingSchema,
|
||||
MediaServerSwitchPreview,
|
||||
@@ -19,11 +23,11 @@ import {
|
||||
TautulliSetting,
|
||||
tautulliSettingSchema,
|
||||
TmdbSetting,
|
||||
tmdbSettingSchema,
|
||||
TmdbSettingForm,
|
||||
tmdbSettingSchema,
|
||||
TvdbSetting,
|
||||
tvdbSettingSchema,
|
||||
TvdbSettingForm,
|
||||
tvdbSettingSchema,
|
||||
} from '@maintainerr/contracts';
|
||||
import {
|
||||
Body,
|
||||
@@ -40,16 +44,16 @@ import {
|
||||
Res,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { ZodValidationPipe } from 'nestjs-zod';
|
||||
import { DatabaseDownloadService } from './database-download.service';
|
||||
import { CronScheduleDto } from "./dto's/cron.schedule.dto";
|
||||
import { SettingDto } from "./dto's/setting.dto";
|
||||
import { UpdateSettingDto } from "./dto's/update-setting.dto";
|
||||
import { Settings } from './entities/settings.entities';
|
||||
import { MetadataProvider } from './metadata-provider';
|
||||
import { MediaServerSwitchService } from './media-server-switch.service';
|
||||
import { MetadataProvider } from './metadata-provider';
|
||||
import { MetadataSettingsService } from './metadata-settings.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@@ -392,6 +396,62 @@ export class SettingsController {
|
||||
return await this.settingsService.removeJellyfinSettings();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Emby
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@Get('/emby')
|
||||
async getEmbySetting(): Promise<EmbySetting | BasicResponseDto> {
|
||||
const settings = await this.settingsService.getSettings();
|
||||
|
||||
if (!(settings instanceof Settings)) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
return {
|
||||
emby_url: settings.emby_url,
|
||||
emby_api_key: settings.emby_api_key,
|
||||
emby_user_id: settings.emby_user_id,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/emby/test')
|
||||
testEmby(
|
||||
@Body(new ZodValidationPipe(embySettingSchema))
|
||||
payload: EmbySetting,
|
||||
): Promise<BasicResponseDto> {
|
||||
return this.settingsService.testEmby(payload);
|
||||
}
|
||||
|
||||
@Post('/emby')
|
||||
async saveEmbySettings(
|
||||
@Body(new ZodValidationPipe(embySettingSchema))
|
||||
payload: EmbySetting,
|
||||
): Promise<BasicResponseDto> {
|
||||
return await this.settingsService.saveEmbySettings(payload);
|
||||
}
|
||||
|
||||
@Delete('/emby')
|
||||
async removeEmbySettings(): Promise<BasicResponseDto> {
|
||||
return await this.settingsService.removeEmbySettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate against an Emby server with username/password (Plex-style
|
||||
* login UX). Returns library/user lists for confirmation before save.
|
||||
*/
|
||||
@Post('/emby/login')
|
||||
async loginEmby(
|
||||
@Body(new ZodValidationPipe(embyLoginRequestSchema))
|
||||
payload: EmbyLoginRequest,
|
||||
) {
|
||||
return this.settingsService.loginEmby(
|
||||
payload.emby_url,
|
||||
payload.username,
|
||||
payload.password,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('/sonarr/:id')
|
||||
async deleteSonarrSetting(@Param('id', new ParseIntPipe()) id: number) {
|
||||
return await this.settingsService.deleteSonarrSetting(id);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BasicResponseDto,
|
||||
EmbySetting,
|
||||
JellyfinSetting,
|
||||
MaintainerrEvent,
|
||||
MediaServerType,
|
||||
@@ -90,6 +91,14 @@ export class SettingsService implements SettingDto {
|
||||
|
||||
jellyfin_server_name?: string;
|
||||
|
||||
emby_url?: string;
|
||||
|
||||
emby_api_key?: string;
|
||||
|
||||
emby_user_id?: string;
|
||||
|
||||
emby_server_name?: string;
|
||||
|
||||
// Seerr settings
|
||||
seerr_url: string;
|
||||
|
||||
@@ -157,6 +166,10 @@ export class SettingsService implements SettingDto {
|
||||
this.jellyfin_api_key = settingsDb?.jellyfin_api_key;
|
||||
this.jellyfin_user_id = settingsDb?.jellyfin_user_id;
|
||||
this.jellyfin_server_name = settingsDb?.jellyfin_server_name;
|
||||
this.emby_url = settingsDb?.emby_url;
|
||||
this.emby_api_key = settingsDb?.emby_api_key;
|
||||
this.emby_user_id = settingsDb?.emby_user_id;
|
||||
this.emby_server_name = settingsDb?.emby_server_name;
|
||||
this.seerr_url = settingsDb?.seerr_url;
|
||||
this.seerr_api_key = settingsDb?.seerr_api_key;
|
||||
this.tmdb_api_key = settingsDb?.tmdb_api_key;
|
||||
@@ -183,6 +196,15 @@ export class SettingsService implements SettingDto {
|
||||
{ id: this.id },
|
||||
{ media_server_type: MediaServerType.JELLYFIN },
|
||||
);
|
||||
} else if (this.emby_api_key) {
|
||||
this.logger.log(
|
||||
'Detected existing Emby configuration without media_server_type set. Setting to emby.',
|
||||
);
|
||||
this.media_server_type = MediaServerType.EMBY;
|
||||
await this.settingsRepo.update(
|
||||
{ id: this.id },
|
||||
{ media_server_type: MediaServerType.EMBY },
|
||||
);
|
||||
} else if (this.plex_auth_token) {
|
||||
this.logger.log(
|
||||
'Detected existing Plex configuration without media_server_type set. Setting to plex.',
|
||||
@@ -258,6 +280,7 @@ export class SettingsService implements SettingDto {
|
||||
...settings,
|
||||
plex_auth_token: maskSecret(settings.plex_auth_token),
|
||||
jellyfin_api_key: maskSecret(settings.jellyfin_api_key),
|
||||
emby_api_key: maskSecret(settings.emby_api_key),
|
||||
seerr_api_key: maskSecret(settings.seerr_api_key),
|
||||
tmdb_api_key: maskSecret(settings.tmdb_api_key),
|
||||
tvdb_api_key: maskSecret(settings.tvdb_api_key),
|
||||
@@ -703,6 +726,201 @@ export class SettingsService implements SettingDto {
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Emby
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Test connection to an Emby server using the API-key flow.
|
||||
*/
|
||||
public async testEmby(settings: EmbySetting): Promise<
|
||||
BasicResponseDto & {
|
||||
serverName?: string;
|
||||
version?: string;
|
||||
users?: Array<{ id: string; name: string }>;
|
||||
}
|
||||
> {
|
||||
try {
|
||||
const result = await this.mediaServerFactory.testEmbyConnection(
|
||||
settings.emby_url,
|
||||
settings.emby_api_key,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
status: 'OK',
|
||||
code: 1,
|
||||
message: `Connected to ${result.serverName}`,
|
||||
serverName: result.serverName,
|
||||
version: result.version,
|
||||
users: result.users,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'NOK',
|
||||
code: 0,
|
||||
message: formatConnectionFailureMessage(
|
||||
result.error,
|
||||
'Failed to connect to Emby. Verify URL and API key.',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
logConnectionTestError(this.logger, 'Emby');
|
||||
return {
|
||||
status: 'NOK',
|
||||
code: 0,
|
||||
message: formatConnectionFailureMessage(
|
||||
error,
|
||||
'Failed to connect to Emby. Verify URL and API key.',
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate against Emby with admin username/password and return the
|
||||
* library/user lists for the post-login confirmation step (Plex-style UX).
|
||||
*/
|
||||
public async loginEmby(
|
||||
url: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<
|
||||
BasicResponseDto & {
|
||||
token?: string;
|
||||
userId?: string;
|
||||
serverName?: string;
|
||||
users?: Array<{ id: string; name: string }>;
|
||||
libraries?: Array<{ id: string; name: string; type: string }>;
|
||||
}
|
||||
> {
|
||||
try {
|
||||
const result = await this.mediaServerFactory.loginEmbyWithCredentials(
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
);
|
||||
if (result.success) {
|
||||
return {
|
||||
status: 'OK',
|
||||
code: 1,
|
||||
message: `Authenticated against ${result.serverName ?? url}`,
|
||||
token: result.token,
|
||||
userId: result.userId,
|
||||
serverName: result.serverName,
|
||||
users: result.users,
|
||||
libraries: result.libraries,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'NOK',
|
||||
code: 0,
|
||||
message: result.error || 'Emby authentication failed',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'NOK',
|
||||
code: 0,
|
||||
message: formatConnectionFailureMessage(
|
||||
error,
|
||||
'Failed to authenticate with Emby. Verify URL and credentials.',
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Emby settings and initialize the service.
|
||||
*/
|
||||
public async saveEmbySettings(
|
||||
settings: EmbySetting,
|
||||
): Promise<BasicResponseDto> {
|
||||
try {
|
||||
const settingsDb = await this.settingsRepo.findOne({ where: {} });
|
||||
|
||||
const testResult = await this.testEmby(settings);
|
||||
if (testResult.code !== 1) {
|
||||
return {
|
||||
status: 'NOK',
|
||||
code: 0,
|
||||
message: testResult.message || 'Connection test failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate selected user is an admin when provided
|
||||
const userId = settings.emby_user_id;
|
||||
if (userId && testResult.users && testResult.users.length > 0) {
|
||||
const selectedUser = testResult.users.find((u) => u.id === userId);
|
||||
if (!selectedUser) {
|
||||
return {
|
||||
status: 'NOK',
|
||||
code: 0,
|
||||
message:
|
||||
'Selected Emby user must be an admin. Re-test the connection and pick a valid admin.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveSettings({
|
||||
...settingsDb,
|
||||
emby_url: settings.emby_url,
|
||||
emby_api_key: settings.emby_api_key,
|
||||
emby_user_id: userId || null,
|
||||
emby_server_name: testResult.serverName || null,
|
||||
media_server_type: MediaServerType.EMBY,
|
||||
});
|
||||
|
||||
this.mediaServerFactory.uninitializeServer(MediaServerType.EMBY);
|
||||
|
||||
this.emby_url = settings.emby_url;
|
||||
this.emby_api_key = settings.emby_api_key;
|
||||
this.emby_user_id = userId;
|
||||
this.emby_server_name = testResult.serverName;
|
||||
this.media_server_type = MediaServerType.EMBY;
|
||||
|
||||
this.logger.log('Emby settings saved successfully');
|
||||
return { status: 'OK', code: 1, message: 'Success' };
|
||||
} catch (error) {
|
||||
this.logger.error('Error while saving Emby settings');
|
||||
this.logger.debug(error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to save settings';
|
||||
return { status: 'NOK', code: 0, message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Emby settings.
|
||||
*/
|
||||
public async removeEmbySettings(): Promise<BasicResponseDto> {
|
||||
try {
|
||||
const settingsDb = await this.settingsRepo.findOne({ where: {} });
|
||||
|
||||
await this.saveSettings({
|
||||
...settingsDb,
|
||||
emby_url: null,
|
||||
emby_api_key: null,
|
||||
emby_user_id: null,
|
||||
emby_server_name: null,
|
||||
});
|
||||
|
||||
this.mediaServerFactory.uninitializeServer(MediaServerType.EMBY);
|
||||
|
||||
this.emby_url = undefined;
|
||||
this.emby_api_key = undefined;
|
||||
this.emby_user_id = undefined;
|
||||
this.emby_server_name = undefined;
|
||||
|
||||
this.logger.log('Emby settings cleared');
|
||||
return { status: 'OK', code: 1, message: 'Success' };
|
||||
} catch (error) {
|
||||
this.logger.error('Error removing Emby settings');
|
||||
this.logger.debug(error);
|
||||
return { status: 'NOK', code: 0, message: 'Failed' };
|
||||
}
|
||||
}
|
||||
|
||||
public async addSonarrSetting(
|
||||
settings: Omit<SonarrSettings, 'id' | 'collections'>,
|
||||
): Promise<SonarrSettingResponseDto> {
|
||||
@@ -1243,6 +1461,20 @@ export class SettingsService implements SettingDto {
|
||||
).status === 'OK'
|
||||
);
|
||||
}
|
||||
case MediaServerType.EMBY: {
|
||||
if (!this.emby_url || !this.emby_api_key) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(
|
||||
await this.testEmby({
|
||||
emby_url: this.emby_url,
|
||||
emby_api_key: this.emby_api_key,
|
||||
emby_user_id: this.emby_user_id,
|
||||
})
|
||||
).status === 'OK'
|
||||
);
|
||||
}
|
||||
case MediaServerType.PLEX:
|
||||
return (await this.testPlex()).status === 'OK';
|
||||
default:
|
||||
@@ -1348,6 +1580,11 @@ export class SettingsService implements SettingDto {
|
||||
if (this.jellyfin_url && this.jellyfin_api_key) {
|
||||
return true;
|
||||
}
|
||||
} else if (this.media_server_type === MediaServerType.EMBY) {
|
||||
// Emby requires URL and API key (user ID is optional, can be auto-detected later)
|
||||
if (this.emby_url && this.emby_api_key) {
|
||||
return true;
|
||||
}
|
||||
} else if (this.media_server_type === MediaServerType.PLEX) {
|
||||
// Plex requires hostname, name, port, and auth token
|
||||
if (
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,15 @@
|
||||
emby.png
|
||||
|
||||
Source: https://commons.wikimedia.org/wiki/File:Emby-logo.png
|
||||
Author: sahebk (Emby community contributor, 2015)
|
||||
License: Creative Commons Attribution-Share Alike 4.0 International (CC BY-SA 4.0)
|
||||
URL: https://creativecommons.org/licenses/by-sa/4.0/
|
||||
|
||||
This file is a derivative of the Wikimedia Commons source above: the
|
||||
original 1238×377 wordmark has been cropped to the leftmost 377×377 region
|
||||
to isolate the icon mark for use in square UI slots. Per CC BY-SA 4.0,
|
||||
this derivative is distributed under the same license.
|
||||
|
||||
Note: "Emby" and the Emby logo are trademarks of Emby LLC. This icon is
|
||||
used to identify the Emby integration in the Maintainerr UI; nothing in
|
||||
this file implies endorsement by Emby LLC.
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BasicResponseDto,
|
||||
EmbySetting,
|
||||
JellyfinSetting,
|
||||
MediaServerSwitchPreview,
|
||||
MediaServerType,
|
||||
@@ -48,6 +49,11 @@ export interface ISettings {
|
||||
jellyfin_api_key?: string
|
||||
jellyfin_user_id?: string
|
||||
jellyfin_server_name?: string
|
||||
// Emby settings
|
||||
emby_url?: string
|
||||
emby_api_key?: string
|
||||
emby_user_id?: string
|
||||
emby_server_name?: string
|
||||
// Seerr integration
|
||||
seerr_api_key: string
|
||||
tautulli_url: string
|
||||
@@ -70,6 +76,20 @@ export interface JellyfinTestResult {
|
||||
}>
|
||||
}
|
||||
|
||||
// Emby shares the Jellyfin test-result shape; aliased for call-site clarity.
|
||||
export type EmbyTestResult = JellyfinTestResult
|
||||
|
||||
// Login response shape for the Plex-style Emby credentials login flow.
|
||||
export interface EmbyLoginResult extends JellyfinTestResult {
|
||||
token?: string
|
||||
userId?: string
|
||||
libraries?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}>
|
||||
}
|
||||
|
||||
type UseSettingsQueryKey = ['settings']
|
||||
|
||||
type UseSettingsOptions = Omit<
|
||||
@@ -421,6 +441,131 @@ export type UseDeleteJellyfinSettingsResult = ReturnType<
|
||||
typeof useDeleteJellyfinSettings
|
||||
>
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Emby
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type UseEmbySettingsQueryKey = ['settings', 'emby']
|
||||
|
||||
type UseEmbySettingsOptions = Omit<
|
||||
UseQueryOptions<EmbySetting, Error, EmbySetting, UseEmbySettingsQueryKey>,
|
||||
'queryKey' | 'queryFn'
|
||||
>
|
||||
|
||||
export const useEmbySettings = (options?: UseEmbySettingsOptions) => {
|
||||
return useQuery<EmbySetting, Error, EmbySetting, UseEmbySettingsQueryKey>({
|
||||
queryKey: ['settings', 'emby'],
|
||||
queryFn: async () => {
|
||||
return await GetApiHandler<EmbySetting>(`/settings/emby`)
|
||||
},
|
||||
staleTime: 0,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseEmbySettingsResult = ReturnType<typeof useEmbySettings>
|
||||
|
||||
type UseTestEmbyOptions = Omit<
|
||||
UseMutationOptions<EmbyTestResult, Error, EmbySetting>,
|
||||
'mutationFn' | 'mutationKey'
|
||||
>
|
||||
|
||||
export const useTestEmby = (options?: UseTestEmbyOptions) => {
|
||||
return useMutation<EmbyTestResult, Error, EmbySetting>({
|
||||
mutationKey: ['settings', 'testEmby'],
|
||||
mutationFn: async (payload) => {
|
||||
return await PostApiHandler<EmbyTestResult>(
|
||||
'/settings/emby/test',
|
||||
payload,
|
||||
)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseTestEmbyResult = ReturnType<typeof useTestEmby>
|
||||
|
||||
type UseSaveEmbySettingsOptions = Omit<
|
||||
UseMutationOptions<BasicResponseDto, Error, EmbySetting>,
|
||||
'mutationFn' | 'mutationKey' | 'onSuccess'
|
||||
>
|
||||
|
||||
export const useSaveEmbySettings = (options?: UseSaveEmbySettingsOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<BasicResponseDto, Error, EmbySetting>({
|
||||
mutationKey: ['settings', 'saveEmby'],
|
||||
mutationFn: async (payload) => {
|
||||
return await PostApiHandler<BasicResponseDto>('/settings/emby', payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['settings'] satisfies UseSettingsQueryKey,
|
||||
})
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseSaveEmbySettingsResult = ReturnType<typeof useSaveEmbySettings>
|
||||
|
||||
type UseDeleteEmbySettingsOptions = Omit<
|
||||
UseMutationOptions<BasicResponseDto, Error, void>,
|
||||
'mutationFn' | 'mutationKey' | 'onSuccess'
|
||||
>
|
||||
|
||||
export const useDeleteEmbySettings = (
|
||||
options?: UseDeleteEmbySettingsOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<BasicResponseDto, Error, void>({
|
||||
mutationKey: ['settings', 'deleteEmby'],
|
||||
mutationFn: async () => {
|
||||
return await DeleteApiHandler<BasicResponseDto>('/settings/emby')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['settings'] satisfies UseSettingsQueryKey,
|
||||
})
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseDeleteEmbySettingsResult = ReturnType<
|
||||
typeof useDeleteEmbySettings
|
||||
>
|
||||
|
||||
// Login (Plex-style): URL + admin username/password → access token + lists
|
||||
type UseLoginEmbyOptions = Omit<
|
||||
UseMutationOptions<
|
||||
EmbyLoginResult,
|
||||
Error,
|
||||
{ emby_url: string; username: string; password: string }
|
||||
>,
|
||||
'mutationFn' | 'mutationKey'
|
||||
>
|
||||
|
||||
export const useLoginEmby = (options?: UseLoginEmbyOptions) => {
|
||||
return useMutation<
|
||||
EmbyLoginResult,
|
||||
Error,
|
||||
{ emby_url: string; username: string; password: string }
|
||||
>({
|
||||
mutationKey: ['settings', 'loginEmby'],
|
||||
mutationFn: async (payload) => {
|
||||
return await PostApiHandler<EmbyLoginResult>(
|
||||
'/settings/emby/login',
|
||||
payload,
|
||||
)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseLoginEmbyResult = ReturnType<typeof useLoginEmby>
|
||||
|
||||
type UsePreviewMediaServerSwitchOptions = Omit<
|
||||
UseMutationOptions<MediaServerSwitchPreview, Error, MediaServerType>,
|
||||
'mutationFn' | 'mutationKey'
|
||||
|
||||
@@ -150,7 +150,7 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
|
||||
}) => {
|
||||
useLockBodyScroll(true)
|
||||
|
||||
const { isPlex, isJellyfin } = useMediaServerType()
|
||||
const { isPlex, isJellyfin, isEmby } = useMediaServerType()
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [backdropResult, setBackdropResult] =
|
||||
useState<BackdropResult>(emptyBackdropResult)
|
||||
@@ -533,6 +533,27 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{isEmby && serverUrl && (
|
||||
<div>
|
||||
<a
|
||||
href={
|
||||
machineId
|
||||
? `${serverUrl}/web/index.html#!/item?id=${id}&serverId=${machineId}`
|
||||
: `${serverUrl}/web/index.html#!/item?id=${id}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img
|
||||
src={`${basePath}/icons_logos/emby.png`}
|
||||
alt="Emby Logo"
|
||||
width={128}
|
||||
height={32}
|
||||
className="mt-1 h-8 w-32 rounded-lg bg-black bg-opacity-70 p-1 shadow-lg"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{isJellyfin && serverUrl && (
|
||||
<div>
|
||||
<a
|
||||
|
||||
@@ -21,6 +21,10 @@ export const getMediaServerSetupRoute = (
|
||||
return '/settings/jellyfin'
|
||||
}
|
||||
|
||||
if (mediaServerType === MediaServerType.EMBY) {
|
||||
return '/settings/emby'
|
||||
}
|
||||
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
return '/settings/plex'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { LoginIcon } from '@heroicons/react/outline'
|
||||
import { useState } from 'react'
|
||||
import { type EmbyLoginResult, useLoginEmby } from '../../../api/settings'
|
||||
import { getApiErrorMessage } from '../../../utils/ApiError'
|
||||
import Button from '../../Common/Button'
|
||||
import Modal from '../../Common/Modal'
|
||||
import { InputGroup } from '../../Forms/Input'
|
||||
|
||||
export interface EmbyLoginButtonProps {
|
||||
/**
|
||||
* Resolved Emby base URL the credentials should authenticate against.
|
||||
* Required — the button is disabled while empty.
|
||||
*/
|
||||
embyUrl: string | undefined
|
||||
/**
|
||||
* Called on successful authentication with the access token, admin user id,
|
||||
* and the post-login server snapshot (libraries + users + serverName).
|
||||
*/
|
||||
onAuthenticated: (
|
||||
result: Required<Pick<EmbyLoginResult, 'token' | 'userId'>> &
|
||||
EmbyLoginResult,
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a small modal to collect Emby admin username/password and POST them
|
||||
* to `/api/settings/emby/login`. Mirrors the Plex Login button pattern
|
||||
* ([apps/ui/src/components/Login/Plex](apps/ui/src/components/Login/Plex)) so
|
||||
* server-specific auth UX lives outside the settings page.
|
||||
*
|
||||
* Emby's auth endpoint is `POST /Users/AuthenticateByName` — verified against
|
||||
* Emby Server 4.9 and consistent with the Jellyseerr/Seerr Jellyfin client.
|
||||
*/
|
||||
const EmbyLoginButton: React.FC<EmbyLoginButtonProps> = ({
|
||||
embyUrl,
|
||||
onAuthenticated,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { mutateAsync: login, isPending } = useLoginEmby()
|
||||
|
||||
const reset = () => {
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
reset()
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!embyUrl) {
|
||||
setError('Enter your Emby server URL first.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
const result = await login({
|
||||
emby_url: embyUrl,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
if (result.code !== 1 || !result.token || !result.userId) {
|
||||
setError(result.message || 'Authentication failed')
|
||||
return
|
||||
}
|
||||
onAuthenticated({
|
||||
...result,
|
||||
token: result.token,
|
||||
userId: result.userId,
|
||||
})
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, 'Authentication failed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
buttonType="primary"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={!embyUrl}
|
||||
>
|
||||
<LoginIcon className="mr-1 h-5 w-5" />
|
||||
Sign in with Emby
|
||||
</Button>
|
||||
|
||||
{open ? (
|
||||
<Modal
|
||||
onCancel={handleClose}
|
||||
cancelText="Close"
|
||||
loading={isPending}
|
||||
footerActions={
|
||||
<Button
|
||||
buttonType="primary"
|
||||
className="ml-3"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isPending || !username || !password}
|
||||
>
|
||||
{isPending ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="text-zinc-100">
|
||||
<h3 className="mb-2 text-lg font-medium">Sign in with Emby</h3>
|
||||
<p className="mb-4 text-sm text-zinc-400">
|
||||
Authenticates against{' '}
|
||||
<strong className="text-zinc-200">{embyUrl}</strong> with an admin
|
||||
username and password. The resulting access token is stored as the
|
||||
API key.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<InputGroup
|
||||
label="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<InputGroup
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-4 rounded bg-error-900/30 p-3 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmbyLoginButton
|
||||
@@ -72,6 +72,7 @@ const shouldFilterApplication = (
|
||||
sonarrSettingsId: number | null | undefined,
|
||||
isPlex: boolean,
|
||||
isJellyfin: boolean,
|
||||
isEmby: boolean = false,
|
||||
): boolean => {
|
||||
// Filter out Radarr if no Radarr server is selected
|
||||
if (
|
||||
@@ -87,15 +88,19 @@ const shouldFilterApplication = (
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Filter out Plex/Tautulli if on Jellyfin
|
||||
// Filter out Plex/Tautulli on non-Plex servers (Jellyfin, Emby).
|
||||
if (
|
||||
isJellyfin &&
|
||||
(isJellyfin || isEmby) &&
|
||||
(appId === Application.PLEX || appId === Application.TAUTULLI)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Filter out Jellyfin if on Plex
|
||||
if (isPlex && appId === Application.JELLYFIN) {
|
||||
// Filter out Jellyfin on Plex/Emby.
|
||||
if ((isPlex || isEmby) && appId === Application.JELLYFIN) {
|
||||
return true
|
||||
}
|
||||
// Filter out Emby on Plex/Jellyfin.
|
||||
if ((isPlex || isJellyfin) && appId === Application.EMBY) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -280,7 +285,7 @@ const RuleInput = (props: IRuleInput) => {
|
||||
)
|
||||
|
||||
const { data: constants, isLoading: constantsLoading } = useRuleConstants()
|
||||
const { isPlex, isJellyfin } = useMediaServerType()
|
||||
const { isPlex, isJellyfin, isEmby } = useMediaServerType()
|
||||
|
||||
const availableApplications = useMemo(() => {
|
||||
return (
|
||||
@@ -293,6 +298,7 @@ const RuleInput = (props: IRuleInput) => {
|
||||
props.sonarrSettingsId,
|
||||
isPlex,
|
||||
isJellyfin,
|
||||
isEmby,
|
||||
) &&
|
||||
(app.mediaType === MediaType.BOTH ||
|
||||
props.mediaType === app.mediaType),
|
||||
@@ -311,6 +317,7 @@ const RuleInput = (props: IRuleInput) => {
|
||||
)
|
||||
}, [
|
||||
constants?.applications,
|
||||
isEmby,
|
||||
isJellyfin,
|
||||
isPlex,
|
||||
props.dataType,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
type EmbySetting,
|
||||
embySettingSchema,
|
||||
maskSecret,
|
||||
} from '@maintainerr/contracts'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { useSettingsOutletContext } from '..'
|
||||
import {
|
||||
useDeleteEmbySettings,
|
||||
useEmbySettings,
|
||||
useSaveEmbySettings,
|
||||
useTestEmby,
|
||||
} from '../../../api/settings'
|
||||
import { getApiErrorMessage } from '../../../utils/ApiError'
|
||||
import { stripTrailingSlashes } from '../../../utils/SettingsUtils'
|
||||
import Alert from '../../Common/Alert'
|
||||
import DocsButton from '../../Common/DocsButton'
|
||||
import SaveButton from '../../Common/SaveButton'
|
||||
import TestingButton from '../../Common/TestingButton'
|
||||
import { InputGroup } from '../../Forms/Input'
|
||||
import { Select } from '../../Forms/Select'
|
||||
import EmbyLoginButton from '../../Login/Emby/EmbyLoginButton'
|
||||
import SettingsAlertSlot from '../SettingsAlertSlot'
|
||||
import { useSettingsFeedback } from '../useSettingsFeedback'
|
||||
|
||||
const EmbySettingDeleteSchema = z.object({
|
||||
emby_url: z.literal(''),
|
||||
emby_api_key: z.literal(''),
|
||||
emby_user_id: z.string().optional(),
|
||||
})
|
||||
|
||||
const EmbySettingFormSchema = z.union([
|
||||
embySettingSchema,
|
||||
EmbySettingDeleteSchema,
|
||||
])
|
||||
|
||||
type EmbySettingFormResult = z.infer<typeof EmbySettingFormSchema>
|
||||
|
||||
const EmbySettings = () => {
|
||||
const [testResult, setTestResult] = useState<{
|
||||
status: boolean
|
||||
message: string
|
||||
} | null>(null)
|
||||
const [testedSettings, setTestedSettings] = useState<{
|
||||
url: string
|
||||
apiKey: string
|
||||
} | null>(null)
|
||||
const [embyUsers, setEmbyUsers] = useState<
|
||||
Array<{ id: string; name: string }>
|
||||
>([])
|
||||
const { feedback, showUpdated, showUpdateError, showError, clearError } =
|
||||
useSettingsFeedback('Emby settings')
|
||||
|
||||
const { settings } = useSettingsOutletContext()
|
||||
|
||||
const { data: embyData } = useEmbySettings({ enabled: !!settings })
|
||||
const isEmbyLoading = settings != null && embyData == null
|
||||
|
||||
const { mutateAsync: testEmby, isPending: isTestPending } = useTestEmby()
|
||||
const { mutateAsync: saveSettings, isPending: isSavePending } =
|
||||
useSaveEmbySettings()
|
||||
const { mutateAsync: deleteSettings, isPending: isDeletePending } =
|
||||
useDeleteEmbySettings()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<EmbySettingFormResult, any, EmbySettingFormResult>({
|
||||
resolver: zodResolver(EmbySettingFormSchema),
|
||||
defaultValues: {
|
||||
emby_url: '',
|
||||
emby_api_key: '',
|
||||
emby_user_id: '',
|
||||
},
|
||||
})
|
||||
|
||||
const embyUrl = useWatch({ control, name: 'emby_url' })
|
||||
const embyApiKey = useWatch({ control, name: 'emby_api_key' })
|
||||
|
||||
useEffect(() => {
|
||||
if (embyData) {
|
||||
reset({
|
||||
emby_url: embyData.emby_url ?? '',
|
||||
emby_api_key: embyData.emby_api_key ?? '',
|
||||
emby_user_id: embyData.emby_user_id ?? '',
|
||||
})
|
||||
}
|
||||
}, [embyData, reset])
|
||||
|
||||
const isGoingToRemoveSettings = embyUrl === '' && embyApiKey === ''
|
||||
const enteredSettingsHaveBeenTested =
|
||||
embyUrl === testedSettings?.url &&
|
||||
embyApiKey === testedSettings?.apiKey &&
|
||||
testResult?.status
|
||||
const canSaveSettings =
|
||||
!isEmbyLoading && !isTestPending && !isSavePending && !isDeletePending
|
||||
|
||||
const clearTransientState = () => {
|
||||
clearError()
|
||||
setTestResult(null)
|
||||
setTestedSettings(null)
|
||||
setEmbyUsers([])
|
||||
}
|
||||
|
||||
const registerApiKey = register('emby_api_key', {
|
||||
onChange: () => {
|
||||
clearTransientState()
|
||||
},
|
||||
})
|
||||
|
||||
const handleTest = async () => {
|
||||
if (isTestPending || !(await trigger())) return
|
||||
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
const result = await testEmby({
|
||||
emby_url: embyUrl,
|
||||
emby_api_key: embyApiKey,
|
||||
})
|
||||
|
||||
if (result.code === 1) {
|
||||
setTestResult({
|
||||
status: true,
|
||||
message: result.serverName
|
||||
? `Connected to ${result.serverName} (v${result.version})`
|
||||
: result.message,
|
||||
})
|
||||
setTestedSettings({ url: embyUrl, apiKey: embyApiKey })
|
||||
|
||||
if (result.users && result.users.length > 0) {
|
||||
const sorted = [...result.users].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
setEmbyUsers(sorted)
|
||||
|
||||
const currentUserId = getValues('emby_user_id')
|
||||
const keepCurrentSelection =
|
||||
currentUserId && sorted.find((u) => u.id === currentUserId)
|
||||
setValue(
|
||||
'emby_user_id',
|
||||
keepCurrentSelection ? currentUserId : sorted[0].id,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setTestResult({ status: false, message: result.message })
|
||||
setTestedSettings(null)
|
||||
setEmbyUsers([])
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(
|
||||
error,
|
||||
'Failed to connect to Emby. Verify URL and API key.',
|
||||
)
|
||||
setTestResult({ status: false, message })
|
||||
setTestedSettings(null)
|
||||
setEmbyUsers([])
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: EmbySettingFormResult) => {
|
||||
clearError()
|
||||
|
||||
if (data.emby_url === '' && data.emby_api_key === '') {
|
||||
try {
|
||||
await deleteSettings()
|
||||
reset({ emby_url: '', emby_api_key: '', emby_user_id: '' })
|
||||
setTestResult(null)
|
||||
setTestedSettings(null)
|
||||
setEmbyUsers([])
|
||||
showUpdated()
|
||||
} catch {
|
||||
showUpdateError()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSettings(data as EmbySetting)
|
||||
reset(data)
|
||||
showUpdated()
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Failed to save settings')
|
||||
showError(
|
||||
message === 'Failed to save settings'
|
||||
? 'Emby settings could not be updated'
|
||||
: message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const savedUserId = settings?.emby_user_id ?? ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Emby settings - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Emby Settings</h3>
|
||||
<p className="description">
|
||||
Configure your Emby server connection. Enter the server URL plus an
|
||||
API key, or sign in with admin credentials to obtain one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsAlertSlot>
|
||||
{feedback || testResult ? (
|
||||
<div className="space-y-4">
|
||||
{feedback ? (
|
||||
<Alert type={feedback.type} title={feedback.title} />
|
||||
) : null}
|
||||
{testResult ? (
|
||||
<Alert
|
||||
type={testResult.status ? 'success' : 'error'}
|
||||
title={testResult.message}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsAlertSlot>
|
||||
|
||||
<div className="section">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name="emby_url"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<InputGroup
|
||||
label="Emby URL"
|
||||
value={field.value}
|
||||
placeholder="http://emby.local:8096"
|
||||
onChange={(event) => {
|
||||
clearTransientState()
|
||||
field.onChange(event)
|
||||
}}
|
||||
onBlur={(event) =>
|
||||
field.onChange(stripTrailingSlashes(event.target.value))
|
||||
}
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
type="text"
|
||||
error={errors.emby_url?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<InputGroup
|
||||
label="API Key"
|
||||
type="password"
|
||||
{...registerApiKey}
|
||||
error={errors.emby_api_key?.message}
|
||||
helpText={
|
||||
<>
|
||||
In Emby, go to{' '}
|
||||
<strong>Dashboard → Advanced → API Keys</strong> and
|
||||
create a new key named "Maintainerr". Or use{' '}
|
||||
<em>Sign in with Emby</em> below to obtain one automatically.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-6 max-w-6xl sm:mt-5 sm:grid sm:grid-cols-3 sm:items-start sm:gap-4">
|
||||
<label htmlFor="emby_user_id" className="sm:mt-2">
|
||||
Admin User
|
||||
</label>
|
||||
<div className="px-3 py-2 sm:col-span-2">
|
||||
<div className="max-w-xl">
|
||||
{embyUsers.length > 0 && enteredSettingsHaveBeenTested ? (
|
||||
<Select {...register('emby_user_id')}>
|
||||
{embyUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} ({maskSecret(user.id)})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Select disabled value={savedUserId}>
|
||||
{savedUserId ? (
|
||||
<option value={savedUserId}>
|
||||
Selected: {maskSecret(savedUserId)}
|
||||
</option>
|
||||
) : (
|
||||
<option value="">
|
||||
Test connection to load Emby admin users
|
||||
</option>
|
||||
)}
|
||||
</Select>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{embyUsers.length > 0 && enteredSettingsHaveBeenTested
|
||||
? 'Select the admin user for Maintainerr operations.'
|
||||
: savedUserId
|
||||
? 'Saved admin user. Test connection to change.'
|
||||
: 'Test connection to load available admin users.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex w-full flex-wrap sm:flex-nowrap">
|
||||
<span className="m-auto rounded-md shadow-sm sm:ml-3 sm:mr-auto">
|
||||
<DocsButton page="Configuration/#emby" />
|
||||
</span>
|
||||
<div className="m-auto mt-3 flex xs:mt-0 sm:m-0 sm:justify-end">
|
||||
<EmbyLoginButton
|
||||
embyUrl={embyUrl}
|
||||
onAuthenticated={(result) => {
|
||||
setValue('emby_api_key', result.token)
|
||||
setValue('emby_user_id', result.userId)
|
||||
if (result.users) setEmbyUsers(result.users)
|
||||
setTestResult({
|
||||
status: true,
|
||||
message: result.serverName
|
||||
? `Authenticated against ${result.serverName}`
|
||||
: 'Authenticated',
|
||||
})
|
||||
setTestedSettings({
|
||||
url: embyUrl,
|
||||
apiKey: result.token,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<TestingButton
|
||||
type="button"
|
||||
buttonType="success"
|
||||
onClick={handleTest}
|
||||
className="ml-3"
|
||||
disabled={
|
||||
isEmbyLoading || isTestPending || isGoingToRemoveSettings
|
||||
}
|
||||
isPending={isTestPending}
|
||||
feedbackStatus={
|
||||
enteredSettingsHaveBeenTested
|
||||
? testResult?.status
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<SaveButton
|
||||
type="submit"
|
||||
disabled={!canSaveSettings}
|
||||
isPending={isSavePending || isDeletePending}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmbySettings
|
||||
@@ -46,8 +46,17 @@ const serverOptions: {
|
||||
description: 'Jellyfin Media Server',
|
||||
icon: `${basePath}/icons_logos/jellyfin.svg`,
|
||||
},
|
||||
{
|
||||
value: MediaServerType.EMBY,
|
||||
name: 'Emby',
|
||||
description: 'Emby Media Server',
|
||||
icon: `${basePath}/icons_logos/emby.png`,
|
||||
},
|
||||
]
|
||||
|
||||
const nameOf = (type: MediaServerType | null): string =>
|
||||
serverOptions.find((o) => o.value === type)?.name ?? ''
|
||||
|
||||
const MediaServerSelector = ({
|
||||
currentType,
|
||||
onSwitch,
|
||||
@@ -83,9 +92,7 @@ const MediaServerSelector = ({
|
||||
await switchServer({
|
||||
targetServerType: type,
|
||||
})
|
||||
onInfo?.(
|
||||
`Selected ${type === MediaServerType.PLEX ? 'Plex' : 'Jellyfin'} as your media server`,
|
||||
)
|
||||
onInfo?.(`Selected ${nameOf(type)} as your media server`)
|
||||
|
||||
// Wait for settings to refetch before navigating
|
||||
await queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
@@ -191,7 +198,7 @@ const MediaServerSelector = ({
|
||||
: 'Select your media server to get started with Maintainerr.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{serverOptions.map((option) => {
|
||||
const isSelected = currentType === option.value
|
||||
const isPending =
|
||||
@@ -275,18 +282,12 @@ const MediaServerSelector = ({
|
||||
serverOptions.find((o) => o.value === currentType)
|
||||
?.icon
|
||||
}
|
||||
alt={
|
||||
currentType === MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: 'Jellyfin'
|
||||
}
|
||||
alt={nameOf(currentType)}
|
||||
className="h-16 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-sm font-medium text-zinc-400">
|
||||
{currentType === MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: 'Jellyfin'}
|
||||
{nameOf(currentType)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -302,14 +303,12 @@ const MediaServerSelector = ({
|
||||
src={
|
||||
serverOptions.find((o) => o.value === pendingType)?.icon
|
||||
}
|
||||
alt={
|
||||
pendingType === MediaServerType.PLEX ? 'Plex' : 'Jellyfin'
|
||||
}
|
||||
alt={nameOf(pendingType)}
|
||||
className="h-16 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-sm font-medium text-zinc-400">
|
||||
{pendingType === MediaServerType.PLEX ? 'Plex' : 'Jellyfin'}
|
||||
{nameOf(pendingType)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,7 +318,7 @@ const MediaServerSelector = ({
|
||||
<>
|
||||
Successfully switched to{' '}
|
||||
<strong className="text-zinc-100">
|
||||
{pendingType === MediaServerType.PLEX ? 'Plex' : 'Jellyfin'}
|
||||
{nameOf(pendingType)}
|
||||
</strong>
|
||||
!
|
||||
</>
|
||||
@@ -327,11 +326,11 @@ const MediaServerSelector = ({
|
||||
<>
|
||||
We will now switch from{' '}
|
||||
<strong className="text-zinc-100">
|
||||
{currentType === MediaServerType.PLEX ? 'Plex' : 'Jellyfin'}
|
||||
{nameOf(currentType)}
|
||||
</strong>{' '}
|
||||
to{' '}
|
||||
<strong className="text-zinc-100">
|
||||
{pendingType === MediaServerType.PLEX ? 'Plex' : 'Jellyfin'}
|
||||
{nameOf(pendingType)}
|
||||
</strong>
|
||||
.
|
||||
</>
|
||||
@@ -420,10 +419,7 @@ const MediaServerSelector = ({
|
||||
/>
|
||||
<label htmlFor="migrateRules" className="ml-3 cursor-pointer">
|
||||
<span className="block font-medium text-zinc-100">
|
||||
Migrate rules to{' '}
|
||||
{pendingType === MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: 'Jellyfin'}
|
||||
Migrate rules to {nameOf(pendingType)}
|
||||
</span>
|
||||
<span className="block text-sm text-zinc-400">
|
||||
{previewData!.ruleMigration!.migratableRules} of{' '}
|
||||
@@ -433,11 +429,7 @@ const MediaServerSelector = ({
|
||||
<span className="text-maintainerr-400">
|
||||
{' '}
|
||||
{previewData!.ruleMigration!.skippedRules} rule(s) use
|
||||
properties not available in{' '}
|
||||
{pendingType === MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: 'Jellyfin'}
|
||||
.
|
||||
properties not available in {nameOf(pendingType)}.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -8,9 +8,14 @@ const navigate = vi.fn()
|
||||
const toastError = vi.fn()
|
||||
|
||||
const getMediaServerSettingsPath = (mediaServerType: MediaServerType) => {
|
||||
return mediaServerType === MediaServerType.PLEX
|
||||
? '/settings/plex'
|
||||
: '/settings/jellyfin'
|
||||
switch (mediaServerType) {
|
||||
case MediaServerType.PLEX:
|
||||
return '/settings/plex'
|
||||
case MediaServerType.EMBY:
|
||||
return '/settings/emby'
|
||||
default:
|
||||
return '/settings/jellyfin'
|
||||
}
|
||||
}
|
||||
|
||||
let currentPath = getMediaServerSettingsPath(MediaServerType.JELLYFIN)
|
||||
@@ -21,6 +26,8 @@ type MockSettingsResult = {
|
||||
plex_auth_token: string | null
|
||||
jellyfin_url?: string
|
||||
jellyfin_api_key?: string
|
||||
emby_url?: string
|
||||
emby_api_key?: string
|
||||
}
|
||||
isLoading: boolean
|
||||
error?: Error
|
||||
@@ -216,6 +223,11 @@ describe('SettingsWrapper', () => {
|
||||
expect(
|
||||
screen.getByText('Connect your media server to finish setup.'),
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Choose your media server, confirm the connection, and then you can continue configuring the rest of Maintainerr.',
|
||||
),
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByRole('button', { name: "Let's get started" }),
|
||||
).toBeTruthy()
|
||||
@@ -259,6 +271,31 @@ describe('SettingsWrapper', () => {
|
||||
).not.toBe('true')
|
||||
})
|
||||
|
||||
it('renders the Emby tab when media_server_type is EMBY', () => {
|
||||
currentPath = getMediaServerSettingsPath(MediaServerType.EMBY)
|
||||
currentSettingsResult = {
|
||||
data: {
|
||||
media_server_type: MediaServerType.EMBY,
|
||||
plex_auth_token: null,
|
||||
emby_url: 'http://emby.local',
|
||||
emby_api_key: 'token',
|
||||
},
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
}
|
||||
|
||||
const { container } = render(<SettingsWrapper />)
|
||||
|
||||
const labels = getDesktopTabLabels(container)
|
||||
expect(labels).toContain('Emby')
|
||||
expect(labels).not.toContain('Plex')
|
||||
expect(labels).not.toContain('Jellyfin')
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'Emby' }).getAttribute('href'),
|
||||
).toBe('/settings/emby')
|
||||
})
|
||||
|
||||
it('shows an error toast when a blocked settings tab is clicked during first setup', () => {
|
||||
currentPath = '/settings/main'
|
||||
currentSettingsResult = {
|
||||
|
||||
@@ -42,6 +42,10 @@ const getMediaServerTypeFromPath = (
|
||||
return MediaServerType.JELLYFIN
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/settings/emby')) {
|
||||
return MediaServerType.EMBY
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith('/settings/plex') ||
|
||||
pathname.startsWith('/settings/tautulli')
|
||||
@@ -65,6 +69,15 @@ const getMediaServerRoute = (
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaServerType === MediaServerType.EMBY) {
|
||||
return {
|
||||
text: 'Emby',
|
||||
content: mediaServerTabContent('Emby'),
|
||||
route: '/settings/emby',
|
||||
regex: /^\/settings\/emby$/,
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
return {
|
||||
text: 'Plex',
|
||||
@@ -261,7 +274,7 @@ const SettingsWrapper = () => {
|
||||
Connect your media server to finish setup.
|
||||
</p>
|
||||
<p className="mt-2 leading-6 text-info-200">
|
||||
Choose Plex or Jellyfin, confirm the connection, and then you
|
||||
Choose your media server, confirm the connection, and then you
|
||||
can continue configuring the rest of Maintainerr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ type MediaServerSetupSettings =
|
||||
| 'plex_auth_token'
|
||||
| 'jellyfin_url'
|
||||
| 'jellyfin_api_key'
|
||||
| 'emby_url'
|
||||
| 'emby_api_key'
|
||||
>
|
||||
| null
|
||||
| undefined
|
||||
@@ -30,6 +32,10 @@ export const hasCompletedMediaServerSetup = (
|
||||
return Boolean(settings.jellyfin_url && settings.jellyfin_api_key)
|
||||
}
|
||||
|
||||
if (settings.media_server_type === MediaServerType.EMBY) {
|
||||
return Boolean(settings.emby_url && settings.emby_api_key)
|
||||
}
|
||||
|
||||
if (settings.media_server_type === MediaServerType.PLEX) {
|
||||
return Boolean(
|
||||
settings.plex_hostname &&
|
||||
@@ -60,6 +66,7 @@ export function useMediaServerType() {
|
||||
isLoading,
|
||||
isPlex: mediaServerType === MediaServerType.PLEX,
|
||||
isJellyfin: mediaServerType === MediaServerType.JELLYFIN,
|
||||
isEmby: mediaServerType === MediaServerType.EMBY,
|
||||
isMediaServerTypeSelected: hasSelectedMediaServerType(settings),
|
||||
isSetupComplete,
|
||||
isNotConfigured: !isSetupComplete,
|
||||
|
||||
@@ -75,6 +75,9 @@ const settingsPlexRoute = createLazyRoute(
|
||||
const settingsJellyfinRoute = createLazyRoute(
|
||||
() => import('./components/Settings/Jellyfin'),
|
||||
)
|
||||
const settingsEmbyRoute = createLazyRoute(
|
||||
() => import('./components/Settings/Emby'),
|
||||
)
|
||||
const settingsSonarrRoute = createLazyRoute(
|
||||
() => import('./components/Settings/Sonarr'),
|
||||
)
|
||||
@@ -254,6 +257,11 @@ const appRoutes: AppRoute[] = [
|
||||
lazy: settingsJellyfinRoute.lazy,
|
||||
preload: settingsJellyfinRoute.preload,
|
||||
},
|
||||
{
|
||||
path: 'emby',
|
||||
lazy: settingsEmbyRoute.lazy,
|
||||
preload: settingsEmbyRoute.preload,
|
||||
},
|
||||
{
|
||||
path: 'sonarr',
|
||||
lazy: settingsSonarrRoute.lazy,
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
# Emby Support — Follow-up Fixes
|
||||
|
||||
Consolidated punch list for `emby-support` branch / PR #2911.
|
||||
|
||||
Sources for each item:
|
||||
- **Tester** — production failure reported by Nomsplease in HOPS Discord
|
||||
- **Review** — GitHub Copilot agent PR review
|
||||
- **RE** — reverse engineering against a live Emby 4.9.3.0 install (decompiled `Emby.Api.dll` + the server's own `/openapi` spec)
|
||||
|
||||
References that anchor every claim in this document:
|
||||
- `/openapi` and `/swagger` on a running Emby server return a 2.5 MB OpenAPI 2.0 spec titled *"Emby Server REST API 4.9.3.0"* — 422 endpoints.
|
||||
- `Emby.Api.dll` decompiled with `ilspycmd` (10,087 lines, 343 `[Route]` attributes) is the source-of-truth handler code.
|
||||
- `Emby.Server.Implementations.dll` decompiled (72,570 lines) contains the auth header parser at lines 53494-53557.
|
||||
|
||||
## Status legend
|
||||
|
||||
| Tier | Meaning |
|
||||
|---|---|
|
||||
| HIGH | Must fix before merge. Each is a reproducible bug that can corrupt user data or silently misbehave. |
|
||||
| MEDIUM | Should fix before merge. Documented behaviour does not match implementation. |
|
||||
| LOW | Nice to fix. Style, copy, or docs inconsistencies. |
|
||||
| OPTIONAL | Considered improvements that need a decision, not a bug. |
|
||||
|
||||
---
|
||||
|
||||
## HIGH — must fix before merge
|
||||
|
||||
### H1. Auto-create flow produces 500 against real Emby — confirmed by tester
|
||||
|
||||
**Source**: Tester
|
||||
**Files**: [emby-adapter.service.ts:765-803](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L765-L803), the upstream call site in `CollectionsService` (Maintainerr's shared `addToCollectionInternal`).
|
||||
|
||||
**Symptom (from the tester log)**:
|
||||
```
|
||||
INFO Adding 76 media items to 'Delete Watched TV Shows by Season'
|
||||
DEBUG [checkAutomaticMediaServerLink] No media server collection — will be created automatically when items match
|
||||
ERROR Failed to create Jellyfin collection
|
||||
Request failed with status code 500 (ERR_BAD_RESPONSE)
|
||||
```
|
||||
|
||||
The tester is on the pre-PR Jellyfin-against-Emby workaround, but the failure mode applies to our adapter too because Maintainerr's shared auto-create flow calls `mediaServer.createCollection(...)` with `Name`, `ParentId`, **no initial items**, then immediately follows with `addToCollection`. Emby's `POST /Collections` accepts an `Ids` query param at create time per the OpenAPI spec and the decompiled `CreateCollection` DTO at `Emby.Api:7535-7548`, and the real-world behaviour observed against an Emby 4.9.3.0 box is that creating an empty box-set first and adding items later is the path that fails.
|
||||
|
||||
**Fix options** (must verify against the live local Emby before picking):
|
||||
|
||||
A. **Pass initial items at create time when known** — change `createCollection` to forward the first batch of `Ids` as a query param on `POST /Collections`. Add a regression test that asserts the request shape via a mocked HTTP layer, plus a smoke test that runs against the local Emby Server to confirm the response is 200 and the items land.
|
||||
|
||||
B. **Capability-aware fallback** — keep the abstraction (create empty, then add) and have the Emby adapter internally buffer the first add call so that, when called immediately after a create, it issues a single combined create-with-items. Less invasive at the call site, more state in the adapter.
|
||||
|
||||
Recommendation: A. The Maintainerr abstraction does not promise "empty create" semantics — it promises "this collection ends up with these items". The Emby adapter is allowed to issue whichever wire calls accomplish that.
|
||||
|
||||
**Verification**:
|
||||
1. Reproduce the 500 against the local Emby in this Codespace by hand-curling `POST /Collections?Name=Foo&ParentId=<libraryId>` with no `Ids`. Capture the exact response body.
|
||||
2. Confirm `POST /Collections?Name=Foo&ParentId=<libraryId>&Ids=<one-real-item-id>` returns 200.
|
||||
3. Add a Jest spec asserting the create call shape.
|
||||
4. Once landed, run the tester's rule against the pr-2911 image and confirm collection creation succeeds end-to-end.
|
||||
|
||||
---
|
||||
|
||||
### H2. Show-context episode resolution returns empty on Emby
|
||||
|
||||
**Source**: Review
|
||||
**Files**: [emby-adapter.service.ts:1054-1063](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L1054-L1063), with the adapter's own contradicting comment at [emby-adapter.service.ts:428-431](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L428-L431).
|
||||
|
||||
**Current code**:
|
||||
```ts
|
||||
if (collectionType === 'episode' && context.type === 'show') {
|
||||
const children = await this.getChildrenMetadata(context.id); // ← no childType
|
||||
// Children of a series are seasons; need to descend further for episodes.
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`getChildrenMetadata(parentId)` with no `childType` falls through to `GET /Items?ParentId=<parentId>`, which the same adapter explicitly documents as broken for seasons:
|
||||
|
||||
> Seasons of a series live under `/Shows/{seriesId}/Seasons`, not under `/Items?ParentId=` (ParentId of a season points to the library folder, not the show).
|
||||
|
||||
So the children list is empty on Emby, and the function returns `[]` instead of the show's episode IDs.
|
||||
|
||||
**Fix**: pass the `'season'` filter so the path routes through `/Shows/{Id}/Seasons`:
|
||||
|
||||
```ts
|
||||
const seasons = await this.getChildrenMetadata(context.id, 'season');
|
||||
const episodeIds: string[] = [];
|
||||
for (const season of seasons) {
|
||||
const eps = await this.getChildrenMetadata(season.id, 'episode');
|
||||
episodeIds.push(...eps.map((e) => e.id));
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**: add a unit test for `getAllIdsForContextAction(collectionType='episode', context={type:'show', id})` that asserts the season route is queried.
|
||||
|
||||
---
|
||||
|
||||
### H3. `cleanupCollectionForLibrary` filter is always empty on Emby — collection switching leaks items
|
||||
|
||||
**Source**: Review
|
||||
**Files**: [emby-adapter.service.ts:820-839](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L820-L839), data shape set by [emby.mapper.ts:150-152](../apps/server/src/modules/api/media-server/emby/emby.mapper.ts#L150-L152).
|
||||
|
||||
**Current code**:
|
||||
```ts
|
||||
const children = await this.getCollectionChildren(collectionId);
|
||||
const fromLibrary = children.filter((c) => c.library?.id === libraryId);
|
||||
```
|
||||
|
||||
The mapper builds each `MediaItem.library.id` from the item's `ParentId`. For items returned by `/Items?ParentId=<collectionId>`, `ParentId` is **the collection itself**, not the source library. The filter is therefore always empty, so library-scoped cleanup never removes anything. Switching a rule group's library leaves stale items behind in shared collections.
|
||||
|
||||
**Fix**: mirror the Jellyfin pattern at [jellyfin-adapter.service.ts:720-739, 1640-1665](../apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts#L1640-L1665) — query Emby for each item's actual ancestors (the equivalent on Emby is `GET /Items/{id}/Ancestors` or `GET /Users/{userId}/Items/{id}` with the `Path`/`ParentId` chain) and check membership against `libraryId`. Encapsulate in a private `itemIsInLibrary(itemId, libraryId)` helper to match Jellyfin.
|
||||
|
||||
**Verification**: spec the helper directly with mocked ancestor responses, plus an integration test that exercises the rule-group switch path end-to-end against the local Emby.
|
||||
|
||||
---
|
||||
|
||||
### H4. `getWatchHistory` collapses transient failures into "never watched" — can drive wrong removals
|
||||
|
||||
**Source**: Review
|
||||
**Files**: [emby-adapter.service.ts:670-700](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L670-L700).
|
||||
|
||||
**Current code**:
|
||||
```ts
|
||||
try {
|
||||
const users = await this.getUsers();
|
||||
...
|
||||
} catch (error) {
|
||||
this.logger.debug(`Emby getWatchHistory(${itemId}) failed: ...`);
|
||||
return []; // ← any auth, 5xx, or network error becomes "never watched"
|
||||
}
|
||||
```
|
||||
|
||||
The reference Jellyfin implementation explicitly documents why this is wrong, at [jellyfin-adapter.service.ts:1040-1043](../apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts#L1040-L1043):
|
||||
|
||||
> Errors must propagate so callers can distinguish a real outage from a confirmed empty history. Returning `[]` here would misclassify failures as "never watched", which leaks into NOT_EXISTS checks and missing-value diagnostics in the rules layer.
|
||||
|
||||
A rule that removes "watched media older than 30 days" would, during an Emby outage, evaluate every item as never watched and *delete nothing*. A rule that removes "never-watched media older than 30 days" would, during the same outage, evaluate every item as never watched and *delete everything*. The second is the dangerous case — and it's exactly the kind of rule community libraries use.
|
||||
|
||||
**Fix**: rethrow on the outer failure. Keep the inner per-user `try { ... } catch { /* skip */ }` — that pattern is correct because individual users may legitimately lack access to an item.
|
||||
|
||||
```ts
|
||||
async getWatchHistory(itemId: string): Promise<WatchRecord[]> {
|
||||
if (!this.http) return [];
|
||||
const users = await this.getUsers(); // ← let failures propagate
|
||||
const records: WatchRecord[] = [];
|
||||
for (const user of users) {
|
||||
try { ... } catch { /* per-user visibility miss */ }
|
||||
}
|
||||
return records;
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**: spec covering (a) one user can't see item → record skipped, others still included; (b) `getUsers()` throws → `getWatchHistory` throws.
|
||||
|
||||
---
|
||||
|
||||
### H5. Overlay `itemExists` can permanently delete the original poster backup
|
||||
|
||||
**Source**: Review
|
||||
**Files**: [emby-overlay.provider.ts:74-76](../apps/server/src/modules/overlays/providers/emby-overlay.provider.ts#L74-L76), chained through [emby-adapter.service.ts:399-418](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L399-L418), consumed by [overlay-processor.service.ts:163-174](../apps/server/src/modules/overlays/overlay-processor.service.ts#L163-L174).
|
||||
|
||||
**Current code**:
|
||||
```ts
|
||||
async itemExists(itemId: string): Promise<boolean> {
|
||||
const meta = await this.emby.getMetadata(itemId);
|
||||
return meta !== undefined;
|
||||
}
|
||||
```
|
||||
|
||||
`getMetadata` swallows every error (auth, network, 5xx) and returns `undefined`. The overlay processor treats `false` as **"item has been deleted from the server"** and removes the saved original poster backup. A transient Emby outage during overlay processing can therefore permanently lose the user's original posters.
|
||||
|
||||
The Jellyfin adapter avoids this by having a dedicated `itemExists` that distinguishes 404 from other failures — see [jellyfin-adapter.service.ts:888-908](../apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts#L888-L908).
|
||||
|
||||
**Fix**: add a dedicated `EmbyAdapterService.itemExists(itemId)` that:
|
||||
- Returns `true` on 200
|
||||
- Returns `false` **only** on confirmed 404 from `GET /Items/{itemId}` (or equivalent narrow probe)
|
||||
- Rethrows on any other status / network error
|
||||
|
||||
Then change the overlay provider to call `this.emby.itemExists(itemId)` directly instead of going through `getMetadata`.
|
||||
|
||||
**Verification**: spec covering (a) 200 → true, (b) 404 → false, (c) 500 → throws, (d) network error → throws. Snapshot a corresponding overlay processor test that verifies the backup is NOT deleted on a thrown error.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM — should fix before merge
|
||||
|
||||
### M1. `sortTitle` is silently dropped on create and update
|
||||
|
||||
**Source**: Review
|
||||
**Files**: [emby-adapter.service.ts:780-795](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L780-L795) and [emby-adapter.service.ts:925-940](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L925-L940).
|
||||
|
||||
**Current `createCollection`**:
|
||||
```ts
|
||||
if (params.summary) { // ← gated on summary only
|
||||
await this.updateCollection({ ..., sortTitle: params.sortTitle });
|
||||
}
|
||||
```
|
||||
|
||||
If a caller passes `sortTitle` but no `summary`, the follow-up never runs.
|
||||
|
||||
**Current `updateCollection`**:
|
||||
```ts
|
||||
const updated = {
|
||||
...current,
|
||||
Name: params.title ?? current.Name,
|
||||
Overview: params.summary ?? current.Overview,
|
||||
// ← no ForcedSortName / SortName field, ever
|
||||
};
|
||||
```
|
||||
|
||||
`sortTitle` is in `CreateCollectionParams` / `UpdateCollectionParams` but the Emby adapter never writes it anywhere.
|
||||
|
||||
**Fix**:
|
||||
1. In `createCollection`, run the follow-up when either `summary` **or** `sortTitle` is set.
|
||||
2. In `updateCollection`, set Emby's sort field (likely `ForcedSortName` based on the BaseItemDto schema in the swagger spec, confirm against the live API) when `params.sortTitle` is present.
|
||||
|
||||
**Verification**: unit test create+update with `{sortTitle: 'foo'}` and assert the body sent on the update POST contains the sort field.
|
||||
|
||||
---
|
||||
|
||||
## LOW — nice to fix before merge
|
||||
|
||||
### L1. Stale auth header comment claims things that aren't true
|
||||
|
||||
**Source**: RE
|
||||
**Files**: [emby-adapter.service.ts:53](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L53), with the matching `buildAuthHeader` at [emby-adapter.service.ts:1197-1199](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L1197-L1199).
|
||||
|
||||
**Current comment**:
|
||||
> X-MediaBrowser-Authorization header requires Version="1.0.0" (pinned).
|
||||
|
||||
Both claims are false, verified against the decompiled parser at `Emby.Server.Implementations:53494-53557`:
|
||||
|
||||
- The header name we actually send is `X-Emby-Authorization` (also `Authorization` is accepted). `X-MediaBrowser-Authorization` is not the name we use anywhere.
|
||||
- There is no `Version` validation in Emby's parser. A live curl with `Version="999.999.999"` returns the same 401 as `Version="1.0.0"` — the parser stores the version on session info but never compares it to a required value. `grep -r '"1\.0\.0"'` across the entire decompiled `Emby.Api.dll` and `Emby.Server.Implementations.dll` returns zero matches.
|
||||
|
||||
**Fix**: replace the comment with a one-liner that accurately describes what we send:
|
||||
|
||||
```ts
|
||||
// X-Emby-Authorization header. Emby's parser accepts either `Emby` or
|
||||
// `MediaBrowser` as the scheme prefix and stores Version on session info
|
||||
// without enforcement.
|
||||
```
|
||||
|
||||
No code change.
|
||||
|
||||
---
|
||||
|
||||
### L2. `GET /Users` is a hidden endpoint — switch to `/Users/Query`
|
||||
|
||||
**Source**: RE
|
||||
**File**: [emby-adapter.service.ts:186](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L186)
|
||||
|
||||
`GET /Users` exists in the decompiled route table at `Emby.Api:2752` but is marked `IsHidden = true` and does not appear in the published `/openapi` spec. The Emby dashboard JS uses `/Users/Query` instead. Works today, but is fragile against upstream changes.
|
||||
|
||||
**Fix**: switch `/Users` → `/Users/Query`. Same response shape, public API. One line.
|
||||
|
||||
---
|
||||
|
||||
### L3. `userId` query param uses wrong casing
|
||||
|
||||
**Source**: RE
|
||||
**File**: [emby-adapter.service.ts:574](../apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts#L574)
|
||||
|
||||
Emby's query parsing is case-insensitive (`StringComparer.OrdinalIgnoreCase`), so it works, but the documented param name in both the OpenAPI spec and the `[ApiMember]` attribute is `UserId` (PascalCase). The rest of our adapter uses PascalCase. Inconsistent.
|
||||
|
||||
**Fix**: `userId: this.embyUserId` → `UserId: this.embyUserId`.
|
||||
|
||||
---
|
||||
|
||||
### L4. Emby login controller bypasses Zod validation
|
||||
|
||||
**Source**: Review
|
||||
**File**: [settings.controller.ts:441-450](../apps/server/src/modules/settings/settings.controller.ts#L441-L450)
|
||||
|
||||
The new `POST /api/settings/emby/login` accepts raw `@Body() payload: { emby_url, username, password }` instead of using a shared schema and `ZodValidationPipe`, unlike the surrounding settings endpoints.
|
||||
|
||||
**Fix**:
|
||||
1. Define `embyLoginRequestSchema` in `packages/contracts/src/media-server/emby/` (new file or extend `embySetting.ts`):
|
||||
```ts
|
||||
export const embyLoginRequestSchema = z.object({
|
||||
emby_url: serviceUrlSchema,
|
||||
username: z.string().trim().min(1),
|
||||
password: z.string(),
|
||||
});
|
||||
export type EmbyLoginRequest = z.infer<typeof embyLoginRequestSchema>;
|
||||
```
|
||||
2. Export from the contracts barrel.
|
||||
3. In the controller: `@Body(new ZodValidationPipe(embyLoginRequestSchema)) payload: EmbyLoginRequest`.
|
||||
|
||||
---
|
||||
|
||||
### L5. Onboarding copy still says "Choose Plex or Jellyfin"
|
||||
|
||||
**Source**: Review
|
||||
**Files**: [Settings/index.tsx:277-278](../apps/ui/src/components/Settings/index.tsx#L277-L278), pinned by [Settings.spec.tsx:224](../apps/ui/src/components/Settings/Settings.spec.tsx#L224).
|
||||
|
||||
The first-time-user welcome screen still tells users to "Choose Plex or Jellyfin", which is no longer true.
|
||||
|
||||
**Fix**: change the copy to either mention Emby explicitly or be server-agnostic ("Choose your media server"). Update the test assertion to match.
|
||||
|
||||
---
|
||||
|
||||
### L6. Broken verification script in `docs/emby-support.md`
|
||||
|
||||
**Source**: Review
|
||||
**File**: [emby-support.md:378-395](emby-support.md#L378-L395)
|
||||
|
||||
The script prints the generated API key but never assigns it to `$APIKEY`, then the next step uses `$APIKEY`.
|
||||
|
||||
**Fix**: assign the key to `APIKEY` on the same line that prints it, or reuse `$TOKEN` consistently throughout the section.
|
||||
|
||||
---
|
||||
|
||||
## OPTIONAL — improvements to consider (not bugs)
|
||||
|
||||
### O1. Set `IsLocked: true` on `createCollection`
|
||||
|
||||
The Jellyfin adapter sets `isLocked: true` on collection creation with the comment *"enables composite image generation from collection items"*. Without it, Emby may not auto-generate the composite cover image when items are added. Worth a quick A/B against the local Emby — if the composite cover behaviour matches Jellyfin, set the flag. Independent of H1, but the changes can land together.
|
||||
|
||||
### O2. Wire `POST /Playlists/{Id}/Items/{ItemId}/Move/{NewIndex}` for playlist reorder
|
||||
|
||||
Confirmed implementable against a live Emby — but deliberately deferred because the call site doesn't exist in Maintainerr.
|
||||
|
||||
**What the source dig confirmed**:
|
||||
- The route is real (`Emby.Api:1841`) and the handler at `Emby.Api:1909-1919` delegates straight to `IPlaylistManager.MoveItem(playlist, request.ItemId, request.NewIndex)`.
|
||||
- The DTO declares `ItemId` as `long`, but the official Emby web client at `dashboard-ui/modules/emby-apiclient/apiclient.js` passes `items[i].PlaylistItemId` — a `string` field on `BaseItemDto` (`Emby.Api:6488`) returned by `GET /Playlists/{Id}/Items`. ServiceStack handles the string-to-long conversion at the path-binding layer. No client-side internal-ID mapping needed.
|
||||
|
||||
**Why we still defer**:
|
||||
- `IMediaServerService` only declares `reorderCollectionItems` ([media-server.interface.ts:280](../apps/server/src/modules/api/media-server/media-server.interface.ts#L280)).
|
||||
- The sole call site for that method is the Plex `COLLECTION_SORT` path at [collections.service.ts:849](../apps/server/src/modules/collections/collections.service.ts#L849).
|
||||
- No `reorderPlaylistItems` method exists, no Maintainerr rule action generates one, and no UI flow asks for it.
|
||||
|
||||
Shipping the Emby impl would mean adding an interface method, three adapter implementations (Plex/Jellyfin/Emby), and a feature flag — all unreachable from any user flow. File as a follow-up issue if a real use case appears.
|
||||
|
||||
Collection reorder remains genuinely unavailable on Emby (no equivalent `/Collections/{Id}/Items/{ItemId}/Move` route in either the swagger spec or the decompiled handler set). The current `COLLECTION_SORT = false` capability is correct.
|
||||
|
||||
### O3. Live-server integration test harness
|
||||
|
||||
The PR doc states up front that a wide Emby HTTP surface is scaffolded but unverified. Once H1-H5 land, add a small set of integration tests that run against a real Emby (e.g. via the same Codespace flow used to find these bugs) and cover at least the create-with-items, library-membership cleanup, watch-history, and overlay flows end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## Dismissed — not a finding
|
||||
|
||||
### D1. `release_pr.yml` removed the `environment: release-builds` gate
|
||||
|
||||
**Source**: Review (false positive)
|
||||
|
||||
This change is present in the cumulative diff against `development` but **is not from this PR**. It came in via the upstream merge commit `5d6f8ff4` ("Revert PR 2879 (#2904)"), which reverted the PR that originally added the gate. Our `emby-support` branch absorbed the revert when it merged `development`.
|
||||
|
||||
`git log development..emby-support -- .github/workflows/release_pr.yml` returns only the merge commit, confirming we made no direct edits to the workflow.
|
||||
|
||||
No action on this PR. If the gate should be re-added, that is a separate conversation about the reverted PR #2879's design.
|
||||
|
||||
---
|
||||
|
||||
## Suggested execution order
|
||||
|
||||
1. **H1, H2, H3, H4** — wire bugs that produce wrong data. Land each as its own commit with a focused regression test.
|
||||
2. **H5** — overlay safety. Land with its own spec.
|
||||
3. **M1** — sortTitle plumbing.
|
||||
4. **L1, L2, L3, L4, L5, L6** — batch as a single "review nits" commit (small, low-risk).
|
||||
5. **O1** — verify against local Emby, land in the same commit if it works as expected.
|
||||
6. **O2, O3** — defer to follow-up issues.
|
||||
|
||||
After step 1-3 land, re-test the full Emby flow end-to-end against the local Codespace Emby AND request that Nomsplease re-runs his rule against the next pr-2911 image build.
|
||||
|
||||
## Cross-reference
|
||||
|
||||
The 28 endpoints our adapter calls were verified against the running Emby 4.9.3.0's own `/openapi` (422-path spec). All resolve. Two are sub-optimal:
|
||||
- `GET /Users` → use `/Users/Query` (L2)
|
||||
- `userId` query param → use `UserId` (L3)
|
||||
|
||||
No other parameter shapes or paths are wrong. The implementation gaps above are about behaviour and error handling, not endpoint discovery.
|
||||
@@ -0,0 +1,555 @@
|
||||
# Emby Support — Technical Documentation
|
||||
|
||||
This document covers the addition of Emby as a third supported media server
|
||||
in Maintainerr alongside Plex and Jellyfin. It records what was built, why
|
||||
the structural choices were made, what is verified, what is unverified, and
|
||||
where future maintainers should look first when triaging Emby-specific bugs.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Architecture & layering](#architecture--layering)
|
||||
3. [Design decisions](#design-decisions)
|
||||
4. [What was verified vs scaffolded](#what-was-verified-vs-scaffolded)
|
||||
5. [Why Emby Connect is not implemented](#why-emby-connect-is-not-implemented)
|
||||
6. [Database migration](#database-migration)
|
||||
7. [Logo + licensing](#logo--licensing)
|
||||
8. [CodeQL alerts (false positives)](#codeql-alerts-false-positives)
|
||||
9. [Testing scripts & how to verify locally](#testing-scripts--how-to-verify-locally)
|
||||
10. [Known gaps / future work](#known-gaps--future-work)
|
||||
11. [Bug history during the PR](#bug-history-during-the-pr)
|
||||
12. [Commit log](#commit-log)
|
||||
13. [File inventory](#file-inventory)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Maintainerr previously supported Plex and Jellyfin through the
|
||||
`IMediaServerService` abstraction in `apps/server/src/modules/api/media-server/`.
|
||||
This change adds Emby as a third adapter slotted into the same abstraction,
|
||||
plus the matching UI, settings, rule-evaluation, and migration plumbing.
|
||||
|
||||
End-user surface:
|
||||
|
||||
- New "Emby" tile in the media-server selector
|
||||
- Dedicated `/settings/emby` settings page with the same shape as the Jellyfin
|
||||
page (URL + API key + admin user dropdown + Test/Save + "Sign in with Emby"
|
||||
credentials login)
|
||||
- Emby application appears in the rule creator with its own properties
|
||||
- Switch flow handles Emby ↔ Plex and Emby ↔ Jellyfin in both directions,
|
||||
migrating rule property IDs across servers
|
||||
|
||||
Server surface:
|
||||
|
||||
- `EmbyAdapterService` implementing all 40 methods of `IMediaServerService`
|
||||
- `EmbyApi` helper at `apps/server/src/modules/api/emby-api/emby-api.helper.ts`
|
||||
that wraps `axios.create`, matching the `plex-api/` / `tautulli-api/` layout
|
||||
- `EmbyGetterService` mirroring `JellyfinGetterService` for rule-property
|
||||
evaluation (50+ property cases)
|
||||
- `EmbyOverlayProvider` for collection-poster uploads
|
||||
- TypeORM migration adding four nullable `emby_*` columns to the `settings`
|
||||
table
|
||||
|
||||
PR: [#2911](https://github.com/Maintainerr/Maintainerr/pull/2911) on the
|
||||
`emby-support` branch.
|
||||
|
||||
---
|
||||
|
||||
## Architecture & layering
|
||||
|
||||
```
|
||||
Settings / Rules / Collections / UI controllers
|
||||
│
|
||||
MediaServerFactory (routes by MediaServerType)
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
PlexAdapterService JellyfinAdapter EmbyAdapterService
|
||||
│ │ │
|
||||
PlexApi (npm) @jellyfin/sdk EmbyApi (this repo)
|
||||
│
|
||||
axios.create({baseURL})
|
||||
│
|
||||
Emby HTTP API
|
||||
```
|
||||
|
||||
- `MediaServerFactory.getService()` reads `settings.media_server_type` and
|
||||
returns the matching adapter. The factory was extended with an `EMBY` case
|
||||
and a third `embyAdapter` injection.
|
||||
- `EmbyAdapterService` (in `modules/api/media-server/emby/`) implements
|
||||
`IMediaServerService`. It delegates HTTP construction to `EmbyApi`.
|
||||
- `EmbyApi` (in `modules/api/emby-api/`) is a small class that builds the
|
||||
`axios` instance with the right headers and base URL. It mirrors how
|
||||
`PlexApiService` wraps the npm `plex-api` package and how `TautulliApi`
|
||||
extends `ExternalApiService`.
|
||||
- `EmbyGetterService` (in `modules/rules/getter/`) implements the per-property
|
||||
read paths used by the rule executor. It is registered alongside the other
|
||||
per-server getters in `RulesModule` and dispatched from `ValueGetterService`.
|
||||
|
||||
### Cross-cutting touchpoints
|
||||
|
||||
The following pre-existing files were extended with Emby cases:
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| `packages/contracts/src/media-server/enums.ts` | `MediaServerType.EMBY = 'emby'` |
|
||||
| `packages/contracts/src/media-server/features.ts` | Emby entry in the feature matrix |
|
||||
| `packages/contracts/src/rules/constants.ts` | `Application.EMBY = 7` + ApplicationNames |
|
||||
| `apps/server/src/modules/api/lib/cache.ts` | `emby` cache in the static registry |
|
||||
| `apps/server/src/modules/api/media-server/media-server-id.utils.ts` | `isLikelyEmbyId`, `isForeignServerId`/`shouldRefreshMetadataItemId` Emby branches |
|
||||
| `apps/server/src/modules/api/media-server/media-server.factory.ts` | `EmbyAdapterService` injection + EMBY case in switches |
|
||||
| `apps/server/src/modules/api/media-server/media-server.module.ts` | `EmbyModule` import + provider |
|
||||
| `apps/server/src/modules/overlays/providers/overlay-provider.factory.ts` | EMBY case |
|
||||
| `apps/server/src/modules/overlays/providers/overlay-provider.module.ts` | `EmbyOverlayProvider` provider |
|
||||
| `apps/server/src/modules/rules/constants/rules.constants.ts` | Constructor pushes an Emby application that shares Jellyfin's `props[]` |
|
||||
| `apps/server/src/modules/rules/getter/getter.service.ts` | EMBY case in dispatch + `EmbyGetterService` injection |
|
||||
| `apps/server/src/modules/rules/rules.module.ts` | `EmbyGetterService` provider |
|
||||
| `apps/server/src/modules/rules/rules.service.ts` | Cache-reset branch for `emby` cache |
|
||||
| `apps/server/src/modules/rules/tasks/rule-executor.service.ts` | Empty-children BoxSet sync-lag workaround now covers Emby as well as Jellyfin |
|
||||
| `apps/server/src/modules/settings/entities/settings.entities.ts` | `emby_url`, `emby_api_key`, `emby_user_id`, `emby_server_name` columns |
|
||||
| `apps/server/src/modules/settings/media-server-switch.service.ts` | Null `emby_*` columns when switching away from Emby |
|
||||
| `apps/server/src/modules/settings/rule-migration.service.ts` | EMBY mapping in `getApplicationId` + `detectRuleSourceApp` |
|
||||
| `apps/server/src/modules/settings/settings.controller.ts` | Routes for `/api/settings/emby[/test | /login]` |
|
||||
| `apps/server/src/modules/settings/settings.service.ts` | `testEmby`, `loginEmby`, `saveEmbySettings`, `removeEmbySettings`, hydration, auto-detect, secret masking, `testSetup`, `testMediaServerConnection` |
|
||||
| `apps/ui/src/api/settings.ts` | `EmbySetting`, `useEmbySettings`, `useTestEmby`, `useSaveEmbySettings`, `useDeleteEmbySettings`, `useLoginEmby` hooks |
|
||||
| `apps/ui/src/components/Common/MediaCard/MediaModal/index.tsx` | Emby deep-link branch |
|
||||
| `apps/ui/src/components/Layout/MediaServerSetupGuard.tsx` | EMBY case in `getMediaServerSetupRoute` |
|
||||
| `apps/ui/src/components/Rules/Rule/RuleCreator/RuleInput/index.tsx` | `shouldFilterApplication` updated to filter rule properties by server type with `isEmby` flag |
|
||||
| `apps/ui/src/components/Settings/MediaServerSelector/index.tsx` | Third selector option + `nameOf()` lookup replacing two-server ternaries |
|
||||
| `apps/ui/src/components/Settings/index.tsx` | Emby tab, path detection, setup route |
|
||||
| `apps/ui/src/hooks/useMediaServerType.ts` | `isEmby` flag |
|
||||
| `apps/ui/src/router.tsx` | `/settings/emby` lazy route |
|
||||
| `ARCHITECTURE.md` | Flowchart + module list updated |
|
||||
|
||||
---
|
||||
|
||||
## Design decisions
|
||||
|
||||
### 1. Separate adapter, separate getter — no shared base
|
||||
|
||||
Emby and Jellyfin share a common ancestor (Jellyfin forked Emby in 2018) and
|
||||
the Emby getter ended up as essentially a 1:1 port of the Jellyfin getter.
|
||||
The temptation to extract a shared `JellyfinLikeGetterBase` was explicitly
|
||||
**rejected**:
|
||||
|
||||
- The fork is seven years old and the APIs will continue to diverge.
|
||||
- A shared base either gets polluted with subclass branches when the first
|
||||
divergence appears, or gets forked back anyway — and the "refactor back"
|
||||
cost is higher than the duplicated-fix cost.
|
||||
- Keeping three distinct servers (Plex, Jellyfin, Emby) as three distinct
|
||||
code paths matches the existing project convention and the architecture
|
||||
guardrail "Use `supportsFeature()` for conditional behaviour — never
|
||||
branch on server type in the shared layer".
|
||||
|
||||
The cost is real: every bug fix in `JellyfinGetterService` needs a parallel
|
||||
fix in `EmbyGetterService`. That's the accepted trade.
|
||||
|
||||
### 2. EmbyApi helper in `modules/api/emby-api/`
|
||||
|
||||
The first draft constructed `axios.create({baseURL: userUrl})` directly
|
||||
inside `EmbyAdapterService`. That worked but didn't match the established
|
||||
layering — `PlexApiService` wraps the `plex-api` npm package, and
|
||||
`TautulliApi` extends `ExternalApiService`. The HTTP-client construction
|
||||
was extracted into a small `EmbyApi` helper class at
|
||||
`apps/server/src/modules/api/emby-api/emby-api.helper.ts`, and
|
||||
`EmbyAdapterService` now instantiates it and reads `.axios`.
|
||||
|
||||
### 3. `Application.EMBY = 7` + shared rule property list
|
||||
|
||||
`Application.EMBY` was added to the rules `Application` enum with id `7`.
|
||||
The Emby application is registered in `RuleConstants` via the constructor;
|
||||
it shares the **same `props[]` array reference** as the Jellyfin application:
|
||||
|
||||
```ts
|
||||
this.applications.push({
|
||||
id: Application.EMBY,
|
||||
name: "Emby",
|
||||
mediaType: MediaType.BOTH,
|
||||
props: jellyfinApp.props,
|
||||
});
|
||||
```
|
||||
|
||||
This means rule migration between Jellyfin and Emby is a property-ID no-op
|
||||
(both sides have the same numeric IDs for the same property names).
|
||||
`rule-migration.service.ts` maps `MediaServerType.EMBY` → `Application.EMBY`
|
||||
in `getApplicationId`, and `detectRuleSourceApp` accepts EMBY alongside the
|
||||
existing two.
|
||||
|
||||
### 4. Login UI follows the existing Plex login pattern
|
||||
|
||||
A dedicated `EmbyLoginButton` lives at `apps/ui/src/components/Login/Emby/`,
|
||||
mirroring `Login/Plex/PlexLoginButton`. The settings page (`Settings/Emby/`)
|
||||
renders that button next to the `TestingButton` and `SaveButton`. Login UX
|
||||
collects admin username/password, POSTs to `/api/settings/emby/login`, and
|
||||
on success populates the URL + API-key fields and the admin-user dropdown
|
||||
in the parent form.
|
||||
|
||||
### 5. Empty-children sync-lag workaround applies to Emby too
|
||||
|
||||
`rule-executor.service.ts` had a Jellyfin-only workaround for cases where
|
||||
the BoxSet API momentarily returns empty children right after a write,
|
||||
which previously caused valid items to be flagged as "manually removed".
|
||||
Emby shares the same .NET BoxSet backend so the workaround now covers both:
|
||||
|
||||
```ts
|
||||
const isJellyfin = this.settings.media_server_type === MediaServerType.JELLYFIN;
|
||||
const isEmby = this.settings.media_server_type === MediaServerType.EMBY;
|
||||
const shouldCheckRemovals =
|
||||
isJellyfin || isEmby ? children && children.length > 0 : true;
|
||||
```
|
||||
|
||||
The two flags are kept separate (not folded into one) so future divergence
|
||||
between the two servers' behaviour stays expressible without a rename.
|
||||
|
||||
### 6. ReDoS regex replaced with string ops
|
||||
|
||||
`url.replace(/\/+$/, '')` was flagged by CodeQL as polynomial regex on
|
||||
user-controlled input. Replaced with the codebase's established
|
||||
`while + endsWith + slice` pattern (see PR #2526 `normalizeDiskPath` and
|
||||
`modules/api/lib/requestLogging.ts:describeRequestTarget`):
|
||||
|
||||
```ts
|
||||
let cleanUrl = url;
|
||||
while (cleanUrl.endsWith("/")) cleanUrl = cleanUrl.slice(0, -1);
|
||||
```
|
||||
|
||||
### 7. Cache key namespace
|
||||
|
||||
A new `'emby'` cache type was added to the static registry in
|
||||
`modules/api/lib/cache.ts` (matching the `'jellyfin'` entry pattern).
|
||||
Cache invalidation in `rules.service.ts:requiresCacheReset` flushes
|
||||
`cacheManager.getCache('emby')` for the Emby case.
|
||||
|
||||
---
|
||||
|
||||
## What was verified vs scaffolded
|
||||
|
||||
### Verified against a live Emby Server 4.9.3.0
|
||||
|
||||
- Connection test (`POST /api/settings/emby/test`) → server name + version + admin user list
|
||||
- Credentials login (`POST /api/settings/emby/login` → `POST /Users/AuthenticateByName`) → access token + libraries + users
|
||||
- Settings save + persistence (the four `emby_*` columns)
|
||||
- Media-server endpoints with Emby active: `/libraries`, `/users`, `/` (status), `/library/:id/content` (real Shows library returned mapped items)
|
||||
- Rule creation against an Emby library (`firstVal: [7, 0]`, "Date added")
|
||||
- Rule executor run end-to-end (no errors, walked the full adapter path)
|
||||
- Media-server switch: Emby ↔ Jellyfin in both directions, rule migration `[7, X]` ↔ `[6, X]`, DB state verified
|
||||
- Full UI walkthrough — 0 console errors with Emby configured
|
||||
- 5 screenshots captured via Playwright MCP and embedded in the PR description
|
||||
|
||||
### Scaffolded but unverified
|
||||
|
||||
Each unverified path is marked `TODO(emby-server-test):` in the code (10 sites).
|
||||
|
||||
| Surface | Status |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Collection write paths (`createCollection`, `addToCollection`, `removeFromCollection`, `updateCollection`, `deleteCollection`, `cleanupCollectionForLibrary`) | Code exists, endpoints look right per Emby docs, never hit a real Emby for confirmation |
|
||||
| `setCollectionImage` | Coded as base64 POST body with original Content-Type; unverified |
|
||||
| `deleteFromDisk`, `refreshItemMetadata` | Coded, unverified |
|
||||
| `getWatchHistory` per-user iteration, `getWatchState`, `getItemSeenBy` | Coded, unverified at scale |
|
||||
| `computeLibraryStorageSizes` | Coded, may report misleading totals if the `Size` field aggregates differently than expected |
|
||||
| `getAllIdsForContextAction` (show ↔ episode traversal) | Coded, unverified |
|
||||
| `EmbyGetterService` — all 50+ property cases | Ported 1:1 from the verified `JellyfinGetterService`; structurally correct but the underlying adapter HTTP calls are unverified |
|
||||
| `EmbyOverlayProvider` | `isAvailable`, `getSections`, `itemExists`, image upload have implementations; `getRandomItem`, `getRandomEpisode`, `downloadImage` return `null` with a TODO |
|
||||
| `supportsFeature()` matrix | Conservative defaults matching Jellyfin: COLLECTION_VISIBILITY, WATCHLIST, CENTRAL_WATCH_HISTORY, COLLECTION_SORT all off. The COLLECTION_SORT note is updated to reflect that Emby has no item-move endpoint (only DisplayOrder = PremiereDate \| SortName), so Maintainerr's "push an ordered list of IDs" contract is structurally unsatisfiable |
|
||||
|
||||
### Tests landed in this PR
|
||||
|
||||
These specs cover the in-process Maintainerr logic that branches on
|
||||
`MediaServerType.EMBY` — none of them call out to a live Emby server, so they
|
||||
verify _Maintainerr's_ behaviour when configured for Emby rather than what
|
||||
Emby itself returns:
|
||||
|
||||
| Spec | What it pins |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `media-server.factory.spec.ts` (extended) | Emby routing via `getServiceByType(EMBY)`, init-then-return path, uninitialize dispatch, configured-type inference from `emby_*` columns. Also fixes a stale "unsupported type" test that used `'EMBY'` as the example string |
|
||||
| `media-server-switch.service.spec.ts` (extended) | All four Emby switch directions (Plex→Emby, Jellyfin→Emby, Emby→Plex, Emby→Jellyfin) clear the right column sets |
|
||||
| `rule-migration.service.spec.ts` (extended) | Emby ↔ Plex migrations, Emby ↔ Jellyfin no-op remaps (shared `props[]` reference), incompatible-property skip + delete, EMBY source detection for community imports, `getApplicationId(EMBY)` resolution |
|
||||
| `rules.service.cacheReset.spec.ts` (new) | `resetCacheIfGroupUsesRuleThatRequiresIt` flushes `getCache('emby')` when configured for Emby (with peer assertions for Plex and Jellyfin so the dispatch table is fully covered) |
|
||||
| `emby.mapper.spec.ts` (new) | The pure `EmbyBaseItemDto → MediaItem/MediaLibrary/MediaCollection/MediaPlaylist/MediaUser/MediaServerStatus/WatchRecord` transforms, including parent/grandparent semantics, RunTimeTicks→ms conversion, AspectRatio parsing, Tags→labels mapping, and the "Emby has no smart collections" invariant |
|
||||
| `Settings.spec.tsx` (extended) | Emby render path — when `media_server_type === EMBY` the desktop tab list contains "Emby" and not "Plex"/"Jellyfin", and the link points at `/settings/emby` |
|
||||
|
||||
---
|
||||
|
||||
## Why Emby Connect is not implemented
|
||||
|
||||
The original plan included Emby Connect (the emby.media cloud-account flow)
|
||||
as an MVP feature, citing an `embyconnect.ts` reference implementation in
|
||||
Jellyseerr. **That file does not exist** — verified via the GitHub API
|
||||
against both [Seerr](https://github.com/seerr-team/seerr/tree/develop/server/api)
|
||||
and [Jellyseerr](https://github.com/Fallenbagel/jellyseerr/tree/develop/server/api).
|
||||
Neither repo has a dedicated Emby Connect module, and neither `jellyfin.ts`
|
||||
contains any references to `api.emby.media`, `/service/`, or
|
||||
`X-Connect-UserToken`.
|
||||
|
||||
Rather than ship guessed `api.emby.media` code as if it were verified, the
|
||||
Connect flow was removed entirely. The previously-added
|
||||
`EmbyConnectService`, controller endpoints (`/emby/connect/login`,
|
||||
`/emby/connect/exchange`), and UI Connect modal were all deleted. The
|
||||
verified credentials login (`POST /Users/AuthenticateByName`) and the
|
||||
direct API-key flow remain.
|
||||
|
||||
Connect can be added in a follow-up once the endpoints are confirmed
|
||||
against a Premiere-enabled, Connect-linked server.
|
||||
|
||||
---
|
||||
|
||||
## Database migration
|
||||
|
||||
`apps/server/src/database/migrations/1779021820174-AddEmbySupport.ts` was
|
||||
generated via the documented TypeORM workflow (`yarn migration:generate`
|
||||
against a database with all prior migrations applied — see
|
||||
`typeorm_instructions.txt`).
|
||||
|
||||
It adds four nullable `varchar` columns to the `settings` table:
|
||||
|
||||
- `emby_url`
|
||||
- `emby_api_key`
|
||||
- `emby_user_id`
|
||||
- `emby_server_name`
|
||||
|
||||
The migration uses TypeORM's standard SQLite temporary-table rename pattern
|
||||
(SQLite can't easily `ALTER ADD COLUMN` with full schema preservation).
|
||||
No data loss path; all four columns default to NULL and are only populated
|
||||
when the user configures Emby.
|
||||
|
||||
---
|
||||
|
||||
## Logo + licensing
|
||||
|
||||
`apps/ui/public/icons_logos/emby.png` is a 377×377 crop of the leftmost
|
||||
icon portion of the [Wikimedia Commons Emby-logo.png](https://commons.wikimedia.org/wiki/File:Emby-logo.png)
|
||||
(original 1238×377). Licensed under **CC BY-SA 4.0** — the same license
|
||||
as the existing `jellyfin.svg`. Attribution and license terms are recorded
|
||||
in `apps/ui/public/icons_logos/emby.png.LICENSE.txt`.
|
||||
|
||||
The original wordmark was cropped because the UI selector and switch-preview
|
||||
modal both render the logo into a 1:1 square slot; the wide wordmark was
|
||||
getting squashed.
|
||||
|
||||
---
|
||||
|
||||
## CodeQL alerts (false positives)
|
||||
|
||||
CodeQL's `js/server-side-request-forgery` query flags the
|
||||
`axios.create({baseURL: userUrl})` call in `emby-api.helper.ts`. These
|
||||
alerts will be **dismissed in the GitHub Security UI as "Won't fix — by
|
||||
design"**, not patched, for the following reason:
|
||||
|
||||
Maintainerr is a self-hosted application whose entire purpose is to talk
|
||||
to the user's own media server. The user supplying the URL is the same
|
||||
person running the Maintainerr instance — there is no untrusted
|
||||
operator/attacker separation. The existing Plex and Jellyfin adapters have
|
||||
the exact same data flow (user URL → HTTP request host); CodeQL doesn't
|
||||
flag them only because the URL crosses an external SDK boundary
|
||||
(`new PlexApi({hostname})`, `jellyfin.createApi(url, key)`) that the
|
||||
analysis doesn't trace into. The Emby helper is in-repo so CodeQL sees
|
||||
the full chain.
|
||||
|
||||
Hostname blocklists (rejecting `192.168.*` / `10.*` / `fc*` / `fe80:*`,
|
||||
etc.) were **explicitly considered and rejected** — they would break the
|
||||
documented LAN use case for what is a non-issue here. The maintainer's
|
||||
own words: _"this is intended to be run locally so fixes like this seems
|
||||
super messy and hacky"_.
|
||||
|
||||
---
|
||||
|
||||
## Testing scripts & how to verify locally
|
||||
|
||||
The PR description has the recommended quick-test path (replace `:latest`
|
||||
with `:pr-2911` in your `docker-compose.yml` and restart). For full local
|
||||
dev verification:
|
||||
|
||||
1. Install Emby Server. On a Codespace, the `.deb` install pattern works:
|
||||
```bash
|
||||
curl -sSL -o emby.deb https://github.com/MediaBrowser/Emby.Releases/releases/download/4.9.3.0/emby-server-deb_4.9.3.0_amd64.deb
|
||||
sudo dpkg -i emby.deb
|
||||
apt-get -y install ffmpeg # for scanning sample files
|
||||
sudo mkdir -p /var/lib/emby && sudo chown -R emby:emby /var/lib/emby
|
||||
sudo su -s /bin/bash emby -c "/opt/emby-server/bin/emby-server" &
|
||||
```
|
||||
2. Complete first-run wizard via the API (no UI interaction required):
|
||||
```bash
|
||||
curl -X POST http://localhost:8096/Startup/User -H "Content-Type: application/json" \
|
||||
-d '{"Name":"maintainerrAdmin","Password":"maintainerr123"}'
|
||||
curl -X POST http://localhost:8096/Startup/Complete -H "Content-Type: application/json" -d '{}'
|
||||
```
|
||||
3. Authenticate to get an admin access token, then issue a long-lived API key:
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8096/Users/AuthenticateByName \
|
||||
-H 'X-Emby-Authorization: MediaBrowser Client="Setup", Device="Bootstrap", DeviceId="setup", Version="1.0.0"' \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Username":"maintainerrAdmin","Pw":"maintainerr123"}' | jq -r .AccessToken)
|
||||
curl -X POST "http://localhost:8096/Auth/Keys?App=Maintainerr" -H "X-Emby-Token: $TOKEN"
|
||||
APIKEY=$(curl -s http://localhost:8096/Auth/Keys -H "X-Emby-Token: $TOKEN" | jq -r '.Items[0].AccessToken')
|
||||
```
|
||||
4. Create a sample media library:
|
||||
```bash
|
||||
sudo mkdir -p /opt/emby-media/movies /opt/emby-media/shows
|
||||
# Generate tiny real videos so Emby's scanner picks them up
|
||||
for f in "Sample Movie One (2024).mkv" "Sample Movie Two (2024).mkv"; do
|
||||
sudo bash -c "ffmpeg -y -f lavfi -i color=black:s=160x90:d=1 -c:v libx264 -t 1 '/opt/emby-media/movies/$f'"
|
||||
done
|
||||
sudo chown -R emby:emby /opt/emby-media
|
||||
curl -X POST "http://localhost:8096/Library/VirtualFolders?refreshLibrary=true&name=Movies&collectionType=movies&paths=%2Fopt%2Femby-media%2Fmovies" \
|
||||
-H "X-Emby-Token: $APIKEY"
|
||||
```
|
||||
5. `yarn dev` from the repo root and pick **Emby** in the media-server
|
||||
selector. Use the credentials login or paste the API key directly.
|
||||
|
||||
---
|
||||
|
||||
## Known gaps / future work
|
||||
|
||||
These are deferred deliberately and tracked in code via `TODO(emby-server-test):`:
|
||||
|
||||
- **Server-dependent test specs**: no `emby-adapter.service.spec.ts`,
|
||||
`emby-getter.service.spec.ts`, or `emby-overlay.provider.spec.ts`.
|
||||
Writing synthetic fixtures against unverified endpoints would just
|
||||
codify assumptions about Emby's actual HTTP response shapes. The
|
||||
in-process branching that does _not_ depend on Emby HTTP is covered —
|
||||
see [Tests landed in this PR](#tests-landed-in-this-pr). Add the
|
||||
server-dependent specs once the live endpoints have been confirmed
|
||||
and the actual response shapes are known.
|
||||
- **Per-user fan-out batching**: `getDescendantEpisodeWatchers`,
|
||||
`getItemFavoritedBy`, `getTotalPlayCount` use a sequential `for` loop
|
||||
over users. `JellyfinAdapterService` uses `mapUsersBatched` with
|
||||
`Promise.allSettled` for rate-limited parallelism. Worth porting if
|
||||
performance becomes an issue on servers with many users.
|
||||
- **Smart collection support**: Emby has no native smart collections
|
||||
(verified against Emby's official Collections docs and a 2018 forum
|
||||
thread where Luke/CEO said it was a planned feature; closed unimplemented
|
||||
in 2021). If Emby ever ships them, revisit `EmbyMapper.toMediaCollection`
|
||||
and `MEDIA_SERVER_FEATURES[EMBY]`.
|
||||
- **Emby Connect**: see [above](#why-emby-connect-is-not-implemented).
|
||||
Needs Premiere-enabled test server to verify endpoints before shipping.
|
||||
- **Overlay random-item helpers**: `EmbyOverlayProvider.getRandomItem` /
|
||||
`getRandomEpisode` / `downloadImage` are stubbed.
|
||||
- **Comment "Jellyfin" residue**: two header references in
|
||||
`emby-getter.service.ts` deliberately name Jellyfin as the prior art
|
||||
the file mirrors. Not a bug, but worth a refresh if the two files
|
||||
diverge meaningfully.
|
||||
|
||||
---
|
||||
|
||||
## Bug history during the PR
|
||||
|
||||
Surfaced and fixed inside this PR:
|
||||
|
||||
1. **`testSetup()` missing EMBY case** (settings.service.ts) — caused 403
|
||||
on `/api/media-server/*` after a successful Emby save. Fixed by adding
|
||||
an EMBY branch alongside Plex and Jellyfin.
|
||||
2. **`testMediaServerConnection()` missing EMBY case** — similar oversight,
|
||||
meant the connection re-verification helper would always return false
|
||||
for Emby.
|
||||
3. **Prettier failures** on the initial commit — 9 files; resolved with
|
||||
`yarn format` plus replacement of `/\/+$/` regex with string ops (also
|
||||
eliminates the CodeQL ReDoS alert).
|
||||
4. **Scope-creep refactors caught in self-review** —
|
||||
`settings.service.ts:autoDetect` had been rewritten as an array-loop
|
||||
when a simple `else if` would do; `factory.ts:resolveServerType` had
|
||||
been rewritten with a count-based check. Both reverted to the minimal
|
||||
additive shape matching the original Plex/Jellyfin pattern.
|
||||
|
||||
Surfaced by an external tester (Nomsplease on the beta-testers Discord channel):
|
||||
|
||||
5. **`sw_*` show-level rule properties returned `null`** — rules like
|
||||
`Emby - Amount of watched episodes equals 0` evaluated to null because
|
||||
`EmbyGetterService` only implemented 16 simple properties and fell
|
||||
through to a `default:` TODO for everything else. Fixed by:
|
||||
- Adding 5 new methods to `EmbyAdapterService`:
|
||||
`getChildrenMetadata(parentId, kind?)`, `getItemFavoritedBy`,
|
||||
`getTotalPlayCount`, `getDescendantEpisodeWatchers`, `getPlaylistItems`.
|
||||
- Replacing `EmbyGetterService` with a full 1007-line port of
|
||||
`JellyfinGetterService` (all 50+ case branches + every private helper,
|
||||
same caching semantics, same `Application.EMBY` dispatch).
|
||||
|
||||
Caught while reviewing my own comments:
|
||||
|
||||
6. **False claim that Emby Premiere supports smart collections** — verified
|
||||
against [Emby's Collections docs](https://emby.media/support/articles/Collections.html)
|
||||
and a [closed-as-duplicate 2018 feature request](https://emby.media/community/index.php?/topic/58063-emby-server-option-to-auto-add-to-collections-based-on-pathattribute-matching-rules/).
|
||||
Emby has manual BoxSets and TheMovieDb-driven "Automatic Creation of
|
||||
Collections" (franchise grouping, not filter rules). No native smart
|
||||
collections. Comments in `emby.mapper.ts` and `emby-getter.service.ts`
|
||||
corrected.
|
||||
7. **False claim that Emby retained pre-fork boxset Move endpoints** —
|
||||
verified against an [Emby forum thread](https://emby.media/community/topic/124081-set-display-order-of-a-collection-with-api/)
|
||||
where Luke (Emby CEO) confirmed the API exposes `DisplayOrder =
|
||||
PremiereDate | SortName` only; no item-move/reorder endpoint exists.
|
||||
`EmbyAdapterService.reorderCollectionItems` was rewritten to simply
|
||||
throw "not supported"; the COLLECTION_SORT comment in
|
||||
`features.ts` was corrected to explain why.
|
||||
|
||||
---
|
||||
|
||||
## Commit log
|
||||
|
||||
All commits on the `emby-support` branch after the initial feature commit
|
||||
landed (most recent first):
|
||||
|
||||
```
|
||||
3b64701b fix(emby): correct two false claims about Emby's collection support
|
||||
6b870fc4 style(emby-getter): replace stale Jellyfin attributions with Emby ones
|
||||
674da0df feat(emby): port full JellyfinGetterService to EmbyGetterService
|
||||
5f1e6db0 style: yarn format
|
||||
24ef20b4 Refactor isEmby assignment for readability
|
||||
12608e54 style(rule-executor): break boxset-workaround ternary across lines for readability
|
||||
9f32bc6a refactor(emby): move HTTP client construction into emby-api/
|
||||
0459f85e refactor(rule-executor): separate isJellyfin/isEmby flags
|
||||
bb02133f style(emby): apply prettier and use string-ops trailing-slash trim
|
||||
169b2009 Merge branch 'development' into emby-support
|
||||
f63d2b63 feat(media-server): add Emby as a third supported media server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File inventory
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
apps/server/src/database/migrations/1779021820174-AddEmbySupport.ts
|
||||
apps/server/src/modules/api/emby-api/emby-api.helper.ts
|
||||
apps/server/src/modules/api/media-server/emby/emby-adapter.service.ts
|
||||
apps/server/src/modules/api/media-server/emby/emby.constants.ts
|
||||
apps/server/src/modules/api/media-server/emby/emby.mapper.ts
|
||||
apps/server/src/modules/api/media-server/emby/emby.mapper.spec.ts
|
||||
apps/server/src/modules/api/media-server/emby/emby.module.ts
|
||||
apps/server/src/modules/api/media-server/emby/emby.types.ts
|
||||
apps/server/src/modules/api/media-server/emby/index.ts
|
||||
apps/server/src/modules/overlays/providers/emby-overlay.provider.ts
|
||||
apps/server/src/modules/rules/getter/emby-getter.service.ts
|
||||
apps/server/src/modules/rules/rules.service.cacheReset.spec.ts
|
||||
apps/ui/public/icons_logos/emby.png
|
||||
apps/ui/public/icons_logos/emby.png.LICENSE.txt
|
||||
apps/ui/src/components/Login/Emby/EmbyLoginButton.tsx
|
||||
apps/ui/src/components/Settings/Emby/index.tsx
|
||||
packages/contracts/src/media-server/emby/embySetting.ts
|
||||
packages/contracts/src/media-server/emby/index.ts
|
||||
```
|
||||
|
||||
### Modified files
|
||||
|
||||
See the "Cross-cutting touchpoints" table in
|
||||
[Architecture & layering](#architecture--layering) above for the full list
|
||||
with per-file rationale.
|
||||
|
||||
---
|
||||
|
||||
## Pointer for future contributors
|
||||
|
||||
The single highest-value follow-up is **running the PR Docker image against
|
||||
a real Emby server** (both free tier and Premiere). The verified surface is
|
||||
narrow; the scaffolded surface is wide. When a tester reports a bug:
|
||||
|
||||
1. Reproduce against the same Emby version
|
||||
2. Open the matching `TODO(emby-server-test):` site in the adapter or getter
|
||||
3. Hit the endpoint directly with `curl` against the Emby server to see the
|
||||
real response shape
|
||||
4. Fix the call site, run `yarn test`, push
|
||||
|
||||
The Jellyfin adapter and getter are the closest reference for "what
|
||||
correct looks like" given the shared backend lineage — but never assume
|
||||
parity. Verify, then code.
|
||||
@@ -0,0 +1,21 @@
|
||||
import z from 'zod'
|
||||
import { serviceUrlSchema } from '../../settings/serviceUrl'
|
||||
|
||||
/**
|
||||
* Schema for Emby server settings
|
||||
*/
|
||||
export const embySettingSchema = z.object({
|
||||
emby_url: serviceUrlSchema,
|
||||
emby_api_key: z.string().trim().min(1, 'API key is required'),
|
||||
emby_user_id: z.string().trim().optional(),
|
||||
})
|
||||
|
||||
export type EmbySetting = z.infer<typeof embySettingSchema>
|
||||
|
||||
export const embyLoginRequestSchema = z.object({
|
||||
emby_url: serviceUrlSchema,
|
||||
username: z.string().trim().min(1, 'Username is required'),
|
||||
password: z.string(),
|
||||
})
|
||||
|
||||
export type EmbyLoginRequest = z.infer<typeof embyLoginRequestSchema>
|
||||
@@ -0,0 +1 @@
|
||||
export * from './embySetting'
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum MediaServerType {
|
||||
PLEX = 'plex',
|
||||
JELLYFIN = 'jellyfin',
|
||||
EMBY = 'emby',
|
||||
}
|
||||
|
||||
export type MediaItemType = 'movie' | 'show' | 'season' | 'episode'
|
||||
@@ -45,6 +46,8 @@ export function isValidMediaItemType(type: string): type is MediaItemType {
|
||||
export enum MediaServerFeature {
|
||||
/** Ability to set collection visibility (home/recommended) */
|
||||
COLLECTION_VISIBILITY = 'collection_visibility',
|
||||
/** Adapter creates a collection and seeds initial items in one API call */
|
||||
BULK_COLLECTION_CREATE = 'bulk_collection_create',
|
||||
/** Watchlist functionality via external API (Plex.tv) */
|
||||
WATCHLIST = 'watchlist',
|
||||
/** Central watch history endpoint (vs per-user iteration) */
|
||||
|
||||
@@ -9,6 +9,7 @@ export const MEDIA_SERVER_FEATURES: Record<
|
||||
ReadonlySet<MediaServerFeature>
|
||||
> = {
|
||||
[MediaServerType.PLEX]: new Set([
|
||||
MediaServerFeature.BULK_COLLECTION_CREATE,
|
||||
MediaServerFeature.COLLECTION_VISIBILITY,
|
||||
MediaServerFeature.WATCHLIST,
|
||||
MediaServerFeature.CENTRAL_WATCH_HISTORY,
|
||||
@@ -18,6 +19,7 @@ export const MEDIA_SERVER_FEATURES: Record<
|
||||
MediaServerFeature.COLLECTION_SORT,
|
||||
]),
|
||||
[MediaServerType.JELLYFIN]: new Set([
|
||||
MediaServerFeature.BULK_COLLECTION_CREATE,
|
||||
MediaServerFeature.LABELS, // Tags in Jellyfin
|
||||
MediaServerFeature.PLAYLISTS,
|
||||
MediaServerFeature.COLLECTION_POSTER,
|
||||
@@ -26,6 +28,19 @@ export const MEDIA_SERVER_FEATURES: Record<
|
||||
// Note: CENTRAL_WATCH_HISTORY not supported (requires user iteration)
|
||||
// Note: COLLECTION_SORT not supported — no boxset reorder API; ForcedSortName has global side-effects.
|
||||
]),
|
||||
[MediaServerType.EMBY]: new Set([
|
||||
MediaServerFeature.BULK_COLLECTION_CREATE,
|
||||
MediaServerFeature.LABELS,
|
||||
MediaServerFeature.PLAYLISTS,
|
||||
MediaServerFeature.COLLECTION_POSTER,
|
||||
// Conservative defaults mirroring Jellyfin:
|
||||
// - COLLECTION_VISIBILITY: Emby has no Plex-style home/recommended pinning.
|
||||
// - WATCHLIST: no public watchlist API.
|
||||
// - CENTRAL_WATCH_HISTORY: same per-user iteration model as Jellyfin.
|
||||
// - COLLECTION_SORT: Emby exposes DisplayOrder = PremiereDate | SortName
|
||||
// on a BoxSet but no item-move/reorder endpoint, so Maintainerr's
|
||||
// "push an explicit ordered list of item IDs" contract isn't satisfiable.
|
||||
]),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './emby'
|
||||
export * from './enums'
|
||||
export * from './features'
|
||||
export * from './jellyfin'
|
||||
|
||||
@@ -227,6 +227,7 @@ export interface CreateCollectionParams {
|
||||
summary?: string
|
||||
type: MediaItemType
|
||||
sortTitle?: string
|
||||
initialItemIds?: string[]
|
||||
}
|
||||
|
||||
/** Plex-only visibility settings */
|
||||
|
||||
@@ -68,6 +68,7 @@ export enum Application {
|
||||
SEERR = 3,
|
||||
TAUTULLI = 4,
|
||||
JELLYFIN = 6,
|
||||
EMBY = 7,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +81,7 @@ export const ApplicationNames: Record<Application, string> = {
|
||||
[Application.SEERR]: 'Seerr',
|
||||
[Application.TAUTULLI]: 'Tautulli',
|
||||
[Application.JELLYFIN]: 'Jellyfin',
|
||||
[Application.EMBY]: 'Emby',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7832,21 +7832,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"baseline-browser-mapping@npm:^2.10.12":
|
||||
version: 2.10.19
|
||||
resolution: "baseline-browser-mapping@npm:2.10.19"
|
||||
"baseline-browser-mapping@npm:^2.10.12, baseline-browser-mapping@npm:^2.9.0":
|
||||
version: 2.10.31
|
||||
resolution: "baseline-browser-mapping@npm:2.10.31"
|
||||
bin:
|
||||
baseline-browser-mapping: dist/cli.cjs
|
||||
checksum: 10c0/d7ab47484477d16e29b711b74c56791d751701e796a133fcd6b72cf7f73f95cb72c0bc02070c3a93e78210cd02a4dc6d573191ce6920b863b3a9d8e9aa893bcf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"baseline-browser-mapping@npm:^2.9.0":
|
||||
version: 2.9.7
|
||||
resolution: "baseline-browser-mapping@npm:2.9.7"
|
||||
bin:
|
||||
baseline-browser-mapping: dist/cli.js
|
||||
checksum: 10c0/500af82926d71d23fab20bcf821eb27deeaad45d1a01bd33d2dea7aab6114149068fa0d42bb9c5c18657e996b6e8063b84612c8fb8ac8ba6c6c6028fa4930ed1
|
||||
checksum: 10c0/ecb6a10b4bb62d8660e3a4c525acdae2b1c7f68e4ec1e03f473b2050781b7131fbd562a200489ae987d11eb8ae85ec95d3ac77d9d84b4afe6548dfdfdceb6734
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8288,24 +8279,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"caniuse-lite@npm:^1.0.30001663":
|
||||
version: 1.0.30001755
|
||||
resolution: "caniuse-lite@npm:1.0.30001755"
|
||||
checksum: 10c0/7b8e32a4ec307b50f557d30176651cf69f20a0ea4de6f5f34149ea65a1f0cfcc0677b403484aea3661c7469ab11f2df6528027b9ec2d0265635ede9d5b517380
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"caniuse-lite@npm:^1.0.30001759":
|
||||
version: 1.0.30001760
|
||||
resolution: "caniuse-lite@npm:1.0.30001760"
|
||||
checksum: 10c0/cee26dff5c5b15ba073ab230200e43c0d4e88dc3bac0afe0c9ab963df70aaa876c3e513dde42a027f317136bf6e274818d77b073708b74c5807dfad33c029d3c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"caniuse-lite@npm:^1.0.30001782, caniuse-lite@npm:^1.0.30001787":
|
||||
version: 1.0.30001788
|
||||
resolution: "caniuse-lite@npm:1.0.30001788"
|
||||
checksum: 10c0/d3c4695d0e7a1e95194cc5072e26db59cbcd25adfff64253859213c1a04ce9bc17f7b8ec8b11908ac1ecc6c1a0caf95fae0aec064a64b8df03286dffa629ce8a
|
||||
"caniuse-lite@npm:^1.0.30001663, caniuse-lite@npm:^1.0.30001759, caniuse-lite@npm:^1.0.30001782, caniuse-lite@npm:^1.0.30001787":
|
||||
version: 1.0.30001793
|
||||
resolution: "caniuse-lite@npm:1.0.30001793"
|
||||
checksum: 10c0/bee8f8b55d1ccdb2076b7355c06fd01916952eadd76b828e4d5fb9ac62d17ec7db0e2b7c326b923478b93526ad1ff74f189cf40c06de0e4a5edbc677009b97fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user