Add JellyfinClient

This commit is contained in:
2026-08-12 21:04:47 +02:00
parent ef037d25fe
commit 9683d65a1f
3 changed files with 179 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import { ApiClient } from '../../../core/http/ApiClient';
import type { Movie } from '../../models';
import { ItemsEndpoint } from './endpoints/ItemsEndpoint';
export class JellyfinClient extends ApiClient {
private readonly endpoint = new ItemsEndpoint();
constructor(
baseUrl: string,
private readonly apiKey: string,
private readonly userId: string
) {
super(baseUrl);
}
protected buildHeaders(): Record<string, string> {
return {
...super.buildHeaders(),
Authorization: `MediaBrowser Token="${this.apiKey}"`,
};
}
getFavoriteMovies(): Promise<Movie[]> {
return this.request(this.endpoint, {
userId: this.userId,
query: {
IncludeItemTypes: 'Movie',
Recursive: 'true',
IsFavorite: 'true',
Fields: 'ProviderIds,Genres,CommunityRating,UserData',
},
});
}
getRecentlyWatched(): Promise<Movie[]> {
return this.request(this.endpoint, {
userId: this.userId,
query: {
IncludeItemTypes: 'Movie',
Recursive: 'true',
Filters: 'IsPlayed',
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Limit: '50',
Fields: 'ProviderIds,Genres,CommunityRating,UserData',
},
});
}
getAllMovies(): Promise<Movie[]> {
return this.request(this.endpoint, {
userId: this.userId,
query: {
IncludeItemTypes: 'Movie',
Recursive: 'true',
Fields: 'ProviderIds',
Limit: '10000',
},
});
}
}

View File

@@ -0,0 +1,46 @@
import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint';
import type { Movie } from '../../../models';
interface JellyfinItem {
Name: string;
ProductionYear: number;
ProviderIds: Record<string, string>;
Genres: string[];
CommunityRating: number;
UserData: { IsFavorite: boolean; PlayCount: number };
}
interface JellyfinItemsResponse {
Items: JellyfinItem[];
}
export interface ItemsParams {
userId: string;
query: Record<string, string>;
}
export class ItemsEndpoint extends Endpoint<Movie[], ItemsParams> {
readonly method: HttpMethod = 'GET';
path(params: ItemsParams): string {
return `/Users/${params.userId}/Items`;
}
query(params: ItemsParams): Record<string, string> {
return params.query;
}
parseResponse(json: unknown): Movie[] {
const response = json as JellyfinItemsResponse;
return response.Items.map((item) => ({
name: item.Name,
year: item.ProductionYear,
imdbId: item.ProviderIds?.Imdb ?? '',
tmdbId: item.ProviderIds?.Tmdb ?? '',
genres: item.Genres ?? [],
communityRating: item.CommunityRating ?? 0,
isFavorite: item.UserData?.IsFavorite ?? false,
playCount: item.UserData?.PlayCount ?? 0,
}));
}
}

View File

@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JellyfinClient } from '../../../../src/app/clients/jellyfin/JellyfinClient';
describe('JellyfinClient', () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
Items: [
{
Name: 'Dune',
ProductionYear: 2021,
ProviderIds: { Imdb: 'tt1160419', Tmdb: '438631' },
Genres: ['Sci-Fi'],
CommunityRating: 8.0,
UserData: { IsFavorite: true, PlayCount: 2 },
},
],
}),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('getFavoriteMovies requests favorites and maps the response to Movie[]', async () => {
const client = new JellyfinClient('http://jellyfin', 'jf-key', 'user-1');
const movies = await client.getFavoriteMovies();
expect(movies).toEqual([
{
name: 'Dune',
year: 2021,
imdbId: 'tt1160419',
tmdbId: '438631',
genres: ['Sci-Fi'],
communityRating: 8.0,
isFavorite: true,
playCount: 2,
},
]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/Users/user-1/Items');
expect(url).toContain('IsFavorite=true');
expect(init.headers.Authorization).toBe('MediaBrowser Token="jf-key"');
});
it('getRecentlyWatched requests played items sorted by date', async () => {
const client = new JellyfinClient('http://jellyfin', 'jf-key', 'user-1');
await client.getRecentlyWatched();
const [url] = fetchMock.mock.calls[0];
expect(url).toContain('Filters=IsPlayed');
expect(url).toContain('SortBy=DatePlayed');
});
it('getAllMovies requests the full library', async () => {
const client = new JellyfinClient('http://jellyfin', 'jf-key', 'user-1');
await client.getAllMovies();
const [url] = fetchMock.mock.calls[0];
expect(url).toContain('Limit=10000');
});
});