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

This commit is contained in:
enoch85
2026-05-19 19:00:57 +02:00
committed by GitHub
parent 97745172ed
commit 36dc409a93
66 changed files with 7043 additions and 109 deletions
+9 -7
View File
@@ -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,
});
}
}
+3 -1
View File
@@ -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)}&sectionId=${
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.
+145
View File
@@ -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 &rarr; Advanced &rarr; API Keys</strong> and
create a new key named &quot;Maintainerr&quot;. 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 = {
+14 -1
View File
@@ -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>
+7
View File
@@ -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,
+8
View File
@@ -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,
+370
View File
@@ -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.
+555
View File
@@ -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',
}
/**
+8 -31
View File
@@ -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