Persist client_identifier for plex server to DB (#167)

* Add new Plex server column for client identifier

* Implement fixer to backfill plex server client identifiers

* Fix build error
This commit is contained in:
Christian Benincasa
2024-03-12 11:20:07 -04:00
committed by GitHub
parent 3cdcc09296
commit 16ab7cdf6f
18 changed files with 207 additions and 114 deletions

20
pnpm-lock.yaml generated
View File

@@ -89,6 +89,9 @@ importers:
fast-json-stringify:
specifier: ^5.9.1
version: 5.9.1
fast-xml-parser:
specifier: ^4.3.5
version: 4.3.5
fastify:
specifier: ^4.24.3
version: 4.24.3
@@ -104,9 +107,6 @@ importers:
fluent-ffmpeg:
specifier: ^2.1.2
version: 2.1.2
lodash:
specifier: ^4.17.21
version: 4.17.21
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@@ -183,9 +183,6 @@ importers:
'@types/fluent-ffmpeg':
specifier: ^2.1.23
version: 2.1.23
'@types/lodash':
specifier: ^4.14.202
version: 4.14.202
'@types/lodash-es':
specifier: ^4.17.10
version: 4.17.10
@@ -4925,6 +4922,13 @@ packages:
resolution: {integrity: sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==}
dev: false
/fast-xml-parser@4.3.5:
resolution: {integrity: sha512-sWvP1Pl8H03B8oFJpFR3HE31HUfwtX7Rlf9BNsvdpujD4n7WMhfmu8h9wOV2u+c1k0ZilTADhPqypzx2J690ZQ==}
hasBin: true
dependencies:
strnum: 1.0.5
dev: false
/fastify-plugin@4.5.1:
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
dev: false
@@ -8373,6 +8377,10 @@ packages:
escape-string-regexp: 1.0.5
dev: true
/strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
dev: false
/stylis@4.2.0:
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
dev: false

View File

@@ -42,12 +42,12 @@
"dayjs": "^1.11.10",
"express-fileupload": "^1.2.1",
"fast-json-stringify": "^5.9.1",
"fast-xml-parser": "^4.3.5",
"fastify": "^4.24.3",
"fastify-plugin": "^4.5.1",
"fastify-print-routes": "^2.2.0",
"fastify-type-provider-zod": "^1.1.9",
"fluent-ffmpeg": "^2.1.2",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lowdb": "^7.0.0",
"morgan": "^1.10.0",
@@ -75,7 +75,6 @@
"@types/express": "^4.17.20",
"@types/express-fileupload": "^1.4.3",
"@types/fluent-ffmpeg": "^2.1.23",
"@types/lodash": "^4.14.202",
"@types/lodash-es": "^4.17.10",
"@types/morgan": "^1.9.7",
"@types/node": "^20.8.9",

View File

@@ -156,7 +156,7 @@ export const miscRouter: RouterPluginCallback = (fastify, _opts, done) => {
}
const plex = new Plex(server);
return res.send(await plex.Get(req.query.path));
return res.send(await plex.doGet(req.query.path));
},
);

View File

@@ -1,6 +1,6 @@
import { Entity, Property, Unique } from '@mikro-orm/core';
import { BaseEntity } from './BaseEntity.js';
import { PlexServerSettings as PlexServerSettingsDTO } from '@tunarr/types';
import { BaseEntity } from './BaseEntity.js';
@Entity()
@Unique({ properties: ['name', 'uri'] })
@@ -23,6 +23,10 @@ export class PlexServerSettings extends BaseEntity {
@Property()
index: number;
// Nullable for now!
@Property({ nullable: true })
clientIdentifier?: string;
toDTO(): PlexServerSettingsDTO {
return {
id: this.uuid,
@@ -32,6 +36,7 @@ export class PlexServerSettings extends BaseEntity {
sendChannelUpdates: this.sendChannelUpdates,
sendGuideUpdates: this.sendGuideUpdates,
index: this.index,
clientIdentifier: this.clientIdentifier,
};
}
}

View File

@@ -617,6 +617,15 @@
"primary": false,
"nullable": false,
"mappedType": "integer"
},
"client_identifier": {
"name": "client_identifier",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
}
},
"name": "plex_server_settings",

View File

@@ -0,0 +1,9 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240308184352 extends Migration {
async up(): Promise<void> {
this.addSql('alter table `plex_server_settings` add column `client_identifier` text null;');
}
}

View File

