From 9cf9b2e7c06c13d8379238445c2a3de030164607 Mon Sep 17 00:00:00 2001 From: h2nkm5vydg-oss <308933960+h2nkm5vydg-oss@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:30:09 +1000 Subject: [PATCH 1/2] feat: prevent requests before digital release --- docs/using-seerr/settings/general.md | 9 + seerr-api.yml | 36 ++ server/constants/error.ts | 1 + server/entity/MediaRequest.ts | 49 ++- server/interfaces/api/settingsInterfaces.ts | 1 + server/lib/settings/index.ts | 5 + server/routes/request.test.ts | 414 ++++++++++++++++-- server/routes/request.ts | 35 ++ server/utils/releaseEligibility.test.ts | 160 +++++++ server/utils/releaseEligibility.ts | 144 ++++++ .../Settings/SettingsMain/index.tsx | 37 ++ src/context/SettingsContext.tsx | 1 + src/i18n/locale/en.json | 2 + src/pages/_app.tsx | 1 + 14 files changed, 861 insertions(+), 34 deletions(-) create mode 100644 server/utils/releaseEligibility.test.ts create mode 100644 server/utils/releaseEligibility.ts diff --git a/docs/using-seerr/settings/general.md b/docs/using-seerr/settings/general.md index 24bf19daa9..b2b88f0664 100644 --- a/docs/using-seerr/settings/general.md +++ b/docs/using-seerr/settings/general.md @@ -79,6 +79,15 @@ Requested media will still appear in search results, however, so it is possible This setting is **disabled** by default. +## Release Date Restrictions + +Enable **Release Date Restrictions** to prevent ordinary users from requesting media before it is released: + +- A movie becomes requestable once TMDB lists a Digital release (release type 4) dated today or earlier in any region. Theatrical and physical releases do not make a movie eligible. +- A TV season becomes requestable on its TMDB air date. + +Users with the **Manage Requests** permission can bypass the restriction. Media with no valid Digital release date or season air date remains requestable. Release date restrictions are disabled by default and do not change existing requests. + ## Allow Partial Series Requests When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons. diff --git a/seerr-api.yml b/seerr-api.yml index ae1fdc254f..595a741c40 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -235,6 +235,10 @@ components: hideRequested: type: boolean example: false + releaseDateRestrictionEnabled: + type: boolean + default: false + description: Prevent non-managers from requesting movies before a Digital release or TV seasons before their air date. partialRequestsEnabled: type: boolean example: false @@ -711,6 +715,7 @@ components: required: - initialized - plexClientIdentifier + - releaseDateRestrictionEnabled properties: initialized: type: boolean @@ -720,6 +725,9 @@ components: format: uuid description: Instance Plex OAuth client identifier example: 6919275e-142a-48d8-be6b-93594cbd4626 + releaseDateRestrictionEnabled: + type: boolean + default: false MovieResult: type: object required: @@ -6625,6 +6633,20 @@ paths: application/json: schema: $ref: '#/components/schemas/MediaRequest' + '403': + description: The request is forbidden, including when release date restrictions block it. + content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + description: Release restriction failures use MEDIA_NOT_RELEASED. + example: + message: MEDIA_NOT_RELEASED /request/count: get: summary: Gets request counts @@ -6730,6 +6752,20 @@ paths: application/json: schema: $ref: '#/components/schemas/MediaRequest' + '403': + description: The update is forbidden, including when newly added TV seasons are blocked by release date restrictions. + content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + description: Release restriction failures use MEDIA_NOT_RELEASED. + example: + message: MEDIA_NOT_RELEASED '409': description: Only pending requests can be modified delete: diff --git a/server/constants/error.ts b/server/constants/error.ts index 54640b206b..d553911201 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -6,6 +6,7 @@ export enum ApiErrorCode { NotAdmin = 'NOT_ADMIN', NoAdminUser = 'NO_ADMIN_USER', ConnectionError = 'CONNECTION_ERROR', + MediaNotReleased = 'MEDIA_NOT_RELEASED', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', Unauthorized = 'UNAUTHORIZED', diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index e359387e23..4e79f620af 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -12,6 +12,12 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper'; +import { + evaluateReleaseRestriction, + getMovieReleaseEligibility, + getTvSeasonReleaseEligibility, + type ReleaseEligibility, +} from '@server/utils/releaseEligibility'; import requestLock, { mediaKey, mediaLock, @@ -40,6 +46,24 @@ export class QuotaRestrictedError extends Error {} export class DuplicateMediaRequestError extends Error {} export class NoSeasonsAvailableError extends Error {} export class BlocklistedMediaError extends Error {} +export class ReleaseDateRestrictedError extends Error {} + +export const assertReleaseRestriction = ( + eligibilities: ReleaseEligibility[], + actingUser: User +): void => { + const settings = getSettings(); + const decision = evaluateReleaseRestriction(eligibilities, { + enabled: settings.main.releaseDateRestrictionEnabled, + canBypass: actingUser.hasPermission(Permission.MANAGE_REQUESTS), + }); + + if (!decision.allowed) { + throw new ReleaseDateRestrictedError( + 'This media has not been released yet.' + ); + } +}; type MediaRequestOptions = { isAutoRequest?: boolean; @@ -171,6 +195,16 @@ export class MediaRequest { ? await tmdb.getMovie({ movieId: requestBody.mediaId }) : await tmdb.getTvShow({ tvId: requestBody.mediaId }); + if ( + requestBody.mediaType === MediaType.MOVIE && + 'release_dates' in tmdbMedia + ) { + assertReleaseRestriction( + [getMovieReleaseEligibility(tmdbMedia.release_dates)], + user + ); + } + let media = await mediaRepository.findOne({ where: { tmdbId: requestBody.mediaId, @@ -413,7 +447,20 @@ export class MediaRequest { if (finalSeasons.length === 0) { throw new NoSeasonsAvailableError('No seasons available to request'); - } else if ( + } + + assertReleaseRestriction( + finalSeasons.map((seasonNumber) => + getTvSeasonReleaseEligibility( + tmdbMediaShow.seasons.find( + (season) => season.season_number === seasonNumber + )?.air_date + ) + ), + user + ); + + if ( !ignoreQuota && quotas.tv.limit && finalSeasons.length > (quotas.tv.remaining ?? 0) diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 678a9d07aa..5a60405d1a 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -32,6 +32,7 @@ export interface PublicSettingsResponse { hideAvailable: boolean; hideBlocklisted: boolean; hideRequested: boolean; + releaseDateRestrictionEnabled: boolean; localLogin: boolean; mediaServerLogin: boolean; movie4kEnabled: boolean; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index a52f8cf4c6..d2d9fe7648 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -142,6 +142,7 @@ export interface MainSettings { hideAvailable: boolean; hideBlocklisted: boolean; hideRequested: boolean; + releaseDateRestrictionEnabled: boolean; localLogin: boolean; mediaServerLogin: boolean; newPlexLogin: boolean; @@ -196,6 +197,7 @@ interface FullPublicSettings extends PublicSettings { hideAvailable: boolean; hideBlocklisted: boolean; hideRequested: boolean; + releaseDateRestrictionEnabled: boolean; localLogin: boolean; mediaServerLogin: boolean; movie4kEnabled: boolean; @@ -420,6 +422,7 @@ class Settings { hideAvailable: false, hideBlocklisted: false, hideRequested: false, + releaseDateRestrictionEnabled: false, localLogin: true, mediaServerLogin: true, newPlexLogin: true, @@ -718,6 +721,8 @@ class Settings { hideAvailable: this.data.main.hideAvailable, hideBlocklisted: this.data.main.hideBlocklisted, hideRequested: this.data.main.hideRequested, + releaseDateRestrictionEnabled: + this.data.main.releaseDateRestrictionEnabled, localLogin: this.data.main.localLogin, mediaServerLogin: this.data.main.mediaServerLogin, jellyfinExternalHost: this.data.jellyfin.externalHostname, diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index 6e0f27dc24..b8d5a29367 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -4,8 +4,10 @@ import { before, beforeEach, describe, it, mock } from 'node:test'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbMovieDetails, + TmdbMovieReleaseResult, TmdbTvDetails, } from '@server/api/themoviedb/interfaces'; +import { ApiErrorCode } from '@server/constants/error'; import { MediaRequestStatus, MediaStatus, @@ -41,54 +43,71 @@ const sendNotificationMock = mock.method( async () => undefined ).mock; -const getMovieImpl: (args: { - movieId: number; - language?: string; -}) => Promise = async ({ movieId }) => fakeTmdbMovie(movieId); +const digitalRelease = (releaseDate?: string): TmdbMovieReleaseResult => ({ + results: releaseDate + ? [ + { + iso_3166_1: 'US', + rating: '', + release_dates: [ + { + certification: '', + release_date: releaseDate, + type: 4, + }, + ], + }, + ] + : [], +}); + +const movieDetails = ( + id: number, + releaseDates: TmdbMovieReleaseResult +): TmdbMovieDetails => + ({ + id, + external_ids: {}, + genres: [], + keywords: { keywords: [] }, + original_language: 'en', + release_dates: releaseDates, + }) as unknown as TmdbMovieDetails; + +const tvDetails = ( + id: number, + seasons: { season_number: number; air_date?: string | null }[] +): TmdbTvDetails => + ({ + id, + external_ids: { tvdb_id: id * 10 }, + genres: [], + keywords: { results: [] }, + original_language: 'en', + seasons: seasons.map((season) => ({ ...season, episode_count: 10 })), + }) as unknown as TmdbTvDetails; + +let getMovieImpl = async (movieId: number): Promise => + movieDetails(movieId, digitalRelease('2000-01-01T00:00:00.000Z')); +let getTvShowImpl = async (tvId: number): Promise => + tvDetails(tvId, [{ season_number: 1, air_date: '2000-01-01' }]); Object.defineProperty(TheMovieDb.prototype, 'getMovie', { get() { - return async (args: { movieId: number; language?: string }) => - getMovieImpl(args); + return async ({ movieId }: { movieId: number }) => getMovieImpl(movieId); }, set() {}, configurable: true, }); -const getTvShowImpl: (args: { - tvId: number; - language?: string; -}) => Promise = async ({ tvId }) => fakeTmdbShow(tvId); - Object.defineProperty(TheMovieDb.prototype, 'getTvShow', { get() { - return async (args: { tvId: number; language?: string }) => - getTvShowImpl(args); + return async ({ tvId }: { tvId: number }) => getTvShowImpl(tvId); }, set() {}, configurable: true, }); -function fakeTmdbMovie(tmdbId: number): TmdbMovieDetails { - return { - id: tmdbId, - genres: [], - original_language: 'en', - keywords: { keywords: [] }, - external_ids: {}, - } as unknown as TmdbMovieDetails; -} - -function fakeTmdbShow(tmdbId: number): TmdbTvDetails { - return { - id: tmdbId, - genres: [], - original_language: 'en', - keywords: { results: [] }, - external_ids: {}, - } as unknown as TmdbTvDetails; -} - function configureRadarr(overrides: Partial[]): void { const settings = getSettings(); settings.radarr = overrides.map((o, i) => ({ @@ -174,6 +193,10 @@ before(async () => { beforeEach(() => { sendNotificationMock.resetCalls(); + getMovieImpl = async (movieId) => + movieDetails(movieId, digitalRelease('2000-01-01T00:00:00.000Z')); + getTvShowImpl = async (tvId) => + tvDetails(tvId, [{ season_number: 1, air_date: '2000-01-01' }]); }); setupTestDb(); @@ -228,6 +251,331 @@ async function seedRequest(status = MediaRequestStatus.PENDING) { }); } +async function withReleaseRestriction( + enabled: boolean, + callback: () => Promise +): Promise { + const settings = getSettings(); + const previousEnabled = settings.main.releaseDateRestrictionEnabled; + + settings.main.releaseDateRestrictionEnabled = enabled; + + try { + return await callback(); + } finally { + settings.main.releaseDateRestrictionEnabled = previousEnabled; + } +} + +describe('POST /request release date restrictions', () => { + it('rejects a future movie for an ordinary user without database mutation', async () => { + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + const mediaCount = await mediaRepo.count(); + const requestCount = await requestRepo.count(); + getMovieImpl = async (movieId) => + movieDetails(movieId, digitalRelease('2999-01-01T00:00:00.000Z')); + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.MOVIE, + mediaId: 81001, + is4k: false, + }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.message, ApiErrorCode.MediaNotReleased); + }); + + assert.strictEqual(await mediaRepo.count(), mediaCount); + assert.strictEqual(await requestRepo.count(), requestCount); + }); + + it('allows requests when the release date is unknown', async () => { + getMovieImpl = async (movieId) => movieDetails(movieId, digitalRelease()); + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.MOVIE, + mediaId: 81003, + is4k: false, + }); + + assert.strictEqual(res.status, 201); + }); + }); + + it('does not gate requests when the restriction is disabled', async () => { + getMovieImpl = async (movieId) => + movieDetails(movieId, digitalRelease('2999-01-01T00:00:00.000Z')); + + await withReleaseRestriction(false, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.MOVIE, + mediaId: 81004, + is4k: false, + }); + + assert.strictEqual(res.status, 201); + }); + }); + + it('accepts a Digital release from any TMDB region', async () => { + getMovieImpl = async (movieId) => + movieDetails(movieId, { + results: [ + { + iso_3166_1: 'US', + rating: '', + release_dates: [ + { + certification: '', + release_date: '2999-01-01T00:00:00.000Z', + type: 4, + }, + ], + }, + { + iso_3166_1: 'DE', + rating: '', + release_dates: [ + { + certification: '', + release_date: '2000-01-01T00:00:00.000Z', + type: 4, + }, + ], + }, + ], + }); + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.MOVIE, + mediaId: 81005, + is4k: false, + }); + + assert.strictEqual(res.status, 201); + }); + }); + + it('allows a manager to bypass the restriction when requesting on behalf of a user', async () => { + const friend = await getRepository(User).findOneOrFail({ + where: { email: 'demo@seerr.dev' }, + }); + getMovieImpl = async (movieId) => + movieDetails(movieId, digitalRelease('2999-01-01T00:00:00.000Z')); + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.MOVIE, + mediaId: 81006, + userId: friend.id, + is4k: false, + }); + + assert.strictEqual(res.status, 201); + + const saved = await getRepository(MediaRequest).findOneOrFail({ + where: { id: res.body.id }, + relations: { requestedBy: true }, + }); + assert.strictEqual(saved.requestedBy.id, friend.id); + }); + }); + + it('also rejects future 4K movie requests', async () => { + const userRepo = getRepository(User); + const friend = await userRepo.findOneOrFail({ + where: { email: 'demo@seerr.dev' }, + }); + friend.permissions |= Permission.REQUEST_4K; + await userRepo.save(friend); + getMovieImpl = async (movieId) => + movieDetails(movieId, digitalRelease('2999-01-01T00:00:00.000Z')); + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.MOVIE, + mediaId: 81007, + is4k: true, + }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.message, ApiErrorCode.MediaNotReleased); + }); + }); + + it('rejects an explicit mixed TV season request in full', async () => { + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + const mediaCount = await mediaRepo.count(); + const requestCount = await requestRepo.count(); + getTvShowImpl = async (tvId) => + tvDetails(tvId, [ + { season_number: 1, air_date: '2000-01-01' }, + { season_number: 2, air_date: '2999-01-01' }, + ]); + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.TV, + mediaId: 82001, + seasons: [1, 2], + is4k: false, + }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.message, ApiErrorCode.MediaNotReleased); + }); + + assert.strictEqual(await mediaRepo.count(), mediaCount); + assert.strictEqual(await requestRepo.count(), requestCount); + }); + + it('rejects a seasons: all TV request when any new season is blocked', async () => { + getTvShowImpl = async (tvId) => + tvDetails(tvId, [ + { season_number: 1, air_date: '2000-01-01' }, + { season_number: 2, air_date: '2999-01-01' }, + ]); + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.TV, + mediaId: 82002, + seasons: 'all', + is4k: false, + }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.message, ApiErrorCode.MediaNotReleased); + }); + }); +}); + +describe('PUT /request/:requestId TV release date restrictions', () => { + async function seedTvRequest( + availableSeasons: number[] = [] + ): Promise { + const requestedBy = await getRepository(User).findOneOrFail({ + where: { email: 'demo@seerr.dev' }, + }); + const media = await getRepository(Media).save( + new Media({ + mediaType: MediaType.TV, + tmdbId: 83001, + tvdbId: 830010, + status: MediaStatus.PENDING, + status4k: MediaStatus.UNKNOWN, + seasons: availableSeasons.map( + (seasonNumber) => + new Season({ + seasonNumber, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }) + ), + }) + ); + + return getRepository(MediaRequest).save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media, + requestedBy, + is4k: false, + seasons: [ + new SeasonRequest({ + seasonNumber: 1, + status: MediaRequestStatus.PENDING, + }), + ], + }) + ); + } + + it('rejects a newly added future season and leaves the request unchanged', async () => { + const mediaRequest = await seedTvRequest(); + getTvShowImpl = async (tvId) => + tvDetails(tvId, [ + { season_number: 1, air_date: '2000-01-01' }, + { season_number: 2, air_date: '2999-01-01' }, + ]); + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2], + }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.message, ApiErrorCode.MediaNotReleased); + }); + + const saved = await getRepository(MediaRequest).findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((season) => season.seasonNumber), + [1] + ); + }); + + it('does not revalidate seasons already on the request', async () => { + const mediaRequest = await seedTvRequest(); + getTvShowImpl = async () => { + throw new Error('TMDB should not be called'); + }; + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1], + }); + + assert.strictEqual(res.status, 200); + }); + }); + + it('does not validate or add a season that is already available', async () => { + const mediaRequest = await seedTvRequest([2]); + getTvShowImpl = async () => { + throw new Error('TMDB should not be called'); + }; + + await withReleaseRestriction(true, async () => { + const agent = await loginAs('demo@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2], + }); + + assert.strictEqual(res.status, 200); + }); + + const saved = await getRepository(MediaRequest).findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((season) => season.seasonNumber), + [1] + ); + }); +}); + describe('DELETE /request/:requestId', () => { it('allows the owner to delete their own pending request', async () => { const mediaRequest = await seedRequest(); diff --git a/server/routes/request.ts b/server/routes/request.ts index b17aacd890..3a1e556c0f 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -1,5 +1,7 @@ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; +import TheMovieDb from '@server/api/themoviedb'; +import { ApiErrorCode } from '@server/constants/error'; import { MediaRequestStatus, MediaStatus, @@ -13,7 +15,9 @@ import { MediaRequest, NoSeasonsAvailableError, QuotaRestrictedError, + ReleaseDateRestrictedError, RequestPermissionError, + assertReleaseRestriction, } from '@server/entity/MediaRequest'; import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; @@ -25,6 +29,7 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import { getTvSeasonReleaseEligibility } from '@server/utils/releaseEligibility'; import requestLock, { mediaKey, mediaLock, @@ -328,6 +333,13 @@ requestRoutes.post( return; } + if (error instanceof ReleaseDateRestrictedError) { + return next({ + status: 403, + message: ApiErrorCode.MediaNotReleased, + }); + } + switch (error.constructor) { case RequestPermissionError: case QuotaRestrictedError: @@ -638,6 +650,22 @@ requestRoutes.put<{ requestId: string }>( }); } + if (newSeasons.length > 0 && req.user) { + const tmdb = new TheMovieDb(); + const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); + + assertReleaseRestriction( + newSeasons.map((seasonNumber) => + getTvSeasonReleaseEligibility( + tv.seasons.find( + (season) => season.season_number === seasonNumber + )?.air_date + ) + ), + req.user + ); + } + if (!request.ignoreQuota) { const quotas = await requestUser.getQuota(); @@ -702,6 +730,13 @@ requestRoutes.put<{ requestId: string }>( }); }); } catch (e) { + if (e instanceof ReleaseDateRestrictedError) { + return next({ + status: 403, + message: ApiErrorCode.MediaNotReleased, + }); + } + next({ status: 500, message: e.message }); } } diff --git a/server/utils/releaseEligibility.test.ts b/server/utils/releaseEligibility.test.ts new file mode 100644 index 0000000000..19a858d2a0 --- /dev/null +++ b/server/utils/releaseEligibility.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + evaluateReleaseRestriction, + getMovieReleaseEligibility, + getTvSeasonReleaseEligibility, + type ReleaseEligibility, +} from './releaseEligibility'; + +const now = new Date('2026-08-07T12:00:00.000Z'); + +const movieReleases = ( + regions: { release_date: string; type: number }[][] +) => ({ + results: regions.map((release_dates) => ({ release_dates })), +}); + +describe('getMovieReleaseEligibility', () => { + it('treats a Digital release in any region at or before now as released', () => { + const eligibility = getMovieReleaseEligibility( + movieReleases([ + [{ release_date: '2026-09-01T00:00:00.000Z', type: 4 }], + [{ release_date: '2026-08-07T12:00:00.000Z', type: 4 }], + ]), + now + ); + + assert.deepStrictEqual(eligibility, { status: 'released' }); + }); + + it('returns the earliest future Digital release across regions', () => { + const eligibility = getMovieReleaseEligibility( + movieReleases([ + [{ release_date: '2026-10-01T00:00:00.000Z', type: 4 }], + [{ release_date: '2026-09-01T00:00:00.000Z', type: 4 }], + ]), + now + ); + + assert.deepStrictEqual(eligibility, { + status: 'future', + releaseDate: '2026-09-01T00:00:00.000Z', + }); + }); + + it('ignores theatrical and physical releases', () => { + const eligibility = getMovieReleaseEligibility( + movieReleases([ + [ + { release_date: '2026-01-01T00:00:00.000Z', type: 3 }, + { release_date: '2026-01-02T00:00:00.000Z', type: 5 }, + ], + ]), + now + ); + + assert.deepStrictEqual(eligibility, { status: 'unknown' }); + }); + + it('treats missing and invalid Digital dates as unknown', () => { + assert.deepStrictEqual(getMovieReleaseEligibility(undefined, now), { + status: 'unknown', + }); + assert.deepStrictEqual( + getMovieReleaseEligibility( + movieReleases([ + [ + { release_date: 'not-a-date', type: 4 }, + { release_date: '2026-02-31T00:00:00.000Z', type: 4 }, + ], + ]), + now + ), + { status: 'unknown' } + ); + }); +}); + +describe('getTvSeasonReleaseEligibility', () => { + it('treats past and current air dates as released', () => { + assert.deepStrictEqual(getTvSeasonReleaseEligibility('2026-08-06', now), { + status: 'released', + }); + assert.deepStrictEqual(getTvSeasonReleaseEligibility('2026-08-07', now), { + status: 'released', + }); + }); + + it('returns future and unknown season eligibility', () => { + assert.deepStrictEqual(getTvSeasonReleaseEligibility('2026-08-08', now), { + status: 'future', + releaseDate: '2026-08-08', + }); + assert.deepStrictEqual(getTvSeasonReleaseEligibility(undefined, now), { + status: 'unknown', + }); + assert.deepStrictEqual(getTvSeasonReleaseEligibility('2026-02-31', now), { + status: 'unknown', + }); + }); +}); + +describe('evaluateReleaseRestriction', () => { + const future: ReleaseEligibility = { + status: 'future', + releaseDate: '2026-09-01', + }; + const unknown: ReleaseEligibility = { status: 'unknown' }; + + it('always allows requests when the restriction is disabled', () => { + assert.deepStrictEqual( + evaluateReleaseRestriction([future, unknown], { + enabled: false, + }), + { allowed: true, bypassed: false } + ); + }); + + it('blocks future dates and allows unknown dates', () => { + assert.strictEqual( + evaluateReleaseRestriction([future], { + enabled: true, + }).allowed, + false + ); + assert.strictEqual( + evaluateReleaseRestriction([unknown], { + enabled: true, + }).allowed, + true + ); + }); + + it('allows managers to bypass the restriction and reports the bypass', () => { + const decision = evaluateReleaseRestriction([future], { + enabled: true, + canBypass: true, + }); + + assert.strictEqual(decision.allowed, true); + assert.strictEqual(decision.bypassed, true); + assert.deepStrictEqual(decision.blockedEligibility, future); + }); + + it('reports the earliest future date when several items are blocked', () => { + const decision = evaluateReleaseRestriction( + [ + { status: 'future', releaseDate: '2026-10-01' }, + { status: 'future', releaseDate: '2026-09-01' }, + ], + { enabled: true } + ); + + assert.deepStrictEqual(decision.blockedEligibility, { + status: 'future', + releaseDate: '2026-09-01', + }); + }); +}); diff --git a/server/utils/releaseEligibility.ts b/server/utils/releaseEligibility.ts new file mode 100644 index 0000000000..9061343d7f --- /dev/null +++ b/server/utils/releaseEligibility.ts @@ -0,0 +1,144 @@ +export type ReleaseEligibilityStatus = 'released' | 'future' | 'unknown'; + +export interface ReleaseEligibility { + status: ReleaseEligibilityStatus; + releaseDate?: string; +} + +interface MovieReleaseDate { + release_date: string; + type: number; +} + +interface MovieReleaseRegion { + release_dates: MovieReleaseDate[]; +} + +interface MovieReleaseResult { + results: MovieReleaseRegion[]; +} + +export interface ReleaseRestrictionOptions { + enabled: boolean; + canBypass?: boolean; +} + +export interface ReleaseRestrictionDecision { + allowed: boolean; + bypassed: boolean; + blockedEligibility?: ReleaseEligibility; +} + +const validTimestamp = (value?: string | null): number | undefined => { + if (!value) { + return undefined; + } + + const isoDateMatch = value.match( + /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))?$/ + ); + + if (!isoDateMatch) { + return undefined; + } + + const [, year, month, day] = isoDateMatch; + const calendarDate = new Date( + Date.UTC(Number(year), Number(month) - 1, Number(day)) + ); + + if ( + calendarDate.getUTCFullYear() !== Number(year) || + calendarDate.getUTCMonth() !== Number(month) - 1 || + calendarDate.getUTCDate() !== Number(day) + ) { + return undefined; + } + + const timestamp = Date.parse(value); + + return Number.isNaN(timestamp) ? undefined : timestamp; +}; + +export const getMovieReleaseEligibility = ( + releases?: MovieReleaseResult, + now = new Date() +): ReleaseEligibility => { + const digitalReleases = (releases?.results ?? []) + .flatMap((region) => region.release_dates ?? []) + .filter((release) => release.type === 4) + .map((release) => ({ + releaseDate: release.release_date, + timestamp: validTimestamp(release.release_date), + })) + .filter( + (release): release is { releaseDate: string; timestamp: number } => + release.timestamp !== undefined + ); + + if (digitalReleases.some((release) => release.timestamp <= now.getTime())) { + return { status: 'released' }; + } + + const nextRelease = digitalReleases.sort( + (first, second) => first.timestamp - second.timestamp + )[0]; + + return nextRelease + ? { status: 'future', releaseDate: nextRelease.releaseDate } + : { status: 'unknown' }; +}; + +export const getTvSeasonReleaseEligibility = ( + airDate?: string | null, + now = new Date() +): ReleaseEligibility => { + const timestamp = validTimestamp(airDate); + + if (timestamp === undefined) { + return { status: 'unknown' }; + } + + return timestamp <= now.getTime() + ? { status: 'released' } + : { status: 'future', releaseDate: airDate ?? undefined }; +}; + +export const evaluateReleaseRestriction = ( + eligibilities: ReleaseEligibility[], + options: ReleaseRestrictionOptions +): ReleaseRestrictionDecision => { + if (!options.enabled) { + return { allowed: true, bypassed: false }; + } + + const blockedEligibilities = eligibilities.filter( + (eligibility) => eligibility.status === 'future' + ); + + const blockedEligibility = + blockedEligibilities + .filter( + ( + eligibility + ): eligibility is ReleaseEligibility & { + status: 'future'; + releaseDate: string; + } => eligibility.status === 'future' && !!eligibility.releaseDate + ) + .sort( + (first, second) => + (validTimestamp(first.releaseDate) ?? Number.MAX_SAFE_INTEGER) - + (validTimestamp(second.releaseDate) ?? Number.MAX_SAFE_INTEGER) + )[0] ?? blockedEligibilities[0]; + + if (!blockedEligibility) { + return { allowed: true, bypassed: false }; + } + + if (options.canBypass) { + return { allowed: true, bypassed: true, blockedEligibility }; + } + + return { allowed: false, bypassed: false, blockedEligibility }; +}; diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 42b38e84d0..8243834bfd 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -65,6 +65,9 @@ const messages = defineMessages('components.Settings.SettingsMain', { hideRequested: 'Hide Requested Media', hideRequestedTip: 'Hide media that has been requested from the discover pages but not search results', + releaseDateRestrictionEnabled: 'Enable Release Date Restrictions', + releaseDateRestrictionEnabledTip: + 'Prevent users from requesting movies before a worldwide Digital release or TV seasons before their air date. Users with Manage Requests can bypass this restriction.', cacheImages: 'Enable Image Caching', cacheImagesTip: 'Cache externally sourced images (requires a significant amount of disk space)', @@ -170,6 +173,8 @@ const SettingsMain = () => { hideAvailable: data?.hideAvailable, hideBlocklisted: data?.hideBlocklisted, hideRequested: data?.hideRequested, + releaseDateRestrictionEnabled: + data?.releaseDateRestrictionEnabled ?? false, locale: data?.locale ?? 'en', discoverRegion: data?.discoverRegion, originalLanguage: data?.originalLanguage, @@ -194,6 +199,8 @@ const SettingsMain = () => { hideAvailable: values.hideAvailable, hideBlocklisted: values.hideBlocklisted, hideRequested: values.hideRequested, + releaseDateRestrictionEnabled: + values.releaseDateRestrictionEnabled, locale: values.locale, discoverRegion: values.discoverRegion, streamingRegion: values.streamingRegion, @@ -583,6 +590,36 @@ const SettingsMain = () => { /> +
+ +
+ { + setFieldValue( + 'releaseDateRestrictionEnabled', + !values.releaseDateRestrictionEnabled + ); + }} + /> +
+