Compare commits

...
2 changed files with 156 additions and 42 deletions
+120 -37
View File
@@ -33,6 +33,11 @@ import {
getLibraryStructureApi,
// @ts-expect-error weird typings?
getImageApi,
// @ts-expect-error weird typings?
getUserViewsApi,
// @ts-expect-error weird typings?
getLibraryApi,
//getAncestors
} from "@jellyfin/sdk/lib/utils/api/index.js";
import {
@@ -42,17 +47,13 @@ import {
from "@jellyfin/sdk/lib/index.js";
import dayjs from "dayjs";
import EventEmitter from "events";
import { nanoid } from "nanoid";
import pEvent from "p-event";
import { Simulate } from "react-dom/test-utils";
import { BrainzMeta, PlayObject } from "../../core/Atomic.js";
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import {
FormatPlayObjectOptions,
InternalConfig,
PlayerStateData,
PlayerStateDataMaybePlay,
PlayPlatformId, REPORTED_PLAYER_STATUSES
REPORTED_PLAYER_STATUSES
} from "../common/infrastructure/Atomic.js";
import { JellyApiSourceConfig } from "../common/infrastructure/config/source/jellyfin.js";
import { genGroupIdStr, getPlatformIdFromData, isDebugMode, parseBool, } from "../utils.js";
@@ -86,9 +87,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
logFilterFailure: false | 'debug' | 'warn';
mediaIdsSeen: FixedSizeList<string>;
mediaIdLibrary: FixedSizeList<{id: string, libraryId: string | false}>;
uniqueDropReasons: FixedSizeList<string>;
libraries: {name: string, paths: string[], collectionType: CollectionType}[] = [];
libraries: {name: string, id: string, paths: string[], collectionType: CollectionType}[] = [];
declare config: JellyApiSourceConfig;
@@ -112,6 +114,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
this.uniqueDropReasons = new FixedSizeList<string>(100);
this.mediaIdsSeen = new FixedSizeList<string>(100);
this.mediaIdLibrary = new FixedSizeList<{id: string, libraryId: string}>(15);
}
protected async doBuildInitData(): Promise<true | string | undefined> {
@@ -187,25 +190,29 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
if(servers.length === 0) {
throw new Error(`No servers were parseable from the given Jellyfin URL ${this.config.data.url}`);
}
const best = this.client.discovery.findBestServer(servers);
if(best === undefined) {
for(const s of servers) {
const sysError = s.issues.find(x => x instanceof SystemInfoIssue);
if(sysError !== undefined) {
this.logger.warn(new Error(`Server ${s.address} failed to communicate or something went wrong (SystemInfoIssue)`, {cause: sysError.error}));
} else {
for(const i of s.issues) {
this.logger.verbose(`Found ${servers.length} server candidates with given url ${this.config.data.url}`);
for(const s of servers) {
this.logger.verbose(`Server ${s.systemInfo?.ServerName ?? 'No Server Name'} ${s.systemInfo?.Version ?? 'No Version'} at ${s.address} with ${s.issues.length} issues | ResponseTime ${s.responseTime} | Score ${s.score}`);
if(s.issues.length > 0) {
for(const i of s.issues) {
if(i instanceof SystemInfoIssue) {
this.logger.warn(new Error(`Server ${s.address} failed to communicate or something went wrong (SystemInfoIssue)`, {cause: i.error}));
} else {
this.logger.warn(`Server ${s.address} has an issue (${i.constructor.name})`)
}
}
}
}
const best = this.client.discovery.findBestServer(servers);
if(best === undefined) {
throw new Error('Unable to determine a valid Server to connect to. See warnings above.');
}
this.logger.debug(`Jellyfin SDK chose ${best.address} as best Server`);
this.api = this.client.createApi(best.address);
this.imageApi = getImageApi(this.api);
this.address = best.address;
const info = await getSystemApi(this.api).getPublicSystemInfo();
return `Found Server ${info.data.ServerName} (${info.data.Version})`;
return `Using Server ${info.data.ServerName} (${info.data.Version})`;
} catch (e) {
throw e;
}
@@ -254,14 +261,22 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
// }
protected buildLibraryInfo = async () => {
try {
const virtualResp = await getLibraryStructureApi(this.api).getVirtualFolders();
const folders = virtualResp.data as VirtualFolderInfo[];
this.libraries = folders.map(x => ({name: x.Name, paths: x.Locations, collectionType: x.CollectionType}));
} catch (e) {
throw new Error('Unable to get server Libraries and paths', {cause: e});
if (this.config.data.apiKey !== undefined) {
try {
const virtualResp = await getLibraryStructureApi(this.api).getVirtualFolders();
const folders = virtualResp.data as VirtualFolderInfo[];
this.libraries = folders.map(x => ({ name: x.Name, id: x.ItemId, paths: x.Locations, collectionType: x.CollectionType }));
} catch (e) {
throw new Error('Unable to get server Libraries and paths', { cause: e });
}
} else {
try {
const views = await getUserViewsApi(this.api).getUserViews();
this.libraries = views.data.Items.map(x => ({ name: x.Name, id: x.Id, paths: [], collectionType: x.CollectionType }));
} catch (e) {
throw new Error('Unable to get server Libraries and paths', { cause: e });
}
}
}
getAllowedLibraries = () => {
@@ -308,34 +323,91 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
if(session.NowPlayingItem !== undefined) {
const {
Path
Path,
Id,
} = session.NowPlayingItem;
const allowedLibraries = this.getAllowedLibraries();
if(allowedLibraries.length > 0 && (Path === undefined || !allowedLibraries.map(x => x.paths).flat(1).some(x => Path.includes(x)))) {
const libraryEntry = this.mediaIdLibrary.data.find(x => x.id === Id);
const libraryId = libraryEntry === undefined ? undefined : libraryEntry.libraryId;
let isValidToFilter = true;
const invalidReasons: string[] = [];
if(libraryId === undefined) {
this.logger.warn(`No library entry found for ${Id}`);
invalidReasons.push('No CollectionFolder was fetched for media, cannot determine if its in an allowed library');
isValidToFilter = false;
} else if(libraryId === false) {
invalidReasons.push('No CollectionFolder (library) was found for media');
isValidToFilter = false;
}
if(!isValidToFilter && this.config.data.apiKey !== undefined) {
if(Path === undefined) {
return 'media does not have a path, cannot be included in librariesAllow';
invalidReasons.push('Media does not have a path');
} else {
isValidToFilter = true;
}
}
if(!isValidToFilter) {
return invalidReasons.join(' | ');
}
const allowedLibraries = this.getAllowedLibraries();
if(allowedLibraries.length > 0) {
let anyPass = false;
const reasons = [];
if(allowedLibraries.some(x => x.id === libraryId)) {
anyPass = true;
} else {
reasons.push('Media does not belong to an allowed library (using CollectionFolder filter)');
if(Path !== undefined) {
if(!allowedLibraries.map(x => x.paths).flat(1).some(x => Path.includes(x))) {
reasons.push('media path not found in any allowedLibraries');
} else {
anyPass = true;
}
}
}
if(!anyPass) {
return reasons.join(' | ');
}
return `media not included in librariesAllow`;
}
if(allowedLibraries.length === 0) {
const blockedLibraries = this.getBlockedLibraries();
if(blockedLibraries.length > 0) {
let blockedLibrary = undefined;
if(Path !== undefined) {
blockedLibrary = blockedLibraries.find(x => x.paths.some(y => Path.includes(y)));
let allPass = true;
const reasons = [];
const blocked = blockedLibraries.find(x => x.id === libraryId || (Path !== undefined && x.paths.flat(1).some(y => Path.includes(y))));
if(blocked === undefined) {
allPass = true;
} else {
reasons.push(`Media belongs to a blocked library: ${blocked.name}`);
allPass = false;
}
if(blockedLibrary !== undefined) {
return `media included in librariesBlock '${blockedLibrary.name}'`;
if(!allPass) {
return reasons.join(' | ');
}
}
if(Path === undefined || !this.getValidLibraries().map(x => x.paths).flat(1).some(x => Path.includes(x))) {
if(Path === undefined) {
return 'media does not have a path, cannot be part of a valid library';
let anyPass = false;
const reasons = [];
if(this.getValidLibraries().some(x => x.id === libraryId)) {
anyPass = true;
} else {
reasons.push('Media does not belong to a valid library (using CollectionFolder filter)');
if(Path !== undefined) {
if(!this.getValidLibraries().map(x => x.paths).flat(1).some(x => Path.includes(x))) {
reasons.push('media path not found in any valid libraries');
} else {
anyPass = true;
}
}
return `media not included in a valid library`;
}
if(!anyPass) {
return reasons.join(' | ');
}
}
}
@@ -481,6 +553,17 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
const validSessions: PlayerStateDataMaybePlay[] = [];
for(const sessionData of nonMSSessions) {
const id = sessionData[1].NowPlayingItem.Id;
const mediaLibraryEntry = this.mediaIdLibrary.data.find(x => x.id === id)
if(mediaLibraryEntry === undefined) {
const ancestors = await getLibraryApi(this.api).getAncestors({itemId: id, userId: sessionData[1].UserId});
const f = ancestors.data.find(x => x.Type === 'CollectionFolder');
const entry: {id: string, libraryId: false | string} = {id, libraryId: false};
if(f !== undefined) {
entry.libraryId = f.Id;
}
this.mediaIdLibrary.add(entry);
}
const validPlay = this.isActivityValid(sessionData[0], sessionData[1]);
if(validPlay === true) {
validSessions.push(sessionData[0]);
+36 -5
View File
@@ -33,7 +33,15 @@ const createJfApi = (data: JellyApiData): JellyfinApiSource => {
data,
options: {}
}, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter());
jf.libraries = [{name: 'music', paths: ['/data/allmusic'], collectionType: 'music'}];
jf.libraries = [{name: 'music', paths: ['/data/allmusic'], id: '1234', collectionType: 'music'}];
return jf;
}
const createJfUser = (data: JellyApiData): JellyfinApiSource => {
const jf = new JellyfinApiSource('Test', {
data,
options: {}
}, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter());
jf.libraries = [{name: 'music', paths: [], id: '1234', collectionType: 'music'}];
return jf;
}
@@ -293,7 +301,7 @@ describe("Jellyfin API Source", function() {
it('Should allow activity based on additional libraries typed allowed', async function () {
const jf = createJfApi({...defaultJfApiCreds, additionalAllowedLibraryTypes: ['musicvideos']});
await jf.buildInitData();
jf.libraries.push({name: 'CoolVideos', paths: ['/data/someOtherFolder'], collectionType: 'musicvideos'});
jf.libraries.push({name: 'CoolVideos', paths: ['/data/someOtherFolder'], id: '1234', collectionType: 'musicvideos'});
expect(jf.isActivityValid(validPlayerState, nowPlayingSession({Path: '/data/someOtherFolder/myMusic.mp3'}))).to.be.true;
await jf.destroy();
@@ -308,10 +316,20 @@ describe("Jellyfin API Source", function() {
await jf.destroy();
});
it('Should allow activity based on libraries allow with library id only', async function () {
const jf = createJfUser({...defaultJfApiCreds, librariesAllow: ['music']});
await jf.buildInitData();
expect(jf.isActivityValid(validPlayerState, validSession)).to.not.be.true;
jf.mediaIdLibrary.add({id: '7648c5d1a50b1e03f225a2ce76baef07', 'libraryId': '1234'});
expect(jf.isActivityValid(validPlayerState, nowPlayingSession({}))).to.be.true;
await jf.destroy();
});
it('Should allow activity based on libraries allow and override library type restriction', async function () {
const jf = createJfApi({...defaultJfApiCreds, librariesAllow: ['CoolVideos','music']});
await jf.buildInitData();
jf.libraries.push({name: 'CoolVideos', paths: ['/data/someOtherFolder'], collectionType: 'musicvideos'});
jf.libraries.push({name: 'CoolVideos', paths: ['/data/someOtherFolder'], id: '1234', collectionType: 'musicvideos'});
expect(jf.isActivityValid(validPlayerState, validSession)).to.be.true;
expect(jf.isActivityValid(validPlayerState, nowPlayingSession({Path: '/data/someOtherFolder/myMusic.mp3'}))).to.be.true;
@@ -321,13 +339,26 @@ describe("Jellyfin API Source", function() {
it('Should disallow activity based on libraries block', async function () {
const jf = createJfApi({...defaultJfApiCreds, librariesBlock: ['music']});
await jf.buildInitData();
jf.libraries.push({name: 'CoolMusic', paths: ['/data/someOtherFolder'], collectionType: 'music'});
jf.libraries.push({name: 'CoolMusic', paths: ['/data/someOtherFolder'], id: '1234', collectionType: 'music'});
expect(jf.isActivityValid(validPlayerState, validSession)).to.not.be.true;
expect(jf.isActivityValid(validPlayerState, nowPlayingSession({Path: '/data/someOtherFolder/myMusic.mp3'}))).to.be.true;
await jf.destroy();
});
it('Should disallow activity based on libraries block with library id only', async function () {
const jf = createJfUser({...defaultJfApiCreds, librariesBlock: ['music']});
await jf.buildInitData();
jf.libraries.push({name: 'CoolMusic', paths: [], id: '4567', collectionType: 'music'});
jf.mediaIdLibrary.add({id: '7648c5d1a50b1e03f225a2ce76baef07', 'libraryId': '4567'});
expect(jf.isActivityValid(validPlayerState, validSession)).to.be.true;
jf.mediaIdLibrary.reset();
jf.mediaIdLibrary.add({id: '7648c5d1a50b1e03f225a2ce76baef07', 'libraryId': '1234'});
expect(jf.isActivityValid(validPlayerState, nowPlayingSession({}))).to.not.be.true;
await jf.destroy();
});
});
describe('Detection by Session/Media/Library Type', function() {
@@ -343,7 +374,7 @@ describe("Jellyfin API Source", function() {
it('Should disallow activity with invalid library type', async function () {
const jf = createJfApi({...defaultJfApiCreds});
await jf.buildInitData();
jf.libraries.push({name: 'CoolVideos', paths: ['/data/someOtherFolder'], collectionType: 'musicvideos'});
jf.libraries.push({name: 'CoolVideos', paths: ['/data/someOtherFolder'], id: '1234', collectionType: 'musicvideos'});
expect(jf.isActivityValid(validPlayerState, nowPlayingSession({Path: '/data/someOtherFolder/myMusic.mp3'}))).to.not.be.true;
await jf.destroy();