@@ -1,3 +1,5 @@
import { EntityDTO } from '@mikro-orm/core';
import { PlexDvr, PlexDvrsResponse, PlexResource } from '@tunarr/types/plex';
import axios, {
AxiosInstance,
AxiosRequestConfig,
@@ -5,18 +7,19 @@ import axios, {
RawAxiosRequestHeaders,
isAxiosError,
} from 'axios';
import { isUndefined } from 'lodash-es';
import { XMLParser } from 'fast-xml-parser';
import { isNil, isUndefined } from 'lodash-es';
import NodeCache from 'node-cache';
import querystring, { ParsedUrlQueryInput } from 'querystring';
import { PlexServerSettings } from './dao/entities/PlexServerSettings.js';
import createLogger from './logger.js';
import { Maybe } from './types.js';
import {
PlexMediaContainer,
PlexMediaContainerResponse,
} from './types/plexApiTypes.js';
import { PlexServerSettings } from './dao/entities/PlexServerSettings.js';
import { EntityDTO } from '@mikro-orm/core';
import { PlexDvr, PlexDvrsResponse } from '@tunarr/types/plex';
import NodeCache from 'node-cache';
const ClientIdentifier = 'p86cy1w47clco3ro8t92nfy1';
type AxiosConfigWithMetadata = InternalAxiosRequestConfig & {
metadata: {
@@ -28,11 +31,11 @@ const logger = createLogger(import.meta);
const DEFAULT_HEADERS = {
Accept: 'application/json',
'X-Plex-Device': 'dizqueTV',
'X-Plex-Device-Name': 'dizqueTV',
'X-Plex-Product': 'dizqueTV',
'X-Plex-Device': 'Tunarr',
'X-Plex-Device-Name': 'Tunarr',
'X-Plex-Product': 'Tunarr',
'X-Plex-Version': '0.1',
'X-Plex-Client-Identifier': 'rg14zekk3pa5zp4safjwaa8z',
'X-Plex-Client-Identifier': ClientIdentifier,
'X-Plex-Platform': 'Chrome',
'X-Plex-Platform-Version': '80.0',
};
@@ -85,7 +88,7 @@ export class Plex {
return req;
});
const logAxiosRequest = (req: AxiosConfigWithMetadata) => {
const logAxiosRequest = (req: AxiosConfigWithMetadata, status: number) => {
const query = req.params
? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
`?${querystring.stringify(req.params)}`
@@ -94,18 +97,21 @@ export class Plex {
logger.debug(
`[Axios Request]: ${req.method?.toUpperCase()} ${req.baseURL}${
req.url
}${query} - ${elapsedTime}ms`,
}${query} - (${status}) ${elapsedTime}ms`,
);
};
this.axiosInstance.interceptors.response.use(
(resp) => {
logAxiosRequest(resp.config as AxiosConfigWithMetadata);
logAxiosRequest(resp.config as AxiosConfigWithMetadata, resp.status);
return resp;
},
(err) => {
if (isAxiosError(err) && err.config) {
logAxiosRequest(err.config as AxiosConfigWithMetadata);
logAxiosRequest(
err.config as AxiosConfigWithMetadata,
err.status ?? -1,
);
}
throw err;
},
@@ -139,7 +145,7 @@ export class Plex {
}
}
async Get<T>(
async doGet<T>(
path: string,
optionalHeaders: RawAxiosRequestHeaders = {},
): Promise<Maybe<PlexMediaContainer<T>>> {
@@ -162,7 +168,7 @@ export class Plex {
return res?.MediaContainer;
}
Put(
doPut(
path: string,
query: ParsedUrlQueryInput | URLSearchParams = {},
optionalHeaders: RawAxiosRequestHeaders = {},
@@ -183,7 +189,7 @@ export class Plex {
return this.doRequest(req);
}
Post(
doPost(
path: string,
query: ParsedUrlQueryInput | URLSearchParams = {},
optionalHeaders: RawAxiosRequestHeaders = {},
@@ -206,7 +212,7 @@ export class Plex {
async checkServerStatus() {
try {
await this.Get('/');
await this.doGet('/');
return 1;
} catch (err) {
console.error('Error getting Plex server status', err);
@@ -214,10 +220,10 @@ export class Plex {
}
}
async GetDVRS() {
async getDvrs() {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await this.Get<PlexDvrsResponse>('/livetv/dvrs');
const result = await this.doGet<PlexDvrsResponse>('/livetv/dvrs');
return isUndefined(result?.Dvr) ? [] : result?.Dvr;
} catch (err) {
logger.error('GET /livetv/drs failed: ', err);
@@ -225,18 +231,20 @@ export class Plex {
}
}
async RefreshGuide(_dvrs?: PlexDvr[]) {
const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.GetDVRS();
async getResources() {}
async refreshGuide(_dvrs?: PlexDvr[]) {
const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs();
if (!dvrs) {
throw new Error('Could not retrieve Plex DVRs');
}
for (let i = 0; i < dvrs.length; i++) {
await this.Post(`/livetv/dvrs/${dvrs[i].key}/reloadGuide`);
await this.doPost(`/livetv/dvrs/${dvrs[i].key}/reloadGuide`);
}
}
async RefreshChannels(channels: { number: number }[], _dvrs?: PlexDvr[]) {
const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.GetDVRS();
async refreshChannels(channels: { number: number }[], _dvrs?: PlexDvr[]) {
const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs();
if (!dvrs) throw new Error('Could not retrieve Plex DVRs');
const _channels: number[] = [];
@@ -251,11 +259,33 @@ export class Plex {
}
for (let i = 0; i < dvrs.length; i++) {
for (let y = 0; y < dvrs[i].Device.length; y++) {
await this.Put(
await this.doPut(
`/media/grabbers/devices/${dvrs[i].Device[y].key}/channelmap`,
qs,
);
}
}
}
async getDevices(): Promise<Maybe<PlexTvDevicesResponse>> {
const response = await this.doRequest<string>({
method: 'get',
baseURL: 'https://plex.tv',
url: '/devices.xml',
});
if (isNil(response)) {
return;
}
const parsed = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
}).parse(response) as PlexTvDevicesResponse;
return parsed;
}
}
type PlexTvDevicesResponse = {
MediaContainer: { Device: PlexResource[] };
};

View File

@@ -553,7 +553,7 @@ lang=en`;
}
async getDecisionUnmanaged(directPlay: boolean) {
this.decisionJson = await this.plex.Get<TranscodeDecision>(
this.decisionJson = await this.plex.doGet<TranscodeDecision>(
`/video/:/transcode/universal/decision?${this.transcodingArgs}`,
);
@@ -626,7 +626,7 @@ lang=en`;
return this.cachedItemMetadata;
}
this.cachedItemMetadata = await this.plex.Get<PlexItemMetadata>(this.key);
this.cachedItemMetadata = await this.plex.doGet<PlexItemMetadata>(this.key);
return this.cachedItemMetadata;
}
@@ -654,7 +654,7 @@ lang=en`;
this.log('Updating plex status');
const { path: statusUrl, params } = this.getStatusUrl();
try {
await this.plex.Post(statusUrl, params);
await this.plex.doPost(statusUrl, params);
} catch (error) {
this.log(
`Problem updating Plex status using status URL ${statusUrl}: `,

View File

@@ -0,0 +1,30 @@
import { find, isNil } from 'lodash-es';
import { EntityManager } from '../../dao/dataSource.js';
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js';
import { Plex } from '../../plex.js';
import Fixer from './fixer.js';
export class AddPlexServerIdsFixer extends Fixer {
async runInternal(em: EntityManager): Promise<void> {
const plexServers = await em
.repo(PlexServerSettings)
.find({ clientIdentifier: null });
for (const server of plexServers) {
const api = new Plex(server);
const devices = await api.getDevices();
if (!isNil(devices) && devices.MediaContainer.Device) {
const matchingServer = find(
devices.MediaContainer.Device,
(d) => d.provides.includes('server') && d.name === server.name,
);
if (matchingServer) {
server.clientIdentifier = matchingServer.clientIdentifier;
em.persist(server);
}
}
}
await em.flush();
}
}

View File

@@ -1,4 +1,5 @@
import createLogger from '../../logger.js';
import { AddPlexServerIdsFixer } from './addPlexServerIds.js';
import Fixer from './fixer.js';
import { MissingSeasonNumbersFixer } from './missingSeasonNumbersFixer.js';
@@ -11,7 +12,10 @@ const logger = createLogger(import.meta);
// Maybe one day we'll import these all dynamically and run
// them, but not today.
export const runFixers = async () => {
const allFixers: Fixer[] = [new MissingSeasonNumbersFixer()];
const allFixers: Fixer[] = [
new MissingSeasonNumbersFixer(),
new AddPlexServerIdsFixer(),
];
for (const fixer of allFixers) {
try {

View File

@@ -122,7 +122,7 @@ export class MissingSeasonNumbersFixer extends Fixer {
private async findSeasonNumberUsingEpisode(episodeId: string, plex: Plex) {
try {
const episode = await plex.Get<PlexEpisodeView>(
const episode = await plex.doGet<PlexEpisodeView>(
`/library/metadata/${episodeId}`,
);
return episode?.parentIndex;
@@ -136,7 +136,7 @@ export class MissingSeasonNumbersFixer extends Fixer {
// We get the parent because we're dealing with an episode and we want the
// season index.
try {
const season = await plex.Get<PlexSeasonView>(
const season = await plex.doGet<PlexSeasonView>(
`/library/metadata/${seasonId}`,
);
return first(season?.Metadata ?? [])?.index;

View File

@@ -1,9 +1,9 @@
import { Loaded, wrap } from '@mikro-orm/core';
import { ChannelCache } from '../channelCache.js';
import { withDb } from '../dao/dataSource.js';
import { Settings } from '../dao/settings.js';
import { Channel } from '../dao/entities/Channel.js';
import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js';
import { Settings } from '../dao/settings.js';
import createLogger from '../logger.js';
import { Plex } from '../plex.js';
import { ServerContext } from '../serverContext.js';
@@ -86,7 +86,7 @@ export class UpdateXmlTvTask extends Task<void> {
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
dvrs = await plex.GetDVRS(); // Refresh guide and channel mappings
dvrs = await plex.getDvrs(); // Refresh guide and channel mappings
} catch (err) {
logger.error(
`Couldn't get DVRS list from ${plexServer.name}. This error will prevent 'refresh guide' or 'refresh channels' from working for this Plex server. But it is NOT related to playback issues.`,
@@ -96,7 +96,7 @@ export class UpdateXmlTvTask extends Task<void> {
}
if (plexServer.sendGuideUpdates) {
try {
await plex.RefreshGuide(dvrs);
await plex.refreshGuide(dvrs);
} catch (err) {
logger.error(
`Couldn't tell Plex ${plexServer.name} to refresh guide for some reason. This error will prevent 'refresh guide' from working for this Plex server. But it is NOT related to playback issues.`,
@@ -106,7 +106,7 @@ export class UpdateXmlTvTask extends Task<void> {
}
if (plexServer.sendChannelUpdates && channels.length !== 0) {
try {
await plex.RefreshChannels(channels, dvrs);
await plex.refreshChannels(channels, dvrs);
} catch (err) {
logger.error(
`Couldn't tell Plex ${plexServer.name} to refresh channels for some reason. This error will prevent 'refresh channels' from working for this Plex server. But it is NOT related to playback issues.`,

View File

@@ -1,17 +1,11 @@
import z from 'zod';
import {
PlexServerSettingsInsert,
PlexServerSettingsRemove,
PlexServerSettingsSchema,
PlexStreamSettingsSchema,
} from './schemas/settingsSchemas.js';
export type PlexServerSettings = z.infer<typeof PlexServerSettingsSchema>;
export type PlexServerInsert = z.infer<typeof PlexServerSettingsInsert>;
export type PlexServerRemove = z.infer<typeof PlexServerSettingsRemove>;
export type PlexStreamSettings = z.infer<typeof PlexStreamSettingsSchema>;
export const defaultPlexStreamSettings = PlexStreamSettingsSchema.parse({});

View File

@@ -106,6 +106,7 @@ export type UpdateChannelProgrammingRequest = Alias<
export const UpdatePlexServerRequestSchema = PlexServerSettingsSchema.partial({
sendChannelUpdates: true,
sendGuideUpdates: true,
clientIdentifier: true,
id: true,
});
@@ -117,6 +118,7 @@ export const InsertPlexServerRequestSchema = PlexServerSettingsSchema.partial({
sendChannelUpdates: true,
sendGuideUpdates: true,
index: true,
clientIdentifier: true,
}).omit({
id: true,
});

View File

@@ -488,3 +488,64 @@ export type PlexChildMediaApiType<Target extends PlexMedia> = FindChild0<
Target,
PlexMediaApiChildType
>;
export const PlexPinsResponseSchema = z.object({
authToken: z.string().nullable(),
clientIdentifier: z.string(),
code: z.string(),
createdAt: z.string(),
expiresAt: z.string(),
expiresIn: z.number(),
id: z.number(),
product: z.string(),
qr: z.string(),
trusted: z.boolean(),
});
export type PlexPinsResponse = Alias<z.infer<typeof PlexPinsResponseSchema>>;
export const PlexConnectionSchema = z.object({
IPv6: z.boolean(),
address: z.string(),
local: z.boolean(),
port: z.number(),
protocol: z.string(),
relay: z.boolean(),
uri: z.string(),
});
export type PlexConnection = Alias<z.infer<typeof PlexConnectionSchema>>;
export const PlexResourceSchema = z.object({
accessToken: z.string(),
clientIdentifier: z.string(),
connections: z.array(PlexConnectionSchema),
createdAt: z.string(),
device: z.string(),
dnsRebindingProtection: z.boolean(),
home: z.boolean(),
httpsRequired: z.boolean(),
lastSeenAt: z.string(),
name: z.string(),
owned: z.boolean(),
ownerId: z.string().nullable(),
platform: z.string(),
platformVersion: z.string(),
presence: z.boolean(),
product: z.string(),
productVersion: z.string(),
provides: z.string(),
publicAddress: z.string(),
publicAddressMatches: z.boolean(),
relay: z.boolean(),
sourceTitle: z.string().nullable(),
synced: z.boolean(),
});
export type PlexResource = Alias<z.infer<typeof PlexResourceSchema>>;
export const PlexResourcesResponseSchema = z.array(PlexResourceSchema);
export type PlexResourcesResponse = Alias<
z.infer<typeof PlexResourcesResponseSchema>
>;

View File

@@ -62,18 +62,7 @@ export const PlexServerSettingsSchema = z.object({
sendGuideUpdates: z.boolean(),
sendChannelUpdates: z.boolean(),
index: z.number(),
});
export const PlexServerSettingsInsert = z.object({
name: z.string(),
uri: z.string(),
accessToken: z.string(),
sendGuideUpdates: z.boolean().optional(),
sendChannelUpdates: z.boolean().optional(),
});
export const PlexServerSettingsRemove = z.object({
id: z.string(),
clientIdentifier: z.string().optional(),
});
export const PlexStreamSettingsSchema = z.object({

View File

@@ -33,6 +33,7 @@ export default function AddPlexServer(props: AddPlexServer) {
name: server.name,
uri: connection.uri,
accessToken: server.accessToken,
clientIdentifier: server.clientIdentifier,
}),
);
})

View File

@@ -1,3 +1,4 @@
import { PlexPinsResponse, PlexResourcesResponse } from '@tunarr/types/plex';
import { compact, partition } from 'lodash-es';
import { apiClient } from '../external/api.ts';
import { AsyncInterval } from './AsyncInterval.ts';
@@ -14,55 +15,6 @@ const PlexLoginHeaders = {
'X-Plex-Model': 'Plex OAuth',
};
type PlexPinsResponse = {
authToken: string | null;
clientIdentifier: string;
code: string;
createdAt: string;
expiresAt: string;
expiresIn: number;
id: number;
product: string;
qr: string;
trusted: boolean;
};
type PlexConnection = {
IPv6: boolean;
address: string;
local: boolean;
port: number;
protocol: string;
relay: boolean;
uri: string;
};
type PlexResourcesResponse = {
accessToken: string;
clientIdentifier: string;
connections: PlexConnection[];
createdAt: string;
device: string;
dnsRebindingProtection: boolean;
home: boolean;
httpsRequired: boolean;
lastSeenAt: string;
name: string;
owned: boolean;
ownerId: string | null;
platform: string;
platformVersion: string;
presence: boolean;
product: string;
productVersion: string;
provides: string;
publicAddress: string;
publicAddressMatches: boolean;
relay: boolean;
sourceTitle: string | null;
synced: boolean;
};
export const plexLoginFlow = async () => {
const request = new Request('https://plex.tv/api/v2/pins?strong=true', {
method: 'POST',
@@ -144,12 +96,12 @@ export const plexLoginFlow = async () => {
'X-Plex-Token': authToken,
},
},
).then((res) => res.json() as Promise<PlexResourcesResponse[]>);
).then((res) => res.json() as Promise<PlexResourcesResponse>);
return serversResponse.filter((server) => server.provides.includes('server'));
};
export const checkNewPlexServers = async (servers: PlexResourcesResponse[]) => {
export const checkNewPlexServers = async (servers: PlexResourcesResponse) => {
return sequentialPromises(servers, async (server) => {
const [localConnections, remoteConnections] = partition(
server.connections,