diff --git a/src/backend/common/infrastructure/config/source/jellyfin.ts b/src/backend/common/infrastructure/config/source/jellyfin.ts index 436d7b6da..0e7fca48d 100644 --- a/src/backend/common/infrastructure/config/source/jellyfin.ts +++ b/src/backend/common/infrastructure/config/source/jellyfin.ts @@ -87,6 +87,13 @@ export interface JellyApiData extends CommonSourceData { * @default false */ allowUnknown?: boolean + + /** + * HOST:PORT of the Jellyfin server that your browser will be able to access from the frontend (and thus load images and links from) + * If unspecified it will use the normal server HOST and PORT from the `url` + * Necessary if you are using a reverse proxy or other network configuration that prevents the frontend from accessing the server directly + * */ + frontendUrlOverride?: string } export interface JellyApiOptions extends CommonSourceOptions { diff --git a/src/backend/sources/JellyfinApiSource.ts b/src/backend/sources/JellyfinApiSource.ts index cadbb9726..97f638539 100644 --- a/src/backend/sources/JellyfinApiSource.ts +++ b/src/backend/sources/JellyfinApiSource.ts @@ -364,6 +364,18 @@ export default class JellyfinApiSource extends MemoryPositionalSource { return true; } + replaceUrlIfNeeded = (url: string): string => { + if( + this.config.data.frontendUrlOverride !== undefined && + this.config.data.frontendUrlOverride.length > 0 && + url !== undefined && + url.length > 0 + ) { + return url.replace(this.config.data.url, this.config.data.frontendUrlOverride); + } + return url; + } + formatPlayObjAware(obj: BaseItemDto, options: FormatPlayObjectOptions = {}): PlayObject { const play = JellyfinApiSource.formatPlayObj(obj, options); @@ -377,7 +389,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource { if(AlbumId !== undefined && AlbumPrimaryImageTag !== undefined) { const existingArt = play.meta?.art || {}; - existingArt.album = this.imageApi.getItemImageUrlById(AlbumId, undefined, {maxHeight: 500}); + existingArt.album = this.replaceUrlIfNeeded(this.imageApi.getItemImageUrlById(AlbumId, undefined, {maxHeight: 500})); play.meta.art = existingArt; } if(ParentId !== undefined) { @@ -386,7 +398,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource { u.searchParams.append('serviceId', ServerId); play.meta.url = { ...(play.meta?.url || {}), - web: u.toString().replace('%23', '#') + web: this.replaceUrlIfNeeded(u.toString().replace('%23', '#')) } } diff --git a/src/backend/tests/jellyfin/jellyfin.test.ts b/src/backend/tests/jellyfin/jellyfin.test.ts index b9cddd27e..a26e3aa40 100644 --- a/src/backend/tests/jellyfin/jellyfin.test.ts +++ b/src/backend/tests/jellyfin/jellyfin.test.ts @@ -15,6 +15,8 @@ import { // @ts-expect-error weird typings? SessionInfo, } from "@jellyfin/sdk/lib/generated-client/index.js"; +// @ts-expect-error weird typings? +import { getImageApi } from "@jellyfin/sdk/lib/utils/api/index.js"; import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js"; const dataAsFixture = (data: any): TestFixture => { @@ -132,6 +134,120 @@ describe("Jellyfin API Source", function() { }); }); + describe('Parses and replaces frontendUrlOverride correctly if set', function () { + + const item = { + AlbumId: 123, + AlbumPrimaryImageTag: 'Primary', + ParentId: 456, + ServerId: 789, + }; + const sourceUrl = 'http://192.168.10.11:8096'; + const frontendUrlOverride = 'https://myjellyfin.com'; + + it('Should use default url if frontendUrlOverride unset', async function () { + const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl}); + await jf.buildInitData(); + jf.address = sourceUrl; + jf.api = jf.client.createApi(jf.address); + jf.imageApi = getImageApi(jf.api); + + expect(jf.formatPlayObjAware(item).meta.art.album).to.be.eql(`${sourceUrl}/Items/123/Images/Primary?maxHeight=500`); + expect(jf.formatPlayObjAware(item).meta.url.web).to.be.eql(`${sourceUrl}/web/#/details?id=456&serviceId=789`); + + await jf.destroy(); + }); + + it('Should parse and replace frontendUrlOverride correctly', async function () { + const jf = createJfApi({...defaultJfApiCreds, frontendUrlOverride: frontendUrlOverride, url: sourceUrl}); + await jf.buildInitData(); + jf.address = sourceUrl; + jf.api = jf.client.createApi(jf.address); + jf.imageApi = getImageApi(jf.api); + + expect(jf.formatPlayObjAware(item).meta.art.album).to.be.eql(`${frontendUrlOverride}/Items/123/Images/Primary?maxHeight=500`); + expect(jf.formatPlayObjAware(item).meta.url.web).to.be.eql(`${frontendUrlOverride}/web/#/details?id=456&serviceId=789`); + + await jf.destroy(); + }); + }); + + describe('Correctly replaces URLs with frontendUrlOverride', function () { + + const sourceUrl = 'http://192.168.10.11:8096'; + const frontendUrlOverride = 'https://myjellyfin.com'; + + it('Should return original URL when frontendUrlOverride is not set', async function () { + const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl}); + await jf.buildInitData(); + + const testUrl = `${sourceUrl}/Items/123/Images/Primary`; + expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(testUrl); + + await jf.destroy(); + }); + + it('Should return original URL when frontendUrlOverride is empty string', async function () { + const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: ''}); + await jf.buildInitData(); + + const testUrl = `${sourceUrl}/Items/123/Images/Primary`; + expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(testUrl); + + await jf.destroy(); + }); + + it('Should replace source URL with frontendUrlOverride when set', async function () { + const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride}); + await jf.buildInitData(); + + const testUrl = `${sourceUrl}/Items/123/Images/Primary`; + const expectedUrl = `${frontendUrlOverride}/Items/123/Images/Primary`; + expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(expectedUrl); + + await jf.destroy(); + }); + + it('Should return original URL when input URL is undefined', async function () { + const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride}); + await jf.buildInitData(); + + expect(jf.replaceUrlIfNeeded(undefined)).to.be.undefined; + + await jf.destroy(); + }); + + it('Should return original URL when input URL is empty string', async function () { + const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride}); + await jf.buildInitData(); + + expect(jf.replaceUrlIfNeeded('')).to.be.eql(''); + + await jf.destroy(); + }); + + it('Should not replace URL when source URL is not present', async function () { + const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride}); + await jf.buildInitData(); + + const testUrl = 'https://some-other-domain.com/Items/123/Images/Primary'; + expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(testUrl); + + await jf.destroy(); + }); + + it('Should not replace multiple occurrences of source URL', async function () { + const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride}); + await jf.buildInitData(); + + const testUrl = `${sourceUrl}/redirect?url=${sourceUrl}/Items/123`; + const expectedUrl = `${frontendUrlOverride}/redirect?url=${sourceUrl}/Items/123`; + expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(expectedUrl); + + await jf.destroy(); + }); + }); + describe('Correctly detects activity as valid/invalid', function() { describe('Filters from Configuration', function() {