feat(streamystats): add Jellyfin-only Streamystats integration (#2923)

This commit is contained in:
enoch85
2026-05-19 22:26:28 +02:00
committed by GitHub
parent 8d1f53bf88
commit 04f8f1f91e
41 changed files with 1954 additions and 118 deletions
+5 -1
View File
@@ -47,6 +47,7 @@ flowchart LR
API --> Servarr["Radarr / Sonarr"]
API --> Seerr["Seerr"]
API --> Tautulli["Tautulli"]
API --> Streamystats["Streamystats"]
API --> Metadata["TMDB / TVDB"]
API --> GitHub["GitHub releases"]
```
@@ -96,7 +97,7 @@ integrations, and production static serving.
`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,
APIs, including Plex legacy routes, Servarr, Seerr, Tautulli, Streamystats, TMDB, TVDB,
GitHub, external API, internal API, and shared request/cache helpers.
- `src/modules/rules/` evaluates rule groups against media-server and external
service data.
@@ -147,6 +148,9 @@ Maintainerr integrates with:
- Radarr and Sonarr for unmonitoring, deleting, and quality profile actions.
- Seerr-compatible services for request cleanup.
- Tautulli for Plex analytics and rule data.
- Streamystats for Jellyfin item-level analytics surfaced on the media modal.
Authentication reuses the configured Jellyfin API key. Emby is not supported
upstream.
- TMDB and TVDB for metadata resolution.
- GitHub for release/version checks.
- Notification providers such as Discord, Slack, Telegram, Pushover, Gotify,
+1
View File
@@ -49,6 +49,7 @@ Currently, <b>Maintainerr</b> supports rule parameters from the following apps:
- [Radarr](https://radarr.video/)
- [Sonarr](https://sonarr.tv/)
- [Tautulli](https://tautulli.com/)
- [Streamystats](https://github.com/fredrikburmester/streamystats) (Jellyfin only)
# Preview
+5
View File
@@ -14,6 +14,8 @@ import { PlexApiModule } from '../modules/api/plex-api/plex-api.module';
import { SeerrApiModule } from '../modules/api/seerr-api/seerr-api.module';
import { SeerrApiService } from '../modules/api/seerr-api/seerr-api.service';
import { ServarrApiModule } from '../modules/api/servarr-api/servarr-api.module';
import { StreamystatsApiModule } from '../modules/api/streamystats-api/streamystats-api.module';
import { StreamystatsApiService } from '../modules/api/streamystats-api/streamystats-api.service';
import { TautulliApiModule } from '../modules/api/tautulli-api/tautulli-api.module';
import { TautulliApiService } from '../modules/api/tautulli-api/tautulli-api.service';
import { CollectionsModule } from '../modules/collections/collections.module';
@@ -48,6 +50,7 @@ import ormConfig from './config/typeOrmConfig';
ServarrApiModule,
SeerrApiModule,
TautulliApiModule,
StreamystatsApiModule,
RulesModule,
CollectionsModule,
NotificationsModule,
@@ -85,6 +88,7 @@ export class AppModule implements OnModuleInit {
private readonly mediaServerFactory: MediaServerFactory,
private readonly seerrApi: SeerrApiService,
private readonly tautulliApi: TautulliApiService,
private readonly streamystatsApi: StreamystatsApiService,
private readonly notificationService: NotificationService,
) {}
async onModuleInit() {
@@ -96,6 +100,7 @@ export class AppModule implements OnModuleInit {
this.seerrApi.init();
this.tautulliApi.init();
this.streamystatsApi.init();
// intialize notification agents
await this.notificationService.registerConfiguredAgents();
@@ -0,0 +1,230 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddStreamystatsSettings1779211639861 implements MigrationInterface {
name = 'AddStreamystatsSettings1779211639861';
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,
"streamystats_url" 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",
"emby_url",
"emby_api_key",
"emby_user_id",
"emby_server_name"
)
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",
"emby_url",
"emby_api_key",
"emby_user_id",
"emby_server_name"
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),
"emby_url" varchar,
"emby_api_key" varchar,
"emby_user_id" varchar,
"emby_server_name" varchar
)
`);
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",
"emby_url",
"emby_api_key",
"emby_user_id",
"emby_server_name"
)
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",
"emby_url",
"emby_api_key",
"emby_user_id",
"emby_server_name"
FROM "temporary_settings"
`);
await queryRunner.query(`
DROP TABLE "temporary_settings"
`);
}
}
+2
View File
@@ -8,6 +8,7 @@ type AvailableCacheIds =
| 'seerr'
| 'plexcommunity'
| 'tautulli'
| 'streamystats'
| 'github'
| 'jellyfin'
| 'emby';
@@ -71,6 +72,7 @@ class CacheManager {
'plexcommunity',
),
tautulli: new Cache('tautulli', 'Tautulli API', 'tautulli'),
streamystats: new Cache('streamystats', 'Streamystats API', 'streamystats'),
github: new Cache('github', 'GitHub API', 'github', {
stdTtl: 86400, // 24 hours
checkPeriod: 60 * 60, // Check every hour
@@ -99,7 +99,11 @@ describe('MediaServerFactory', () => {
it('returns and initializes Jellyfin adapter when configured', async () => {
settingsService.getSettings.mockResolvedValue(
createSettings({ media_server_type: MediaServerType.JELLYFIN }),
createSettings({
media_server_type: MediaServerType.JELLYFIN,
jellyfin_url: 'http://jellyfin.local:8096',
jellyfin_api_key: 'key',
}),
);
jellyfinAdapter.isSetup
.mockReturnValueOnce(false)
@@ -113,7 +117,11 @@ describe('MediaServerFactory', () => {
it('throws when Jellyfin remains uninitialized after initialize', async () => {
settingsService.getSettings.mockResolvedValue(
createSettings({ media_server_type: MediaServerType.JELLYFIN }),
createSettings({
media_server_type: MediaServerType.JELLYFIN,
jellyfin_url: 'http://jellyfin.local:8096',
jellyfin_api_key: 'key',
}),
);
jellyfinAdapter.isSetup.mockReturnValue(false);
@@ -122,9 +130,26 @@ describe('MediaServerFactory', () => {
);
});
it('throws ServiceUnavailableException when the configured server has no credentials yet', async () => {
settingsService.getSettings.mockResolvedValue(
createSettings({ media_server_type: MediaServerType.JELLYFIN }),
);
await expect(factory.getService()).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(jellyfinAdapter.initialize).not.toHaveBeenCalled();
});
it('throws when Plex remains uninitialized after initialize', async () => {
settingsService.getSettings.mockResolvedValue(
createSettings({ media_server_type: MediaServerType.PLEX }),
createSettings({
media_server_type: MediaServerType.PLEX,
plex_hostname: 'plex.local',
plex_name: 'Plex',
plex_port: 32400,
plex_auth_token: 'token',
}),
);
plexAdapter.isSetup.mockReturnValue(false);
@@ -135,7 +160,13 @@ describe('MediaServerFactory', () => {
it('returns Plex adapter without initialization if already setup', async () => {
settingsService.getSettings.mockResolvedValue(
createSettings({ media_server_type: MediaServerType.PLEX }),
createSettings({
media_server_type: MediaServerType.PLEX,
plex_hostname: 'plex.local',
plex_name: 'Plex',
plex_port: 32400,
plex_auth_token: 'token',
}),
);
plexAdapter.isSetup.mockReturnValue(true);
@@ -177,7 +208,11 @@ describe('MediaServerFactory', () => {
it('returns and initializes Emby adapter when configured', async () => {
settingsService.getSettings.mockResolvedValue(
createSettings({ media_server_type: MediaServerType.EMBY }),
createSettings({
media_server_type: MediaServerType.EMBY,
emby_url: 'http://emby.local:8096',
emby_api_key: 'key',
}),
);
embyAdapter.isSetup.mockReturnValueOnce(false).mockReturnValueOnce(true);
@@ -189,7 +224,11 @@ describe('MediaServerFactory', () => {
it('throws when Emby remains uninitialized after initialize', async () => {
settingsService.getSettings.mockResolvedValue(
createSettings({ media_server_type: MediaServerType.EMBY }),
createSettings({
media_server_type: MediaServerType.EMBY,
emby_url: 'http://emby.local:8096',
emby_api_key: 'key',
}),
);
embyAdapter.isSetup.mockReturnValue(false);
@@ -83,9 +83,45 @@ export class MediaServerFactory {
throw new Error('No media server type configured');
}
// Surface the "selected but not yet configured" transition state as a
// typed, recognizable exception. This window opens between a media-server
// switch (which nulls the old credentials) and the user saving the new
// credentials. Callers can treat this as transient rather than a real
// failure (see NotificationService.transformMessageContent).
const settings = await this.settingsService.getSettings();
if (
isSettings(settings) &&
!this.areCredentialsPresent(serverType, settings)
) {
throw new ServiceUnavailableException(
`${serverType} is selected but credentials are not yet configured.`,
);
}
return await this.getServiceByType(serverType);
}
private areCredentialsPresent(
type: MediaServerType,
settings: Settings,
): boolean {
switch (type) {
case MediaServerType.JELLYFIN:
return Boolean(settings.jellyfin_url && settings.jellyfin_api_key);
case MediaServerType.PLEX:
return Boolean(
settings.plex_hostname &&
settings.plex_name &&
settings.plex_port &&
settings.plex_auth_token,
);
case MediaServerType.EMBY:
return Boolean(settings.emby_url && settings.emby_api_key);
default:
return false;
}
}
/**
* Get a specific media server service by type.
* Useful for testing or when the type is known.
@@ -0,0 +1,18 @@
import { MaintainerrLogger } from '../../../logging/logs.service';
import { ExternalApiService } from '../../external-api/external-api.service';
import cacheManager from '../../lib/cache';
export class StreamystatsApi extends ExternalApiService {
constructor(
{ url, apiKey }: { url: string; apiKey: string },
protected readonly logger: MaintainerrLogger,
) {
logger.setContext(StreamystatsApi.name);
super(url, {}, logger, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
nodeCache: cacheManager.getCache('streamystats').data,
});
}
}
@@ -0,0 +1,64 @@
import {
MediaServerType,
StreamystatsItemDetails,
} from '@maintainerr/contracts';
import {
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
} from '@nestjs/common';
import { SettingsService } from '../../settings/settings.service';
import { StreamystatsApiService } from './streamystats-api.service';
interface StreamystatsInfoResponse {
url: string;
serverId: number | null;
}
@Controller('api/streamystats')
export class StreamystatsApiController {
constructor(
private readonly streamystatsApiService: StreamystatsApiService,
private readonly settingsService: SettingsService,
) {}
@Get('/info')
async getInfo(): Promise<StreamystatsInfoResponse> {
this.assertJellyfinActive();
const url = this.settingsService.streamystats_url;
if (!url || !this.streamystatsApiService.api) {
throw new NotFoundException('Streamystats is not configured');
}
const serverId = await this.streamystatsApiService.getResolvedServerId();
return { url, serverId };
}
@Get('/items/:itemId')
async getItemDetails(
@Param('itemId') itemId: string,
): Promise<StreamystatsItemDetails> {
this.assertJellyfinActive();
if (!this.streamystatsApiService.api) {
throw new NotFoundException('Streamystats is not configured');
}
const details = await this.streamystatsApiService.getItemDetails(itemId);
if (!details) {
throw new NotFoundException(
'No Streamystats data available for this item',
);
}
return details;
}
private assertJellyfinActive(): void {
if (this.settingsService.media_server_type !== MediaServerType.JELLYFIN) {
throw new ForbiddenException(
'Streamystats is only available when Jellyfin is the active media server.',
);
}
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ExternalApiModule } from '../external-api/external-api.module';
import { StreamystatsApiController } from './streamystats-api.controller';
import { StreamystatsApiService } from './streamystats-api.service';
@Module({
imports: [ExternalApiModule],
controllers: [StreamystatsApiController],
providers: [StreamystatsApiService],
exports: [StreamystatsApiService],
})
export class StreamystatsApiModule {}
@@ -0,0 +1,279 @@
import { Mocked, TestBed } from '@suites/unit';
import { SettingsService } from '../../settings/settings.service';
import { StreamystatsApiService } from './streamystats-api.service';
const apiMock = {
get: jest.fn(),
getWithoutCache: jest.fn(),
getRawWithoutCache: jest.fn(),
};
jest.mock('./helpers/streamystats-api.helper', () => ({
StreamystatsApi: jest.fn().mockImplementation(() => apiMock),
}));
describe('StreamystatsApiService', () => {
let service: StreamystatsApiService;
let settings: Mocked<SettingsService>;
beforeEach(async () => {
apiMock.get.mockReset();
apiMock.getWithoutCache.mockReset();
apiMock.getRawWithoutCache.mockReset();
const { unit, unitRef } = await TestBed.solitary(
StreamystatsApiService,
).compile();
service = unit;
settings = unitRef.get(
SettingsService,
) as unknown as Mocked<SettingsService>;
});
describe('init', () => {
it('is a no-op when Streamystats URL is not configured', () => {
Object.assign(settings, {
streamystats_url: undefined,
jellyfin_api_key: 'jellyfin-key',
});
service.init();
expect(service.api).toBeUndefined();
});
it('is a no-op when Jellyfin API key is missing', () => {
Object.assign(settings, {
streamystats_url: 'http://streamystats',
jellyfin_api_key: undefined,
});
service.init();
expect(service.api).toBeUndefined();
});
it('constructs the API client when both settings are present', () => {
Object.assign(settings, {
streamystats_url: 'http://streamystats',
jellyfin_api_key: 'jellyfin-key',
});
service.init();
expect(service.api).toBeDefined();
});
it('clears the cached client and serverId when settings are removed', async () => {
Object.assign(settings, {
streamystats_url: 'http://streamystats',
jellyfin_api_key: 'jellyfin-key',
jellyfin_server_name: 'My Server',
});
service.init();
apiMock.getWithoutCache.mockResolvedValueOnce([
{ id: 7, url: null, name: 'My Server' },
]);
apiMock.get.mockResolvedValueOnce({
item: { id: 'item-1', type: 'Movie' },
totalViews: 1,
totalWatchTime: 0,
completionRate: 0,
firstWatched: null,
lastWatched: null,
usersWatched: [],
watchHistory: [],
watchCountByMonth: [],
});
await service.getItemDetails('item-1');
expect(service.api).toBeDefined();
// Streamystats URL removed
Object.assign(settings, { streamystats_url: undefined });
service.init();
expect(service.api).toBeUndefined();
});
});
describe('getItemDetails', () => {
beforeEach(() => {
Object.assign(settings, {
streamystats_url: 'http://streamystats',
jellyfin_api_key: 'jellyfin-key',
jellyfin_server_name: 'My Server',
jellyfin_url: 'http://jellyfin.local',
});
service.init();
// /api/servers resolution returns a matching server for "My Server"
apiMock.getWithoutCache.mockResolvedValue([
{ id: 7, url: 'http://jellyfin.local', name: 'My Server' },
]);
});
it('returns null when no Streamystats server matches the configured Jellyfin', async () => {
apiMock.getWithoutCache.mockResolvedValueOnce([
{ id: 99, url: 'http://other.local', name: 'Other Server' },
]);
const result = await service.getItemDetails('item-1');
expect(result).toBeNull();
expect(apiMock.get).not.toHaveBeenCalled();
});
it('returns null when the upstream payload fails schema validation', async () => {
apiMock.get.mockResolvedValue({ bogus: true });
const result = await service.getItemDetails('item-1');
expect(result).toBeNull();
});
it('returns parsed details and passes the resolved serverId', async () => {
apiMock.get.mockResolvedValue({
item: { id: 'item-1', name: 'Item One', type: 'Movie' },
totalViews: 3,
totalWatchTime: 5400,
completionRate: 87.5,
firstWatched: '2026-01-01T00:00:00Z',
lastWatched: '2026-05-01T00:00:00Z',
usersWatched: [],
watchHistory: [],
watchCountByMonth: [],
});
const result = await service.getItemDetails('item-1');
expect(result?.totalViews).toBe(3);
expect(result?.completionRate).toBeCloseTo(87.5);
expect(apiMock.get).toHaveBeenCalledWith(
'/api/get-item-details/item-1',
expect.objectContaining({ params: { serverId: '7' } }),
);
});
it('coerces string-encoded aggregation numbers to real numbers', async () => {
apiMock.get.mockResolvedValue({
item: { id: 'item-1', type: 'Series' },
totalViews: '18',
totalWatchTime: '14400',
completionRate: '36.5',
firstWatched: '2026-05-17T13:38:00Z',
lastWatched: '2026-05-18T21:56:00Z',
usersWatched: [
{
user: { id: 'u1', name: 'beate' },
watchCount: '5',
totalWatchTime: '14400',
completionRate: '36.5',
firstWatched: '2026-05-17T13:38:00Z',
lastWatched: '2026-05-18T21:56:00Z',
},
],
watchHistory: [],
watchCountByMonth: [
{
month: '5',
year: '2026',
watchCount: '18',
uniqueUsers: '2',
totalWatchTime: '14400',
},
],
episodeStats: {
totalSeasons: '2',
totalEpisodes: '18',
watchedEpisodes: '8',
watchedSeasons: '1',
},
});
const result = await service.getItemDetails('item-1');
expect(result?.totalViews).toBe(18);
expect(result?.usersWatched[0].watchCount).toBe(5);
expect(result?.watchCountByMonth[0].month).toBe(5);
expect(result?.episodeStats?.watchedEpisodes).toBe(8);
});
it('matches by URL first and falls back to name only when no URL match exists', async () => {
// Two servers share the name "Jellyfin" but only one matches the URL.
apiMock.getWithoutCache.mockResolvedValueOnce([
{ id: 99, url: 'http://other.local', name: 'Jellyfin' },
{ id: 42, url: 'http://jellyfin.local', name: 'Jellyfin' },
]);
Object.assign(settings, {
jellyfin_server_name: 'Jellyfin',
jellyfin_url: 'http://jellyfin.local',
});
service.init();
apiMock.get.mockResolvedValue({
item: { id: 'item-1', type: 'Movie' },
totalViews: 1,
totalWatchTime: 0,
completionRate: 0,
firstWatched: null,
lastWatched: null,
usersWatched: [],
watchHistory: [],
watchCountByMonth: [],
});
await service.getItemDetails('item-1');
expect(apiMock.get).toHaveBeenCalledWith(
'/api/get-item-details/item-1',
expect.objectContaining({ params: { serverId: '42' } }),
);
});
it('caches the resolved serverId across calls', async () => {
apiMock.get.mockResolvedValue({
item: { id: 'item-1', type: 'Movie' },
totalViews: 1,
totalWatchTime: 100,
completionRate: 100,
firstWatched: null,
lastWatched: null,
usersWatched: [],
watchHistory: [],
watchCountByMonth: [],
});
await service.getItemDetails('item-1');
await service.getItemDetails('item-2');
expect(apiMock.getWithoutCache).toHaveBeenCalledTimes(1);
});
});
describe('testConnection', () => {
it('returns OK with the reported version on a healthy probe', async () => {
apiMock.getRawWithoutCache.mockResolvedValue({
data: {
currentVersion: '2.18.0',
latestVersion: '2.18.0',
hasUpdate: false,
buildTime: 0,
},
});
const result = await service.testConnection({
url: 'http://streamystats',
apiKey: 'jellyfin-key',
});
expect(result.status).toBe('OK');
expect(result.message).toBe('2.18.0');
});
it('returns NOK when the probe fails', async () => {
apiMock.getRawWithoutCache.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await service.testConnection({
url: 'http://streamystats',
apiKey: 'jellyfin-key',
});
expect(result.status).toBe('NOK');
});
});
});
@@ -0,0 +1,209 @@
import {
BasicResponseDto,
StreamystatsItemDetails,
streamystatsItemDetailsSchema,
} from '@maintainerr/contracts';
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { SettingsService } from '../../../modules/settings/settings.service';
import {
CONNECTION_TEST_TIMEOUT_MS,
formatConnectionFailureMessage,
logConnectionTestError,
} from '../../../utils/connection-error';
import {
MaintainerrLogger,
MaintainerrLoggerFactory,
} from '../../logging/logs.service';
import { StreamystatsApi } from './helpers/streamystats-api.helper';
interface StreamystatsVersionInfo {
currentVersion: string;
latestVersion: string;
hasUpdate: boolean;
buildTime: number;
}
interface StreamystatsServer {
id: number;
url?: string | null;
name?: string | null;
}
@Injectable()
export class StreamystatsApiService {
api: StreamystatsApi | undefined;
private resolvedServerId: number | null = null;
constructor(
@Inject(forwardRef(() => SettingsService))
private readonly settings: SettingsService,
private readonly logger: MaintainerrLogger,
private readonly loggerFactory: MaintainerrLoggerFactory,
) {
logger.setContext(StreamystatsApiService.name);
}
public init() {
// Always clear cached client + resolved serverId so consumers don't
// operate against stale Streamystats URL or stale Jellyfin credentials
// after any settings change.
this.api = undefined;
this.resolvedServerId = null;
if (!this.settings.streamystats_url || !this.settings.jellyfin_api_key) {
return;
}
this.api = new StreamystatsApi(
{
url: this.settings.streamystats_url,
apiKey: this.settings.jellyfin_api_key,
},
this.loggerFactory.createLogger(),
);
}
public async info(): Promise<StreamystatsVersionInfo | null> {
try {
return await this.api.getWithoutCache<StreamystatsVersionInfo>(
'/api/version',
{
signal: AbortSignal.timeout(CONNECTION_TEST_TIMEOUT_MS),
},
);
} catch (error) {
this.logger.log("Couldn't fetch Streamystats info");
this.logger.debug(error);
return null;
}
}
public async getItemDetails(
itemId: string,
): Promise<StreamystatsItemDetails | null> {
// /api/get-item-details/[itemId] only accepts the internal Streamystats
// serverId (not serverName/serverUrl). Resolve it via /api/servers once
// and cache for subsequent calls.
const serverId = await this.resolveServerId();
if (serverId == null) {
this.logger.warn(
'Skipping Streamystats item details: could not resolve Streamystats serverId for the configured Jellyfin server.',
);
return null;
}
try {
const raw = await this.api.get<unknown>(
`/api/get-item-details/${itemId}`,
{
params: { serverId: String(serverId) },
},
);
if (raw == null) {
return null;
}
const parsed = streamystatsItemDetailsSchema.safeParse(raw);
if (!parsed.success) {
this.logger.warn(
'Streamystats item details payload did not match expected schema',
);
this.logger.debug(parsed.error);
return null;
}
return parsed.data;
} catch (error) {
this.logger.log("Couldn't fetch Streamystats item details");
this.logger.debug(error);
return null;
}
}
public async testConnection(
params: ConstructorParameters<typeof StreamystatsApi>[0],
): Promise<BasicResponseDto> {
const api = new StreamystatsApi(params, this.loggerFactory.createLogger());
try {
const response = await api.getRawWithoutCache<StreamystatsVersionInfo>(
'/api/version',
{
signal: AbortSignal.timeout(CONNECTION_TEST_TIMEOUT_MS),
},
);
const version = response?.data?.currentVersion;
if (!version) {
return {
status: 'NOK',
code: 0,
message:
'Unexpected response from Streamystats. Verify the URL points to a Streamystats instance.',
};
}
return {
status: 'OK',
code: 1,
message: version,
};
} catch (error) {
logConnectionTestError(this.logger, 'Streamystats');
this.logger.debug(error);
return {
status: 'NOK',
code: 0,
message: formatConnectionFailureMessage(
error,
'Failed to connect to Streamystats. Verify URL and that the service is running.',
),
};
}
}
public async getResolvedServerId(): Promise<number | null> {
return this.resolveServerId();
}
private async resolveServerId(): Promise<number | null> {
if (this.resolvedServerId != null) {
return this.resolvedServerId;
}
if (!this.api) {
return null;
}
try {
const servers =
await this.api.getWithoutCache<StreamystatsServer[]>('/api/servers');
if (!Array.isArray(servers)) {
return null;
}
const targetName = this.settings.jellyfin_server_name?.toLowerCase();
const targetUrl = this.settings.jellyfin_url?.replace(/\/+$/, '');
// Match by URL first (more unique than name, which can collide).
// Fall back to name only when no URL match exists.
const byUrl = targetUrl
? servers.find(
(server) => server.url?.replace(/\/+$/, '') === targetUrl,
)
: undefined;
const match =
byUrl ??
(targetName
? servers.find((server) => server.name?.toLowerCase() === targetName)
: undefined);
if (match) {
this.resolvedServerId = match.id;
return match.id;
}
} catch (error) {
this.logger.debug(error);
}
return null;
}
}
@@ -4,7 +4,11 @@ import {
MediaItem,
RuleHandlerQueueStatusUpdatedEventDto,
} from '@maintainerr/contracts';
import { Injectable, OnModuleInit } from '@nestjs/common';
import {
Injectable,
OnModuleInit,
ServiceUnavailableException,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash';
@@ -909,6 +913,16 @@ export class NotificationService implements OnModuleInit {
return message;
} catch (error) {
if (error instanceof ServiceUnavailableException) {
// Media server in transition (switched but not yet configured, or
// mid-switch). Leave the message untransformed; downstream handlers
// will still see the raw template.
this.logger.debug(
'Skipping notification message transformation; media server not ready',
);
this.logger.debug(error);
return message;
}
this.logger.error("Couldn't transform notification message");
this.logger.debug(error);
}
@@ -58,6 +58,8 @@ export class SettingDto {
tautulli_api_key: string;
streamystats_url: string;
collection_handler_job_cron: string;
rules_handler_job_cron: string;
@@ -106,6 +106,9 @@ export class Settings implements SettingDto {
@Column({ nullable: true })
tautulli_api_key: string;
@Column({ nullable: true })
streamystats_url: string;
@Column({ nullable: false, default: CronExpression.EVERY_12_HOURS })
collection_handler_job_cron: string;
@@ -342,6 +342,7 @@ describe('MediaServerSwitchService', () => {
jellyfin_api_key: 'jf-key',
jellyfin_user_id: 'jf-user',
jellyfin_server_name: 'Jellyfin',
streamystats_url: 'http://streamystats.local:3000',
},
clearedFields: {
media_server_type: MediaServerType.PLEX,
@@ -349,6 +350,7 @@ describe('MediaServerSwitchService', () => {
jellyfin_api_key: null,
jellyfin_user_id: null,
jellyfin_server_name: null,
streamystats_url: null,
},
},
{
@@ -416,6 +418,7 @@ describe('MediaServerSwitchService', () => {
jellyfin_api_key: 'jf-key',
jellyfin_user_id: 'jf-user',
jellyfin_server_name: 'Jellyfin',
streamystats_url: 'http://streamystats.local:3000',
},
clearedFields: {
media_server_type: MediaServerType.EMBY,
@@ -423,6 +426,7 @@ describe('MediaServerSwitchService', () => {
jellyfin_api_key: null,
jellyfin_user_id: null,
jellyfin_server_name: null,
streamystats_url: null,
},
},
])(
@@ -408,6 +408,7 @@ export class MediaServerSwitchService {
updatedSettings.jellyfin_api_key = null;
updatedSettings.jellyfin_user_id = null;
updatedSettings.jellyfin_server_name = null;
updatedSettings.streamystats_url = null;
} else if (currentServerType === MediaServerType.EMBY) {
updatedSettings.emby_url = null;
updatedSettings.emby_api_key = null;
@@ -17,6 +17,8 @@ import {
seerrSettingSchema,
SonarrSetting,
sonarrSettingSchema,
StreamystatsSetting,
streamystatsSettingSchema,
SwitchMediaServerRequest,
SwitchMediaServerResponse,
switchMediaServerSchema,
@@ -33,6 +35,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Header,
Param,
@@ -221,6 +224,55 @@ export class SettingsController {
return this.settingsService.testTautulli(payload);
}
@Get('/streamystats')
async getStreamystatsSetting(): Promise<
StreamystatsSetting | BasicResponseDto
> {
const settings = await this.settingsService.getSettings();
if (!(settings instanceof Settings)) {
return settings;
}
this.assertJellyfinActive();
return {
url: settings.streamystats_url,
};
}
@Post('/streamystats')
async updateStreamystatsSetting(
@Body(new ZodValidationPipe(streamystatsSettingSchema))
payload: StreamystatsSetting,
) {
this.assertJellyfinActive();
return await this.settingsService.updateStreamystatsSetting(payload);
}
@Delete('/streamystats')
async removeStreamystatsSetting() {
this.assertJellyfinActive();
return await this.settingsService.removeStreamystatsSetting();
}
@Post('/test/streamystats')
testStreamystats(
@Body(new ZodValidationPipe(streamystatsSettingSchema))
payload: StreamystatsSetting,
): Promise<BasicResponseDto> {
this.assertJellyfinActive();
return this.settingsService.testStreamystats(payload);
}
private assertJellyfinActive(): void {
if (this.settingsService.media_server_type !== MediaServerType.JELLYFIN) {
throw new ForbiddenException(
'Streamystats is only available when Jellyfin is the active media server.',
);
}
}
@Get('/tmdb')
async getTmdbSetting(): Promise<TmdbSettingForm | BasicResponseDto> {
const settings = await this.settingsService.getSettings();
@@ -5,6 +5,7 @@ import { MediaServerModule } from '../api/media-server/media-server.module';
import { SeerrApiModule } from '../api/seerr-api/seerr-api.module';
import { PlexApiModule } from '../api/plex-api/plex-api.module';
import { ServarrApiModule } from '../api/servarr-api/servarr-api.module';
import { StreamystatsApiModule } from '../api/streamystats-api/streamystats-api.module';
import { TautulliApiModule } from '../api/tautulli-api/tautulli-api.module';
import { TmdbApiModule } from '../api/tmdb-api/tmdb.module';
import { TvdbApiModule } from '../api/tvdb-api/tvdb.module';
@@ -32,6 +33,7 @@ import { SettingsService } from './settings.service';
forwardRef(() => ServarrApiModule),
forwardRef(() => SeerrApiModule),
forwardRef(() => TautulliApiModule),
forwardRef(() => StreamystatsApiModule),
forwardRef(() => TmdbApiModule),
forwardRef(() => TvdbApiModule),
forwardRef(() => InternalApiModule),
@@ -7,6 +7,7 @@ import { MediaServerFactory } from '../api/media-server/media-server.factory';
import { PlexApiService } from '../api/plex-api/plex-api.service';
import { SeerrApiService } from '../api/seerr-api/seerr-api.service';
import { ServarrService } from '../api/servarr-api/servarr.service';
import { StreamystatsApiService } from '../api/streamystats-api/streamystats-api.service';
import { TautulliApiService } from '../api/tautulli-api/tautulli-api.service';
import { MaintainerrLogger } from '../logging/logs.service';
import { RadarrSettings } from './entities/radarr_settings.entities';
@@ -21,6 +22,7 @@ describe('SettingsService', () => {
let plexApi: Mocked<PlexApiService>;
let seerr: Mocked<SeerrApiService>;
let tautulli: Mocked<TautulliApiService>;
let streamystats: Mocked<StreamystatsApiService>;
let internalApi: Mocked<InternalApiService>;
let eventEmitter: Mocked<EventEmitter2>;
@@ -59,6 +61,7 @@ describe('SettingsService', () => {
unitRef.get(ServarrService);
seerr = unitRef.get(SeerrApiService);
tautulli = unitRef.get(TautulliApiService);
streamystats = unitRef.get(StreamystatsApiService);
internalApi = unitRef.get(InternalApiService);
eventEmitter = unitRef.get(EventEmitter2);
unitRef.get(MaintainerrLogger);
@@ -73,6 +76,7 @@ describe('SettingsService', () => {
plexApi.getStatus.mockResolvedValue({ version: '1.0.0' } as never);
seerr.init.mockImplementation();
tautulli.init.mockImplementation();
streamystats.init.mockImplementation();
internalApi.init.mockImplementation();
eventEmitter.emit.mockImplementation();
});
@@ -221,4 +225,47 @@ describe('SettingsService', () => {
});
expect(plexApi.validateAuthToken).not.toHaveBeenCalled();
});
it('re-initialises Streamystats after a successful Jellyfin save', async () => {
settingsRepo.findOne.mockResolvedValue(
createSettings({ media_server_type: MediaServerType.JELLYFIN }),
);
mediaServerFactory.testJellyfinConnection.mockResolvedValue({
success: true,
serverName: 'My Server',
version: '10.11.8',
users: [{ id: 'user-1', name: 'admin' }],
});
mediaServerFactory.uninitializeServer.mockImplementation();
const result = await service.saveJellyfinSettings({
jellyfin_url: 'http://jellyfin.local',
jellyfin_api_key: 'jf-key',
jellyfin_user_id: 'user-1',
});
expect(result.code).toBe(1);
// Streamystats reuses jellyfin_api_key + jellyfin_server_name; it must
// re-init when those change.
expect(streamystats.init).toHaveBeenCalled();
});
it('clears streamystats_url and re-initialises Streamystats when Jellyfin is removed', async () => {
settingsRepo.findOne.mockResolvedValue(
createSettings({
media_server_type: MediaServerType.JELLYFIN,
jellyfin_url: 'http://jellyfin.local',
jellyfin_api_key: 'jf-key',
streamystats_url: 'http://streamystats.local',
}),
);
mediaServerFactory.uninitializeServer.mockImplementation();
const result = await service.removeJellyfinSettings();
expect(result.code).toBe(1);
const saved = settingsRepo.save.mock.calls.at(-1)?.[0] as Settings;
expect(saved.streamystats_url).toBeNull();
expect(streamystats.init).toHaveBeenCalled();
});
});
@@ -6,6 +6,7 @@ import {
MediaServerType,
MetadataProviderPreference,
SeerrSetting,
StreamystatsSetting,
TautulliSetting,
} from '@maintainerr/contracts';
import { forwardRef, Inject, Injectable } from '@nestjs/common';
@@ -25,6 +26,7 @@ import { MediaServerFactory } from '../api/media-server/media-server.factory';
import { PlexApiService } from '../api/plex-api/plex-api.service';
import { SeerrApiService } from '../api/seerr-api/seerr-api.service';
import { ServarrService } from '../api/servarr-api/servarr.service';
import { StreamystatsApiService } from '../api/streamystats-api/streamystats-api.service';
import { TautulliApiService } from '../api/tautulli-api/tautulli-api.service';
import { MaintainerrLogger } from '../logging/logs.service';
import {
@@ -114,6 +116,8 @@ export class SettingsService implements SettingDto {
tautulli_api_key: string;
streamystats_url: string;
collection_handler_job_cron: string;
rules_handler_job_cron: string;
@@ -129,6 +133,8 @@ export class SettingsService implements SettingDto {
private readonly seerr: SeerrApiService,
@Inject(forwardRef(() => TautulliApiService))
private readonly tautulli: TautulliApiService,
@Inject(forwardRef(() => StreamystatsApiService))
private readonly streamystats: StreamystatsApiService,
@Inject(forwardRef(() => InternalApiService))
private readonly internalApi: InternalApiService,
@InjectRepository(Settings)
@@ -179,6 +185,7 @@ export class SettingsService implements SettingDto {
MetadataProviderPreference.TMDB_PRIMARY;
this.tautulli_url = settingsDb?.tautulli_url;
this.tautulli_api_key = settingsDb?.tautulli_api_key;
this.streamystats_url = settingsDb?.streamystats_url;
this.collection_handler_job_cron =
settingsDb?.collection_handler_job_cron;
this.rules_handler_job_cron = settingsDb?.rules_handler_job_cron;
@@ -482,6 +489,48 @@ export class SettingsService implements SettingDto {
}
}
public async removeStreamystatsSetting() {
try {
const settingsDb = await this.settingsRepo.findOne({ where: {} });
await this.saveSettings({
...settingsDb,
streamystats_url: null,
});
this.streamystats_url = null;
this.streamystats.init();
return { status: 'OK', code: 1, message: 'Success' };
} catch (error) {
this.logger.error('Error removing Streamystats settings');
this.logger.debug(error);
return { status: 'NOK', code: 0, message: 'Failed' };
}
}
public async updateStreamystatsSetting(
settings: StreamystatsSetting,
): Promise<BasicResponseDto> {
try {
const settingsDb = await this.settingsRepo.findOne({ where: {} });
await this.saveSettings({
...settingsDb,
streamystats_url: settings.url,
});
this.streamystats_url = settings.url;
this.streamystats.init();
return { status: 'OK', code: 1, message: 'Success' };
} catch (error) {
this.logger.error('Error while updating Streamystats settings');
this.logger.debug(error);
return { status: 'NOK', code: 0, message: 'Failed' };
}
}
public async removeSeerrSetting() {
try {
const settingsDb = await this.settingsRepo.findOne({ where: {} });
@@ -639,6 +688,10 @@ export class SettingsService implements SettingDto {
this.jellyfin_server_name = testResult.serverName;
this.media_server_type = MediaServerType.JELLYFIN;
// Streamystats uses the Jellyfin API key + server identity. Re-init so
// the cached client and resolved serverId track the new credentials.
this.streamystats.init();
this.logger.log('Jellyfin settings saved successfully');
return { status: 'OK', code: 1, message: 'Success' };
} catch (error) {
@@ -701,12 +754,15 @@ export class SettingsService implements SettingDto {
try {
const settingsDb = await this.settingsRepo.findOne({ where: {} });
// Streamystats can't authenticate without Jellyfin credentials; clear
// its URL alongside Jellyfin so we don't leave a half-configured state.
await this.saveSettings({
...settingsDb,
jellyfin_url: null,
jellyfin_api_key: null,
jellyfin_user_id: null,
jellyfin_server_name: null,
streamystats_url: null,
});
// Uninitialize service to clear credentials
@@ -716,6 +772,8 @@ export class SettingsService implements SettingDto {
this.jellyfin_api_key = undefined;
this.jellyfin_user_id = undefined;
this.jellyfin_server_name = undefined;
this.streamystats_url = null;
this.streamystats.init();
this.logger.log('Jellyfin settings cleared');
return { status: 'OK', code: 1, message: 'Success' };
@@ -1318,6 +1376,38 @@ export class SettingsService implements SettingDto {
}
}
public async testStreamystats(
setting?: StreamystatsSetting,
): Promise<BasicResponseDto> {
if (setting) {
return await this.streamystats.testConnection({
url: setting.url,
apiKey: this.jellyfin_api_key,
});
}
try {
const info = await this.streamystats.info();
return info?.currentVersion
? {
status: 'OK',
code: 1,
message: info.currentVersion,
}
: { status: 'NOK', code: 0, message: 'Failure' };
} catch (error) {
logConnectionTestError(this.logger, 'Streamystats');
return {
status: 'NOK',
code: 0,
message: formatConnectionFailureMessage(
error,
'Failed to connect to Streamystats. Verify URL and that the service is running.',
),
};
}
}
public async testRadarr(
id: number | RadarrSettingRawDto,
): Promise<BasicResponseDto> {
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,99 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import StreamystatsStatsPanel from './'
const getApiHandler = vi.fn()
vi.mock('../../../../../utils/ApiHandler', () => ({
default: (url: string) => getApiHandler(url),
}))
describe('StreamystatsStatsPanel', () => {
beforeEach(() => {
cleanup()
getApiHandler.mockReset()
})
afterEach(() => {
cleanup()
})
it('renders aggregate stats and per-user table on a valid response', async () => {
getApiHandler.mockResolvedValue({
item: { id: 'abc' },
totalViews: 12,
totalWatchTime: 36000,
completionRate: 92.4,
firstWatched: '2026-02-01T00:00:00Z',
lastWatched: '2026-05-15T00:00:00Z',
usersWatched: [
{
user: { id: 'u1', name: 'alice' },
watchCount: 5,
totalWatchTime: 18000,
completionRate: 95,
firstWatched: '2026-02-01T00:00:00Z',
lastWatched: '2026-05-15T00:00:00Z',
},
],
watchHistory: [],
watchCountByMonth: [],
})
render(
<StreamystatsStatsPanel
itemId="abc"
itemUrl="http://streamystats.local/servers/1/library/abc"
/>,
)
await waitFor(() => {
expect(screen.getByText('12')).toBeTruthy()
})
expect(screen.getByText('92%')).toBeTruthy()
expect(screen.getByText('alice')).toBeTruthy()
})
it('shows an empty-state message when no data exists for the item (404)', async () => {
getApiHandler.mockRejectedValue(new Error('404 not found'))
render(
<StreamystatsStatsPanel
itemId="abc"
itemUrl="http://streamystats.local/servers/1/library/abc"
/>,
)
await waitFor(() => {
expect(screen.getByText(/no watch history/i)).toBeTruthy()
})
})
it('shows an inline error message when the fetch fails for unexpected reasons', async () => {
getApiHandler.mockRejectedValue(new Error('boom'))
render(
<StreamystatsStatsPanel
itemId="abc"
itemUrl="http://streamystats.local/servers/1/library/abc"
/>,
)
await waitFor(() => {
expect(screen.getByText(/failed to load streamystats/i)).toBeTruthy()
})
})
it('reserves vertical space so the modal layout does not jump', () => {
getApiHandler.mockReturnValue(new Promise(() => {}))
const { container } = render(
<StreamystatsStatsPanel
itemId="abc"
itemUrl="http://streamystats.local/servers/1/library/abc"
/>,
)
const panel = container.firstChild as HTMLElement
expect(panel?.className).toMatch(/min-h-/)
})
})
@@ -0,0 +1,170 @@
import type { StreamystatsItemDetails } from '@maintainerr/contracts'
import { useEffect, useState } from 'react'
import GetApiHandler from '../../../../../utils/ApiHandler'
import BrandLink from '../../../BrandLink'
import { SmallLoadingSpinner } from '../../../LoadingSpinner'
interface StreamystatsStatsPanelProps {
itemId: string
itemUrl: string
}
type FetchState =
| { status: 'loading' }
| { status: 'ready'; data: StreamystatsItemDetails }
| { status: 'empty' }
| { status: 'error'; message: string }
const formatDate = (value: string | null | undefined): string => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleDateString()
}
const formatWatchTime = (seconds: number): string => {
if (!seconds || seconds <= 0) return '0m'
const totalMinutes = Math.round(seconds / 60)
if (totalMinutes < 60) return `${totalMinutes}m`
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
}
const StreamystatsStatsPanel = ({
itemId,
itemUrl,
}: StreamystatsStatsPanelProps) => {
const [state, setState] = useState<FetchState>({ status: 'loading' })
useEffect(() => {
let active = true
GetApiHandler<StreamystatsItemDetails>(`/streamystats/items/${itemId}`)
.then((data) => {
if (!active) return
if (!data) {
setState({ status: 'empty' })
return
}
setState({ status: 'ready', data })
})
.catch((error: unknown) => {
if (!active) return
if (
error instanceof Error &&
/404|not found|no streamystats data/i.test(error.message)
) {
setState({ status: 'empty' })
return
}
setState({
status: 'error',
message: 'Failed to load Streamystats data',
})
})
return () => {
active = false
}
}, [itemId])
return (
<div className="mt-4 min-h-[7.5rem] rounded-xl bg-zinc-900/70 p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-white">Streamystats</p>
<BrandLink external href={itemUrl} className="text-xs no-underline">
View on Streamystats
</BrandLink>
</div>
{state.status === 'loading' ? (
<div className="mt-3 flex h-16 items-center">
<SmallLoadingSpinner className="h-6 w-6" />
</div>
) : state.status === 'error' ? (
<p className="mt-2 text-sm text-error-400">{state.message}</p>
) : state.status === 'empty' ? (
<p className="mt-2 text-sm text-zinc-100/80">
No watch history recorded yet.
</p>
) : (
<div className="mt-2 space-y-3 text-sm text-zinc-100">
<dl className="grid grid-cols-3 gap-3">
<div>
<dt className="text-xs uppercase tracking-wide text-zinc-100/60">
Plays
</dt>
<dd className="font-medium">{state.data.totalViews}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-zinc-100/60">
Completion
</dt>
<dd className="font-medium">
{Math.round(state.data.completionRate)}%
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-zinc-100/60">
Last watched
</dt>
<dd className="font-medium">
{formatDate(state.data.lastWatched)}
</dd>
</div>
</dl>
{state.data.episodeStats ? (
<p className="text-xs text-zinc-100/60">
{state.data.episodeStats.watchedEpisodes}/
{state.data.episodeStats.totalEpisodes} episodes watched ·{' '}
{state.data.episodeStats.watchedSeasons}/
{state.data.episodeStats.totalSeasons} seasons complete
</p>
) : null}
{state.data.usersWatched.length > 0 ? (
<div className="overflow-hidden rounded-lg border border-zinc-700/50">
<table className="w-full text-left text-xs">
<thead className="bg-zinc-800/60 text-zinc-100">
<tr>
<th className="px-2 py-1 font-medium">User</th>
<th className="px-2 py-1 text-right font-medium">Plays</th>
<th className="px-2 py-1 text-right font-medium">
Watch time
</th>
<th className="px-2 py-1 text-right font-medium">
Last watched
</th>
</tr>
</thead>
<tbody>
{state.data.usersWatched.slice(0, 5).map((row) => (
<tr
key={row.user.id}
className="border-t border-zinc-700/50"
>
<td className="px-2 py-1 text-zinc-100">
{row.user.name ?? row.user.id}
</td>
<td className="px-2 py-1 text-right">{row.watchCount}</td>
<td className="px-2 py-1 text-right">
{formatWatchTime(row.totalWatchTime)}
</td>
<td className="px-2 py-1 text-right">
{formatDate(row.lastWatched)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
)}
</div>
)
}
export default StreamystatsStatsPanel
@@ -43,6 +43,7 @@ describe('MediaModal', () => {
isLoading: false,
isPlex: false,
isJellyfin: false,
isEmby: false,
isMediaServerTypeSelected: false,
isSetupComplete: false,
isNotConfigured: true,
@@ -86,6 +87,10 @@ describe('MediaModal', () => {
return secondBackdrop.promise
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -166,6 +171,10 @@ describe('MediaModal', () => {
})
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -214,6 +223,10 @@ describe('MediaModal', () => {
})
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -267,6 +280,10 @@ describe('MediaModal', () => {
return maintainerrStatus.promise
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -326,6 +343,10 @@ describe('MediaModal', () => {
})
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -386,6 +407,10 @@ describe('MediaModal', () => {
})
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -436,6 +461,10 @@ describe('MediaModal', () => {
})
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -487,6 +516,10 @@ describe('MediaModal', () => {
})
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -522,6 +555,10 @@ describe('MediaModal', () => {
return Promise.resolve({} as MediaItem)
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -574,6 +611,10 @@ describe('MediaModal', () => {
})
}
if (path === '/streamystats/info') {
return Promise.reject(new Error('404 Streamystats not configured'))
}
throw new Error(`Unexpected request: ${path}`)
})
@@ -16,6 +16,7 @@ import {
} from '../../../../utils/mediaTypeUtils'
import Button from '../../Button'
import LoadingSpinner from '../../LoadingSpinner'
import StreamystatsStatsPanel from './StreamystatsStatsPanel'
import {
emptyMaintainerrMediaStatusDetails,
getMaintainerrStatusDetailsKey,
@@ -159,6 +160,9 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
const [tautulliModalUrl, setTautulliModalUrl] = useState<string | null>(
null,
)
const [streamystatsItemUrl, setStreamystatsItemUrl] = useState<
string | null
>(null)
const [metadata, setMetadata] = useState<MediaItem | null>(null)
const [maintainerrDetailsState, setMaintainerrDetailsState] = useState<{
key: string
@@ -310,6 +314,18 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
setTautulliModalUrl(resp?.tautulli_url || null)
})
.catch(() => {})
GetApiHandler<{ url: string; serverId: number | null }>(
'/streamystats/info',
)
.then((info) => {
if (!active) return
if (info?.url && info.serverId != null) {
setStreamystatsItemUrl(
`${info.url}/servers/${info.serverId}/library/${id}`,
)
}
})
.catch(() => {})
GetApiHandler<MediaItem>(`/media-server/meta/${id}`)
.then((data) => {
if (!active) return
@@ -588,6 +604,23 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
</a>
</div>
)}
{isJellyfin && streamystatsItemUrl && (
<div>
<a
href={streamystatsItemUrl}
target="_blank"
rel="noreferrer"
>
<img
src={`${basePath}/icons_logos/streamystats.svg`}
alt="Streamystats"
width={128}
height={32}
className="mt-1 h-8 w-32 rounded-lg bg-black bg-opacity-70 object-contain p-1 shadow-lg"
/>
</a>
</div>
)}
</div>
{metadata?.genres && metadata.genres.length > 0 ? (
<div className="pointer-events-none flex flex-wrap-reverse items-end justify-end gap-1">
@@ -618,6 +651,13 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
<p>{metadata?.summary || summary || 'No summary available.'}</p>
</div>
{isJellyfin && streamystatsItemUrl ? (
<StreamystatsStatsPanel
itemId={String(id)}
itemUrl={streamystatsItemUrl}
/>
) : null}
{showMaintainerrDetails ? (
<div
className={`mt-4 grid gap-4 ${shouldShowExcludedDetails && shouldShowManualDetails ? 'grid-cols-2' : ''}`}
@@ -117,12 +117,14 @@ const EmbyLoginButton: React.FC<EmbyLoginButtonProps> = ({
</p>
<div className="space-y-3">
<InputGroup
name="username"
label="Username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<InputGroup
name="password"
label="Password"
type="password"
value={password}
@@ -7,7 +7,9 @@ import {
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import ExternalServiceSettingsPage from './ExternalServiceSettingsPage'
import ExternalServiceSettingsPage, {
type ExternalServiceFieldConfig,
} from './ExternalServiceSettingsPage'
const getApiHandler = vi.fn()
const postApiHandler = vi.fn()
@@ -24,6 +26,32 @@ vi.mock('../Common/DocsButton', () => ({
default: () => <button type="button">Docs</button>,
}))
const urlApiKeyFields: ExternalServiceFieldConfig[] = [
{
name: 'url',
label: 'URL',
placeholder: 'http://localhost:5055',
required: true,
},
{ name: 'api_key', label: 'API key', type: 'password' },
]
const urlOnlyFields: ExternalServiceFieldConfig[] = [
{
name: 'url',
label: 'URL',
placeholder: 'http://localhost:3000',
required: true,
},
]
const urlApiKeySchema = z.object({
url: z.string().min(1),
api_key: z.string().min(1),
})
const urlOnlySchema = z.object({ url: z.string().min(1) })
describe('ExternalServiceSettingsPage', () => {
beforeEach(() => {
cleanup()
@@ -50,11 +78,8 @@ describe('ExternalServiceSettingsPage', () => {
docsPage="Configuration/#seerr"
settingsPath="/settings/seerr"
testPath="/settings/test/seerr"
schema={z.object({
url: z.string().min(1),
api_key: z.string().min(1),
})}
urlPlaceholder="http://localhost:5055"
schema={urlApiKeySchema}
fields={urlApiKeyFields}
testSuccessTitle="Seerr"
testFailureMessage="Failed to connect"
/>,
@@ -89,11 +114,8 @@ describe('ExternalServiceSettingsPage', () => {
docsPage="Configuration/#seerr"
settingsPath="/settings/seerr"
testPath="/settings/test/seerr"
schema={z.object({
url: z.string().min(1),
api_key: z.string().min(1),
})}
urlPlaceholder="http://localhost:5055"
schema={urlApiKeySchema}
fields={urlApiKeyFields}
testSuccessTitle="Seerr"
testFailureMessage="Failed to connect"
/>,
@@ -125,4 +147,40 @@ describe('ExternalServiceSettingsPage', () => {
expect(deleteApiHandler).toHaveBeenCalledWith('/settings/seerr')
})
})
it('renders only the configured fields (URL-only mode)', async () => {
getApiHandler.mockResolvedValue({ url: 'http://streamystats.local' })
deleteApiHandler.mockResolvedValue({ status: 'OK', code: 1, message: 'OK' })
render(
<ExternalServiceSettingsPage
scope="Streamystats settings"
pageTitle="Streamystats settings - Maintainerr"
heading="Streamystats Settings"
description="Streamystats configuration"
docsPage="Configuration/#streamystats"
settingsPath="/settings/streamystats"
testPath="/settings/test/streamystats"
schema={urlOnlySchema}
fields={urlOnlyFields}
testSuccessTitle="Streamystats"
testFailureMessage="Failed to connect"
/>,
)
await screen.findByLabelText(/URL/)
expect(screen.queryByLabelText(/API key/)).toBeNull()
fireEvent.change(screen.getByLabelText(/URL/), {
target: { value: '' },
})
fireEvent.click(
screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement,
)
await waitFor(() => {
expect(deleteApiHandler).toHaveBeenCalledWith('/settings/streamystats')
})
})
})
@@ -24,11 +24,18 @@ import { InputGroup } from '../Forms/Input'
import SettingsAlertSlot from './SettingsAlertSlot'
import { useSettingsFeedback } from './useSettingsFeedback'
interface UrlApiKeySettingsValues {
url: string
api_key: string
export interface ExternalServiceFieldConfig {
name: string
label: string
type?: 'text' | 'password'
placeholder?: string
helpText?: JSX.Element | string
normalize?: (value: string) => string
required?: boolean
}
type SettingsValues = Record<string, string>
interface TestStatus {
status: boolean
message: string
@@ -43,14 +50,19 @@ interface ExternalServiceSettingsPageProps {
settingsPath: string
testPath: string
schema: z.ZodTypeAny
urlPlaceholder: string
urlHelpText?: JSX.Element | string
fields: ExternalServiceFieldConfig[]
testSuccessTitle: string
testFailureMessage: string
normalizeUrl?: (url: string) => string
}
const identity = (value: string) => value
const allEmpty = (
values: SettingsValues,
fields: ExternalServiceFieldConfig[],
) => fields.every((field) => (values[field.name] ?? '') === '')
const valuesEqual = (a: SettingsValues, b: SettingsValues): boolean =>
Object.keys(a).length === Object.keys(b).length &&
Object.keys(a).every((key) => a[key] === b[key])
const ExternalServiceSettingsPage = ({
scope,
@@ -61,45 +73,37 @@ const ExternalServiceSettingsPage = ({
settingsPath,
testPath,
schema,
urlPlaceholder,
urlHelpText,
fields,
testSuccessTitle,
testFailureMessage,
normalizeUrl = identity,
}: ExternalServiceSettingsPageProps) => {
const [testedSettings, setTestedSettings] =
useState<UrlApiKeySettingsValues>()
const [testedSettings, setTestedSettings] = useState<SettingsValues>()
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<TestStatus>()
const { feedback, showUpdated, showUpdateError, clearError } =
useSettingsFeedback(scope)
const {
register,
control,
clearErrors,
getValues,
reset,
setError,
formState: { errors, isSubmitting, isLoading },
} = useForm<UrlApiKeySettingsValues>({
} = useForm<SettingsValues>({
defaultValues: async () => {
const response =
await GetApiHandler<UrlApiKeySettingsValues>(settingsPath)
return {
url: response.url ?? '',
api_key: response.api_key ?? '',
}
await GetApiHandler<Record<string, string | undefined>>(settingsPath)
return Object.fromEntries(
fields.map((field) => [field.name, response?.[field.name] ?? '']),
)
},
})
const url = useWatch({ control, name: 'url' }) ?? ''
const apiKey = useWatch({ control, name: 'api_key' }) ?? ''
const isGoingToRemove = url === '' && apiKey === ''
const currentValues = (useWatch({ control }) ?? {}) as SettingsValues
const isGoingToRemove = allEmpty(currentValues, fields)
const testFeedbackStatus =
url === testedSettings?.url && apiKey === testedSettings?.api_key
testedSettings && valuesEqual(currentValues, testedSettings)
? testResult?.status
: undefined
const canSave = !isSubmitting && !isLoading
@@ -110,8 +114,8 @@ const ExternalServiceSettingsPage = ({
setTestResult(undefined)
}
const validateValues = (values: UrlApiKeySettingsValues) => {
if (values.url === '' && values.api_key === '') {
const validateValues = (values: SettingsValues) => {
if (allEmpty(values, fields)) {
clearErrors()
return true
}
@@ -124,11 +128,11 @@ const ExternalServiceSettingsPage = ({
}
clearErrors()
const fieldNames = new Set(fields.map((field) => field.name))
result.error.issues.forEach((issue) => {
const fieldName = issue.path[0]
if (fieldName === 'url' || fieldName === 'api_key') {
const fieldName = String(issue.path[0])
if (fieldNames.has(fieldName)) {
setError(fieldName, {
type: 'manual',
message: issue.message,
@@ -139,18 +143,12 @@ const ExternalServiceSettingsPage = ({
return false
}
const registerApiKey = register('api_key', {
onChange: () => {
clearTransientState()
},
})
const onSubmit = async () => {
const data = getValues()
clearError()
const removingSetting = data.api_key === '' && data.url === ''
const removingSetting = allEmpty(data, fields)
if (!removingSetting && !validateValues(data)) {
return
@@ -181,10 +179,7 @@ const ExternalServiceSettingsPage = ({
setTesting(true)
await PostApiHandler<BasicResponseDto>(testPath, {
api_key: values.api_key,
url: values.url,
} satisfies UrlApiKeySettingsValues)
await PostApiHandler<BasicResponseDto>(testPath, values)
.then((response: BasicResponseDto) => {
setTestResult({
status: response.code === 1,
@@ -245,38 +240,42 @@ const ExternalServiceSettingsPage = ({
void onSubmit()
}}
>
<Controller
name="url"
defaultValue=""
control={control}
render={({ field }) => (
<InputGroup
label="URL"
value={field.value}
placeholder={urlPlaceholder}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
clearTransientState()
field.onChange(event)
}}
onBlur={(event: FocusEvent<HTMLInputElement>) =>
field.onChange(normalizeUrl(event.target.value))
}
ref={field.ref}
name={field.name}
type="text"
error={errors.url?.message}
helpText={urlHelpText ?? undefined}
required
/>
)}
/>
<InputGroup
label="API key"
type="password"
{...registerApiKey}
error={errors.api_key?.message}
/>
{fields.map((fieldConfig) => (
<Controller
key={fieldConfig.name}
name={fieldConfig.name}
defaultValue=""
control={control}
render={({ field }) => (
<InputGroup
label={fieldConfig.label}
value={field.value}
placeholder={fieldConfig.placeholder}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
clearTransientState()
field.onChange(event)
}}
onBlur={(event: FocusEvent<HTMLInputElement>) => {
if (fieldConfig.normalize) {
field.onChange(
fieldConfig.normalize(event.target.value),
)
} else {
field.onBlur()
}
}}
ref={field.ref}
name={field.name}
type={fieldConfig.type ?? 'text'}
error={
errors[fieldConfig.name]?.message as string | undefined
}
helpText={fieldConfig.helpText ?? undefined}
required={fieldConfig.required}
/>
)}
/>
))}
<div className="actions mt-5 w-full">
<div className="flex w-full flex-wrap sm:flex-nowrap">
+27 -11
View File
@@ -1,7 +1,9 @@
import { seerrSettingSchema } from '@maintainerr/contracts'
import { z } from 'zod'
import { stripTrailingSlashes } from '../../../utils/SettingsUtils'
import ExternalServiceSettingsPage from '../ExternalServiceSettingsPage'
import ExternalServiceSettingsPage, {
type ExternalServiceFieldConfig,
} from '../ExternalServiceSettingsPage'
const SeerrSettingDeleteSchema = z.object({
url: z.literal(''),
@@ -13,6 +15,29 @@ const SeerrSettingFormSchema = z.union([
SeerrSettingDeleteSchema,
])
const fields: ExternalServiceFieldConfig[] = [
{
name: 'url',
label: 'URL',
placeholder: 'http://localhost:5055',
helpText: (
<>
Example URL formats:{' '}
<span className="whitespace-nowrap">http://localhost:5055</span>,{' '}
<span className="whitespace-nowrap">http://192.168.1.5/seerr</span>,{' '}
<span className="whitespace-nowrap">https://seerr.example.com</span>
</>
),
normalize: stripTrailingSlashes,
required: true,
},
{
name: 'api_key',
label: 'API key',
type: 'password',
},
]
const SeerrSettings = () => {
return (
<ExternalServiceSettingsPage
@@ -24,18 +49,9 @@ const SeerrSettings = () => {
settingsPath="/settings/seerr"
testPath="/settings/test/seerr"
schema={SeerrSettingFormSchema}
urlPlaceholder="http://localhost:5055"
urlHelpText={
<>
Example URL formats:{' '}
<span className="whitespace-nowrap">http://localhost:5055</span>,{' '}
<span className="whitespace-nowrap">http://192.168.1.5/seerr</span>,{' '}
<span className="whitespace-nowrap">https://seerr.example.com</span>
</>
}
fields={fields}
testSuccessTitle="Seerr"
testFailureMessage="Failed to connect to Overseerr. Verify URL and API key."
normalizeUrl={stripTrailingSlashes}
/>
)
}
@@ -113,6 +113,7 @@ describe('SettingsWrapper', () => {
'Radarr',
'Sonarr',
'Metadata',
'Streamystats',
'Notifications',
'Logs',
'Jobs',
@@ -143,6 +144,7 @@ describe('SettingsWrapper', () => {
'Radarr',
'Sonarr',
'Metadata',
'Streamystats',
'Notifications',
'Logs',
'Jobs',
@@ -0,0 +1,97 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import StreamystatsSettings from './index'
const useMediaServerTypeMock = vi.fn()
const getApiHandler = vi.fn()
vi.mock('../../../hooks/useMediaServerType', () => ({
useMediaServerType: () => useMediaServerTypeMock(),
}))
vi.mock('../../../utils/ApiHandler', () => ({
default: (url: string) => getApiHandler(url),
PostApiHandler: vi.fn(),
DeleteApiHandler: vi.fn(),
}))
vi.mock('react-router-dom', async () => {
const actual =
await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
Navigate: ({ to }: { to: string }) => (
<div data-testid="navigate" data-to={to} />
),
}
})
vi.mock('../../Common/DocsButton', () => ({
default: () => <button type="button">Docs</button>,
}))
describe('StreamystatsSettings', () => {
beforeEach(() => {
cleanup()
useMediaServerTypeMock.mockReset()
getApiHandler.mockReset()
})
afterEach(() => {
cleanup()
})
it('renders nothing while settings are loading', () => {
useMediaServerTypeMock.mockReturnValue({
isJellyfin: false,
isLoading: true,
})
const { container } = render(<StreamystatsSettings />)
expect(container.firstChild).toBeNull()
expect(getApiHandler).not.toHaveBeenCalled()
})
it('redirects to /settings/main when the active server is Plex', () => {
useMediaServerTypeMock.mockReturnValue({
isJellyfin: false,
isLoading: false,
})
render(<StreamystatsSettings />)
const nav = screen.getByTestId('navigate')
expect(nav.getAttribute('data-to')).toBe('/settings/main')
// The settings form must not mount, so its initial GET must not fire.
expect(getApiHandler).not.toHaveBeenCalled()
})
it('redirects to /settings/main when the active server is Emby', () => {
useMediaServerTypeMock.mockReturnValue({
isJellyfin: false,
isLoading: false,
})
render(<StreamystatsSettings />)
expect(screen.getByTestId('navigate').getAttribute('data-to')).toBe(
'/settings/main',
)
expect(getApiHandler).not.toHaveBeenCalled()
})
it('renders the settings form when the active server is Jellyfin', async () => {
useMediaServerTypeMock.mockReturnValue({
isJellyfin: true,
isLoading: false,
})
getApiHandler.mockResolvedValue({ url: '' })
render(<StreamystatsSettings />)
await waitFor(() => {
expect(screen.queryByTestId('navigate')).toBeNull()
})
expect(getApiHandler).toHaveBeenCalledWith('/settings/streamystats')
})
})
@@ -0,0 +1,70 @@
import { streamystatsSettingSchema } from '@maintainerr/contracts'
import { Navigate } from 'react-router-dom'
import { z } from 'zod'
import { useMediaServerType } from '../../../hooks/useMediaServerType'
import { stripTrailingSlashes } from '../../../utils/SettingsUtils'
import ExternalServiceSettingsPage, {
type ExternalServiceFieldConfig,
} from '../ExternalServiceSettingsPage'
const StreamystatsSettingDeleteSchema = z.object({
url: z.literal(''),
})
const StreamystatsSettingFormSchema = z.union([
streamystatsSettingSchema,
StreamystatsSettingDeleteSchema,
])
const fields: ExternalServiceFieldConfig[] = [
{
name: 'url',
label: 'URL',
placeholder: 'http://localhost:3000',
helpText: (
<>
Example URL formats:
<br />
<span className="whitespace-nowrap">http://localhost:3000</span>
<br />
<span className="whitespace-nowrap">
https://streamystats.example.com
</span>
</>
),
normalize: stripTrailingSlashes,
required: true,
},
]
const StreamystatsSettings = () => {
const { isJellyfin, isLoading } = useMediaServerType()
if (isLoading) {
return null
}
// Streamystats is Jellyfin-only upstream; redirect away from the route if
// the active media server is anything else (e.g. Plex/Emby user typing
// /settings/streamystats directly).
if (!isJellyfin) {
return <Navigate to="/settings/main" replace />
}
return (
<ExternalServiceSettingsPage
scope="Streamystats settings"
pageTitle="Streamystats settings - Maintainerr"
heading="Streamystats Settings"
description="Streamystats configuration. Authentication reuses the configured Jellyfin API key."
docsPage="Configuration/#streamystats"
settingsPath="/settings/streamystats"
testPath="/settings/test/streamystats"
schema={StreamystatsSettingFormSchema}
fields={fields}
testSuccessTitle="Streamystats"
testFailureMessage="Failed to connect to Streamystats. Verify URL and that the service is running."
/>
)
}
export default StreamystatsSettings
@@ -1,7 +1,9 @@
import { tautulliSettingSchema } from '@maintainerr/contracts'
import { z } from 'zod'
import { stripTrailingSlashes } from '../../../utils/SettingsUtils'
import ExternalServiceSettingsPage from '../ExternalServiceSettingsPage'
import ExternalServiceSettingsPage, {
type ExternalServiceFieldConfig,
} from '../ExternalServiceSettingsPage'
const TautulliSettingDeleteSchema = z.object({
url: z.literal(''),
@@ -13,6 +15,29 @@ const TautulliSettingFormSchema = z.union([
TautulliSettingDeleteSchema,
])
const fields: ExternalServiceFieldConfig[] = [
{
name: 'url',
label: 'URL',
placeholder: 'http://localhost:8181',
helpText: (
<>
Example URL formats:{' '}
<span className="whitespace-nowrap">http://localhost:8181</span>,{' '}
<span className="whitespace-nowrap">http://192.168.1.5/tautulli</span>,{' '}
<span className="whitespace-nowrap">https://tautulli.example.com</span>
</>
),
normalize: stripTrailingSlashes,
required: true,
},
{
name: 'api_key',
label: 'API key',
type: 'password',
},
]
const TautulliSettings = () => {
return (
<ExternalServiceSettingsPage
@@ -24,21 +49,9 @@ const TautulliSettings = () => {
settingsPath="/settings/tautulli"
testPath="/settings/test/tautulli"
schema={TautulliSettingFormSchema}
urlPlaceholder="http://localhost:8181"
urlHelpText={
<>
Example URL formats:{' '}
<span className="whitespace-nowrap">http://localhost:8181</span>,{' '}
<span className="whitespace-nowrap">http://192.168.1.5/tautulli</span>
,{' '}
<span className="whitespace-nowrap">
https://tautulli.example.com
</span>
</>
}
fields={fields}
testSuccessTitle="Tautulli"
testFailureMessage="Failed to connect to Tautulli. Verify URL and API key."
normalizeUrl={stripTrailingSlashes}
/>
)
}
+13 -1
View File
@@ -38,7 +38,10 @@ const mediaServerTabContent = (label?: string) => {
const getMediaServerTypeFromPath = (
pathname: string,
): MediaServerType | undefined => {
if (pathname.startsWith('/settings/jellyfin')) {
if (
pathname.startsWith('/settings/jellyfin') ||
pathname.startsWith('/settings/streamystats')
) {
return MediaServerType.JELLYFIN
}
@@ -167,6 +170,15 @@ const SettingsWrapper = () => {
})
}
// Streamystats is a Jellyfin-only integration (no Emby support upstream)
if (mediaServerType === MediaServerType.JELLYFIN) {
baseRoutes.push({
text: 'Streamystats',
route: '/settings/streamystats',
regex: /^\/settings\/streamystats$/,
})
}
baseRoutes.push(
{
text: 'Notifications',
+8
View File
@@ -93,6 +93,9 @@ const settingsSeerrRoute = createLazyRoute(
const settingsTautulliRoute = createLazyRoute(
() => import('./components/Settings/Tautulli'),
)
const settingsStreamystatsRoute = createLazyRoute(
() => import('./components/Settings/Streamystats'),
)
const settingsNotificationsRoute = createLazyRoute(
() => import('./components/Settings/Notifications'),
)
@@ -287,6 +290,11 @@ const appRoutes: AppRoute[] = [
lazy: settingsTautulliRoute.lazy,
preload: settingsTautulliRoute.preload,
},
{
path: 'streamystats',
lazy: settingsStreamystatsRoute.lazy,
preload: settingsStreamystatsRoute.preload,
},
{
path: 'notifications',
lazy: settingsNotificationsRoute.lazy,
+2
View File
@@ -11,7 +11,9 @@ export * from './settings/metadata'
export * from './settings/seerr'
export * from './settings/servarr'
export * from './settings/serviceUrl'
export * from './settings/streamystats'
export * from './settings/tautulli'
export * from './storage-metrics'
export * from './streamystats'
export * from './tasks'
export * from './uploads'
@@ -0,0 +1 @@
export * from './streamystatsSetting'
@@ -0,0 +1,8 @@
import z from 'zod'
import { serviceUrlSchema } from '../serviceUrl'
export const streamystatsSettingSchema = z.object({
url: serviceUrlSchema,
})
export type StreamystatsSetting = z.infer<typeof streamystatsSettingSchema>
@@ -0,0 +1 @@
export * from './itemDetails'
@@ -0,0 +1,82 @@
import z from 'zod'
// Streamystats's getItemDetails surfaces aggregation results (COUNT/SUM/AVG)
// straight from Drizzle, which serialises them as strings even though the
// upstream TypeScript interface declares them as `number`. Coerce defensively
// so the schema stays robust to that wire-format quirk.
const numberLike = z.coerce.number()
const streamystatsUserSchema = z
.object({
id: z.string(),
name: z.string().nullable().optional(),
})
.loose()
const streamystatsItemUserStatsSchema = z.object({
user: streamystatsUserSchema,
watchCount: numberLike,
totalWatchTime: numberLike,
completionRate: numberLike,
firstWatched: z.string().nullable(),
lastWatched: z.string().nullable(),
})
const streamystatsItemWatchHistorySchema = z
.object({
user: streamystatsUserSchema.nullable(),
watchDate: z.string(),
watchDuration: numberLike,
completionPercentage: numberLike,
playMethod: z.string().nullable().optional(),
deviceName: z.string().nullable().optional(),
clientName: z.string().nullable().optional(),
})
.loose()
const streamystatsItemWatchCountByMonthSchema = z.object({
month: numberLike,
year: numberLike,
watchCount: numberLike,
uniqueUsers: numberLike,
totalWatchTime: numberLike,
})
const streamystatsSeriesEpisodeStatsSchema = z.object({
totalSeasons: numberLike,
totalEpisodes: numberLike,
watchedEpisodes: numberLike,
watchedSeasons: numberLike,
})
export const streamystatsItemDetailsSchema = z.object({
item: z
.object({
id: z.string(),
name: z.string().nullable().optional(),
type: z.string().nullable().optional(),
})
.loose(),
totalViews: numberLike,
totalWatchTime: numberLike,
completionRate: numberLike,
firstWatched: z.string().nullable(),
lastWatched: z.string().nullable(),
usersWatched: z.array(streamystatsItemUserStatsSchema),
watchHistory: z.array(streamystatsItemWatchHistorySchema),
watchCountByMonth: z.array(streamystatsItemWatchCountByMonthSchema),
episodeStats: streamystatsSeriesEpisodeStatsSchema.optional(),
})
export type StreamystatsItemDetails = z.infer<
typeof streamystatsItemDetailsSchema
>
export type StreamystatsItemUserStats = z.infer<
typeof streamystatsItemUserStatsSchema
>
export type StreamystatsItemWatchCountByMonth = z.infer<
typeof streamystatsItemWatchCountByMonthSchema
>
export type StreamystatsSeriesEpisodeStats = z.infer<
typeof streamystatsSeriesEpisodeStatsSchema
>