Skip to content
Merged
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
20 changes: 16 additions & 4 deletions server/entity/MediaRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,18 +151,26 @@ export class MediaRequest {
throw new BlocklistedMediaError('This media is blocklisted.');
}

if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
if (
(media.status === MediaStatus.UNKNOWN ||
media.status === MediaStatus.DELETED) &&
!requestBody.is4k
) {
media.status = MediaStatus.PENDING;
}

if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
if (
(media.status4k === MediaStatus.UNKNOWN ||
media.status4k === MediaStatus.DELETED) &&
requestBody.is4k
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
media.status4k = MediaStatus.PENDING;
}
}

const existing = await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.leftJoinAndSelect('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
Expand Down Expand Up @@ -192,9 +200,13 @@ export class MediaRequest {

// If an existing auto-request for this media exists from the same user,
// don't allow a new one.
const statusKey = requestBody.is4k ? 'status4k' : 'status';
if (
existing.find(
(r) => r.requestedBy.id === requestUser.id && r.isAutoRequest
(r) =>
r.requestedBy.id === requestUser.id &&
r.isAutoRequest &&
r.media?.[statusKey] !== MediaStatus.DELETED
)
) {
throw new DuplicateMediaRequestError(
Expand Down
199 changes: 199 additions & 0 deletions server/lib/watchlistsync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import type { PlexWatchlistItem } from '@server/api/plextv';
import PlexTvAPI from '@server/api/plextv';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import { UserSettings } from '@server/entity/UserSettings';
import { Permission } from '@server/lib/permissions';
import { setupTestDb } from '@server/test/db';
import assert from 'node:assert/strict';
import { beforeEach, describe, it } from 'node:test';

let watchlistItems: PlexWatchlistItem[] = [];

Object.defineProperty(PlexTvAPI.prototype, 'getWatchlist', {
get() {
return async () => ({
offset: 0,
size: 20,
totalSize: watchlistItems.length,
items: watchlistItems,
});
},
set() {},
configurable: true,
});

let requestCalls: { mediaId: number; mediaType: MediaType }[] = [];

Object.defineProperty(MediaRequest, 'request', {
value: async (body: { mediaId: number; mediaType: MediaType }) => {
requestCalls.push({ mediaId: body.mediaId, mediaType: body.mediaType });
return {} as MediaRequest;
},
writable: true,
configurable: true,
});

import watchlistSync from '@server/lib/watchlistsync';

setupTestDb();

async function configureSyncUser(): Promise<User> {
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ where: { id: 1 } });

admin.plexToken = 'test-plex-token';
admin.permissions = Permission.AUTO_REQUEST;
await userRepository.save(admin);

const userSettingsRepository = getRepository(UserSettings);
await userSettingsRepository.save(
new UserSettings({
user: admin,
watchlistSyncMovies: true,
watchlistSyncTv: true,
})
);

return admin;
}

async function seedMedia(
tmdbId: number,
mediaType: MediaType,
status: MediaStatus
): Promise<void> {
const mediaRepository = getRepository(Media);
await mediaRepository.save(
new Media({
tmdbId,
mediaType,
status,
status4k: MediaStatus.UNKNOWN,
})
);
}

function movieItem(tmdbId: number, title: string): PlexWatchlistItem {
return { ratingKey: `rk-${tmdbId}`, tmdbId, title, type: 'movie' };
}

function showItem(tmdbId: number, title: string): PlexWatchlistItem {
return {
ratingKey: `rk-${tmdbId}`,
tmdbId,
tvdbId: tmdbId * 1000,
title,
type: 'show',
};
}

describe('WatchlistSync re-request gating', () => {
beforeEach(() => {
requestCalls = [];
watchlistItems = [];
});

it('re-requests DELETED watchlist items and skips non-requestable ones', async () => {
await configureSyncUser();

await seedMedia(100, MediaType.MOVIE, MediaStatus.DELETED);
await seedMedia(101, MediaType.MOVIE, MediaStatus.UNKNOWN);
await seedMedia(102, MediaType.MOVIE, MediaStatus.AVAILABLE);
await seedMedia(103, MediaType.MOVIE, MediaStatus.BLOCKLISTED);

await seedMedia(200, MediaType.TV, MediaStatus.DELETED);
await seedMedia(201, MediaType.TV, MediaStatus.AVAILABLE);

watchlistItems = [
movieItem(100, 'Deleted Movie'),
movieItem(101, 'Unknown Movie'),
movieItem(102, 'Available Movie'),
movieItem(103, 'Blocklisted Movie'),
showItem(200, 'Deleted Show'),
showItem(201, 'Available Show'),
];

await watchlistSync.syncWatchlist();

const requestedArray = requestCalls.map(
(c) => `${c.mediaType}:${c.mediaId}`
);
const requested = new Set(requestedArray);

assert.strictEqual(
requestedArray.length,
requested.size,
'Each item should be requested exactly once'
);

Comment thread
fallenbagel marked this conversation as resolved.
assert.ok(
requested.has(`${MediaType.MOVIE}:100`),
'DELETED movie on the watchlist should be re-requested'
);

assert.ok(
requested.has(`${MediaType.MOVIE}:101`),
'UNKNOWN movie should be requested'
);
assert.ok(
!requested.has(`${MediaType.MOVIE}:102`),
'AVAILABLE movie should NOT be requested'
);
assert.ok(
!requested.has(`${MediaType.MOVIE}:103`),
'BLOCKLISTED movie should NOT be requested'
);

assert.ok(
requested.has(`${MediaType.TV}:200`),
'DELETED show should be re-requested'
);
assert.ok(
!requested.has(`${MediaType.TV}:201`),
'AVAILABLE show should NOT be requested'
);
});

it('re-requests DELETED watchlist items even when a stale auto-request exists', async () => {
const user = await configureSyncUser();

await seedMedia(100, MediaType.MOVIE, MediaStatus.DELETED);

const media = await getRepository(Media).findOneOrFail({
where: { tmdbId: 100, mediaType: MediaType.MOVIE },
});

await getRepository(MediaRequest).save(
new MediaRequest({
type: MediaType.MOVIE,
status: MediaRequestStatus.COMPLETED,
media,
requestedBy: user,
is4k: false,
isAutoRequest: true,
})
);

watchlistItems = [movieItem(100, 'Deleted Movie')];

await watchlistSync.syncWatchlist();

const calls = requestCalls.filter(
(c) => c.mediaType === MediaType.MOVIE && c.mediaId === 100
);

assert.strictEqual(
calls.length,
1,
'DELETED movie should be re-requested even when a stale auto-request exists'
);
});
});
7 changes: 5 additions & 2 deletions server/lib/watchlistsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ class WatchlistSync {

const autoRequestedTmdbIds = new Set(
existingAutoRequests
.filter((r) => r.media != null)
.filter(
(r) => r.media != null && r.media.status !== MediaStatus.DELETED
)
.map((r) => `${r.media.mediaType}:${r.media.tmdbId}`)
);

Expand All @@ -106,7 +108,8 @@ class WatchlistSync {
m.mediaType === itemMediaType &&
(m.status === MediaStatus.BLOCKLISTED ||
(itemMediaType === MediaType.MOVIE &&
m.status !== MediaStatus.UNKNOWN) ||
m.status !== MediaStatus.UNKNOWN &&
m.status !== MediaStatus.DELETED) ||
(itemMediaType === MediaType.TV &&
m.status === MediaStatus.AVAILABLE))
)
Expand Down
Loading