mirror of
https://github.com/jorenn92/Maintainerr.git
synced 2026-06-01 18:48:13 +02:00
feat(streamystats): add Jellyfin-only Streamystats integration (#2923)
This commit is contained in:
+5
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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 |
+99
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
>
|
||||
Reference in New Issue
Block a user