Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions server/api/plextv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import { randomUUID } from 'node:crypto';
import xml2js from 'xml2js';
import ExternalAPI from './externalapi';

// Plex returns seconds, so fall back to a millisecond value if that ever changes.
const normalizeWatchlistedAt = (value: number | undefined): number | null => {
if (value === undefined || value <= 0) {
return null;
}

return value > 1e11 ? Math.floor(value / 1000) : value;
};

interface PlexAccountResponse {
user: PlexUser;
}
Expand Down Expand Up @@ -122,6 +131,14 @@ interface MetadataResponse {
};
}

interface UserStateResponse {
MediaContainer: {
UserState?: {
watchlistedAt?: number;
}[];
};
}

export interface PlexWatchlistItem {
ratingKey: string;
tmdbId: number;
Expand Down Expand Up @@ -392,6 +409,43 @@ class PlexTvAPI extends ExternalAPI {
}
}

// Deliberately not this.get(), as ExternalAPI's cache key omits the auth token
// and would serve one user's watchlist state to another.
public async getWatchlistedAt(ratingKey: string): Promise<number | null> {
const watchlistCache = cacheManager.getCache('plexwatchlist');
const cacheKey = `userstate:${this.authToken}:${ratingKey}`;
const cached = watchlistCache.data.get<number | null>(cacheKey);

if (cached !== undefined) {
return cached;
}

try {
const response = await this.axios.get<UserStateResponse>(
`/library/metadata/${ratingKey}/userState`,
{
baseURL: 'https://discover.provider.plex.tv',
}
);
Comment thread
fallenbagel marked this conversation as resolved.
Dismissed

const watchlistedAt = normalizeWatchlistedAt(
response.data.MediaContainer.UserState?.[0]?.watchlistedAt
);

watchlistCache.data.set(cacheKey, watchlistedAt);

return watchlistedAt;
} catch (e) {
logger.warn('Failed to retrieve watchlist state for item', {
label: 'Plex.TV Metadata API',
ratingKey,
errorMessage: e.message,
});

return null;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

public async pingToken() {
try {
const response = await this.axios.get('/api/v2/ping', {
Expand Down
4 changes: 4 additions & 0 deletions server/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import { UserSettings } from '@server/entity/UserSettings';
import { Watchlist } from '@server/entity/Watchlist';
import { WatchlistSyncEntry } from '@server/entity/WatchlistSyncEntry';
import { WatchlistSyncEntrySeason } from '@server/entity/WatchlistSyncEntrySeason';
import { IssueCommentSubscriber } from '@server/subscriber/IssueCommentSubscriber';
import { IssueSubscriber } from '@server/subscriber/IssueSubscriber';
import { MediaRequestSubscriber } from '@server/subscriber/MediaRequestSubscriber';
Expand Down Expand Up @@ -39,6 +41,8 @@ const entities = [
UserPushSubscription,
UserSettings,
Watchlist,
WatchlistSyncEntry,
WatchlistSyncEntrySeason,
];

const subscribers = [
Expand Down
58 changes: 58 additions & 0 deletions server/entity/WatchlistSyncEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { MediaType } from '@server/constants/media';
import { User } from '@server/entity/User';
import { WatchlistSyncEntrySeason } from '@server/entity/WatchlistSyncEntrySeason';
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
import {
Column,
Entity,
Index,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';

@Entity()
@Unique('UNIQUE_WATCHLIST_SYNC_ENTRY', ['requestedBy', 'tmdbId', 'mediaType'])
export class WatchlistSyncEntry {
@PrimaryGeneratedColumn()
public id: number;

@Column()
@Index()
public tmdbId: number;

@Column({ type: 'varchar' })
public mediaType: MediaType;

// Unix seconds, verbatim from Plex.
@Column({ type: 'int' })
public watchlistedAt: number;
Comment thread
fallenbagel marked this conversation as resolved.
Resolved

@ManyToOne(() => User, {
onDelete: 'CASCADE',
nullable: false,
})
@Index()
public requestedBy: User;

@OneToMany(() => WatchlistSyncEntrySeason, (season) => season.entry, {
cascade: true,
eager: true,
})
public seasons: WatchlistSyncEntrySeason[];

@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;

@UpdateDateColumn({
type: resolveDbType('datetime'),
default: () => 'CURRENT_TIMESTAMP',
})
public updatedAt: Date;

constructor(init?: Partial<WatchlistSyncEntry>) {
Object.assign(this, init);
}
}
31 changes: 31 additions & 0 deletions server/entity/WatchlistSyncEntrySeason.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { WatchlistSyncEntry } from '@server/entity/WatchlistSyncEntry';
import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';

@Entity()
@Unique('UNIQUE_WATCHLIST_SYNC_ENTRY_SEASON', ['entry', 'seasonNumber'])
export class WatchlistSyncEntrySeason {
@PrimaryGeneratedColumn()
public id: number;

@Column()
public seasonNumber: number;

@ManyToOne(() => WatchlistSyncEntry, (entry) => entry.seasons, {
onDelete: 'CASCADE',
nullable: false,
orphanedRowAction: 'delete',
})
@Index()
public entry: WatchlistSyncEntry;

constructor(init?: Partial<WatchlistSyncEntrySeason>) {
Object.assign(this, init);
}
}
1 change: 1 addition & 0 deletions server/job/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const startJobs = (): void => {
});
});
}),
running: () => watchlistSync.running,
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
Expand Down
Loading
Loading