Skip to content

Commit 3f02025

Browse files
committed
fix(watchlistsync): re-request deleted media from watchlist
Allow watchlist sync to re-request movies and shows whose media status is DELETED, and fix the duplicate auto-request guard blocking both sync and manual requests
1 parent 4ed29cf commit 3f02025

3 files changed

Lines changed: 220 additions & 6 deletions

File tree

server/entity/MediaRequest.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,18 +151,26 @@ export class MediaRequest {
151151
throw new BlocklistedMediaError('This media is blocklisted.');
152152
}
153153

154-
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
154+
if (
155+
(media.status === MediaStatus.UNKNOWN ||
156+
media.status === MediaStatus.DELETED) &&
157+
!requestBody.is4k
158+
) {
155159
media.status = MediaStatus.PENDING;
156160
}
157161

158-
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
162+
if (
163+
(media.status4k === MediaStatus.UNKNOWN ||
164+
media.status4k === MediaStatus.DELETED) &&
165+
requestBody.is4k
166+
) {
159167
media.status4k = MediaStatus.PENDING;
160168
}
161169
}
162170

163171
const existing = await requestRepository
164172
.createQueryBuilder('request')
165-
.leftJoin('request.media', 'media')
173+
.leftJoinAndSelect('request.media', 'media')
166174
.leftJoinAndSelect('request.requestedBy', 'user')
167175
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
168176
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
@@ -192,9 +200,13 @@ export class MediaRequest {
192200

193201
// If an existing auto-request for this media exists from the same user,
194202
// don't allow a new one.
203+
const statusKey = requestBody.is4k ? 'status4k' : 'status';
195204
if (
196205
existing.find(
197-
(r) => r.requestedBy.id === requestUser.id && r.isAutoRequest
206+
(r) =>
207+
r.requestedBy.id === requestUser.id &&
208+
r.isAutoRequest &&
209+
r.media?.[statusKey] !== MediaStatus.DELETED
198210
)
199211
) {
200212
throw new DuplicateMediaRequestError(

server/lib/watchlistsync.test.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import type { PlexWatchlistItem } from '@server/api/plextv';
2+
import PlexTvAPI from '@server/api/plextv';
3+
import {
4+
MediaRequestStatus,
5+
MediaStatus,
6+
MediaType,
7+
} from '@server/constants/media';
8+
import { getRepository } from '@server/datasource';
9+
import Media from '@server/entity/Media';
10+
import { MediaRequest } from '@server/entity/MediaRequest';
11+
import { User } from '@server/entity/User';
12+
import { UserSettings } from '@server/entity/UserSettings';
13+
import { Permission } from '@server/lib/permissions';
14+
import { setupTestDb } from '@server/test/db';
15+
import assert from 'node:assert/strict';
16+
import { beforeEach, describe, it } from 'node:test';
17+
18+
let watchlistItems: PlexWatchlistItem[] = [];
19+
20+
Object.defineProperty(PlexTvAPI.prototype, 'getWatchlist', {
21+
get() {
22+
return async () => ({
23+
offset: 0,
24+
size: 20,
25+
totalSize: watchlistItems.length,
26+
items: watchlistItems,
27+
});
28+
},
29+
set() {},
30+
configurable: true,
31+
});
32+
33+
let requestCalls: { mediaId: number; mediaType: MediaType }[] = [];
34+
35+
Object.defineProperty(MediaRequest, 'request', {
36+
value: async (body: { mediaId: number; mediaType: MediaType }) => {
37+
requestCalls.push({ mediaId: body.mediaId, mediaType: body.mediaType });
38+
return {} as MediaRequest;
39+
},
40+
writable: true,
41+
configurable: true,
42+
});
43+
44+
import watchlistSync from '@server/lib/watchlistsync';
45+
46+
setupTestDb();
47+
48+
async function configureSyncUser(): Promise<User> {
49+
const userRepository = getRepository(User);
50+
const admin = await userRepository.findOneOrFail({ where: { id: 1 } });
51+
52+
admin.plexToken = 'test-plex-token';
53+
admin.permissions = Permission.AUTO_REQUEST;
54+
await userRepository.save(admin);
55+
56+
const userSettingsRepository = getRepository(UserSettings);
57+
await userSettingsRepository.save(
58+
new UserSettings({
59+
user: admin,
60+
watchlistSyncMovies: true,
61+
watchlistSyncTv: true,
62+
})
63+
);
64+
65+
return admin;
66+
}
67+
68+
async function seedMedia(
69+
tmdbId: number,
70+
mediaType: MediaType,
71+
status: MediaStatus
72+
): Promise<void> {
73+
const mediaRepository = getRepository(Media);
74+
await mediaRepository.save(
75+
new Media({
76+
tmdbId,
77+
mediaType,
78+
status,
79+
status4k: MediaStatus.UNKNOWN,
80+
})
81+
);
82+
}
83+
84+
function movieItem(tmdbId: number, title: string): PlexWatchlistItem {
85+
return { ratingKey: `rk-${tmdbId}`, tmdbId, title, type: 'movie' };
86+
}
87+
88+
function showItem(tmdbId: number, title: string): PlexWatchlistItem {
89+
return {
90+
ratingKey: `rk-${tmdbId}`,
91+
tmdbId,
92+
tvdbId: tmdbId * 1000,
93+
title,
94+
type: 'show',
95+
};
96+
}
97+
98+
describe('WatchlistSync re-request gating', () => {
99+
beforeEach(() => {
100+
requestCalls = [];
101+
watchlistItems = [];
102+
});
103+
104+
it('re-requests DELETED watchlist items and skips non-requestable ones', async () => {
105+
await configureSyncUser();
106+
107+
await seedMedia(100, MediaType.MOVIE, MediaStatus.DELETED);
108+
await seedMedia(101, MediaType.MOVIE, MediaStatus.UNKNOWN);
109+
await seedMedia(102, MediaType.MOVIE, MediaStatus.AVAILABLE);
110+
await seedMedia(103, MediaType.MOVIE, MediaStatus.BLOCKLISTED);
111+
112+
await seedMedia(200, MediaType.TV, MediaStatus.DELETED);
113+
await seedMedia(201, MediaType.TV, MediaStatus.AVAILABLE);
114+
115+
watchlistItems = [
116+
movieItem(100, 'Deleted Movie'),
117+
movieItem(101, 'Unknown Movie'),
118+
movieItem(102, 'Available Movie'),
119+
movieItem(103, 'Blocklisted Movie'),
120+
showItem(200, 'Deleted Show'),
121+
showItem(201, 'Available Show'),
122+
];
123+
124+
await watchlistSync.syncWatchlist();
125+
126+
const requestedArray = requestCalls.map(
127+
(c) => `${c.mediaType}:${c.mediaId}`
128+
);
129+
const requested = new Set(requestedArray);
130+
131+
assert.strictEqual(
132+
requestedArray.length,
133+
requested.size,
134+
'Each item should be requested exactly once'
135+
);
136+
137+
assert.ok(
138+
requested.has(`${MediaType.MOVIE}:100`),
139+
'DELETED movie on the watchlist should be re-requested'
140+
);
141+
142+
assert.ok(
143+
requested.has(`${MediaType.MOVIE}:101`),
144+
'UNKNOWN movie should be requested'
145+
);
146+
assert.ok(
147+
!requested.has(`${MediaType.MOVIE}:102`),
148+
'AVAILABLE movie should NOT be requested'
149+
);
150+
assert.ok(
151+
!requested.has(`${MediaType.MOVIE}:103`),
152+
'BLOCKLISTED movie should NOT be requested'
153+
);
154+
155+
assert.ok(
156+
requested.has(`${MediaType.TV}:200`),
157+
'DELETED show should be re-requested'
158+
);
159+
assert.ok(
160+
!requested.has(`${MediaType.TV}:201`),
161+
'AVAILABLE show should NOT be requested'
162+
);
163+
});
164+
165+
it('re-requests DELETED watchlist items even when a stale auto-request exists', async () => {
166+
const user = await configureSyncUser();
167+
168+
await seedMedia(100, MediaType.MOVIE, MediaStatus.DELETED);
169+
170+
const media = await getRepository(Media).findOneOrFail({
171+
where: { tmdbId: 100, mediaType: MediaType.MOVIE },
172+
});
173+
174+
await getRepository(MediaRequest).save(
175+
new MediaRequest({
176+
type: MediaType.MOVIE,
177+
status: MediaRequestStatus.COMPLETED,
178+
media,
179+
requestedBy: user,
180+
is4k: false,
181+
isAutoRequest: true,
182+
})
183+
);
184+
185+
watchlistItems = [movieItem(100, 'Deleted Movie')];
186+
187+
await watchlistSync.syncWatchlist();
188+
189+
const calls = requestCalls.filter(
190+
(c) => c.mediaType === MediaType.MOVIE && c.mediaId === 100
191+
);
192+
193+
assert.strictEqual(
194+
calls.length,
195+
1,
196+
'DELETED movie should be re-requested even when a stale auto-request exists'
197+
);
198+
});
199+
});

server/lib/watchlistsync.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ class WatchlistSync {
9191

9292
const autoRequestedTmdbIds = new Set(
9393
existingAutoRequests
94-
.filter((r) => r.media != null)
94+
.filter(
95+
(r) => r.media != null && r.media.status !== MediaStatus.DELETED
96+
)
9597
.map((r) => `${r.media.mediaType}:${r.media.tmdbId}`)
9698
);
9799

@@ -106,7 +108,8 @@ class WatchlistSync {
106108
m.mediaType === itemMediaType &&
107109
(m.status === MediaStatus.BLOCKLISTED ||
108110
(itemMediaType === MediaType.MOVIE &&
109-
m.status !== MediaStatus.UNKNOWN) ||
111+
m.status !== MediaStatus.UNKNOWN &&
112+
m.status !== MediaStatus.DELETED) ||
110113
(itemMediaType === MediaType.TV &&
111114
m.status === MediaStatus.AVAILABLE))
112115
)

0 commit comments

Comments
 (0)