mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8d840a94 | ||
|
|
aac4f0f5e7 |
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user