diff --git a/src/app/clients/jellyfin/JellyfinClient.ts b/src/app/clients/jellyfin/JellyfinClient.ts new file mode 100644 index 0000000..4fd5be7 --- /dev/null +++ b/src/app/clients/jellyfin/JellyfinClient.ts @@ -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 { + return { + ...super.buildHeaders(), + Authorization: `MediaBrowser Token="${this.apiKey}"`, + }; + } + + getFavoriteMovies(): Promise { + return this.request(this.endpoint, { + userId: this.userId, + query: { + IncludeItemTypes: 'Movie', + Recursive: 'true', + IsFavorite: 'true', + Fields: 'ProviderIds,Genres,CommunityRating,UserData', + }, + }); + } + + getRecentlyWatched(): Promise { + 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 { + return this.request(this.endpoint, { + userId: this.userId, + query: { + IncludeItemTypes: 'Movie', + Recursive: 'true', + Fields: 'ProviderIds', + Limit: '10000', + }, + }); + } +} diff --git a/src/app/clients/jellyfin/endpoints/ItemsEndpoint.ts b/src/app/clients/jellyfin/endpoints/ItemsEndpoint.ts new file mode 100644 index 0000000..86af8e5 --- /dev/null +++ b/src/app/clients/jellyfin/endpoints/ItemsEndpoint.ts @@ -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; + Genres: string[]; + CommunityRating: number; + UserData: { IsFavorite: boolean; PlayCount: number }; +} + +interface JellyfinItemsResponse { + Items: JellyfinItem[]; +} + +export interface ItemsParams { + userId: string; + query: Record; +} + +export class ItemsEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + + path(params: ItemsParams): string { + return `/Users/${params.userId}/Items`; + } + + query(params: ItemsParams): Record { + 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, + })); + } +} diff --git a/test/app/clients/jellyfin/JellyfinClient.test.ts b/test/app/clients/jellyfin/JellyfinClient.test.ts new file mode 100644 index 0000000..7f540d6 --- /dev/null +++ b/test/app/clients/jellyfin/JellyfinClient.test.ts @@ -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'); + }); +});