diff --git a/docs/using-seerr/settings/general.md b/docs/using-seerr/settings/general.md index 65b337495f..d599b6bede 100644 --- a/docs/using-seerr/settings/general.md +++ b/docs/using-seerr/settings/general.md @@ -71,6 +71,15 @@ When enabled, media that has been blocklisted will not appear on the "Discover" 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 f45c91ce23..89da16aabc 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -232,6 +232,10 @@ components: hideAvailable: 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 @@ -708,6 +712,7 @@ components: required: - initialized - plexClientIdentifier + - releaseDateRestrictionEnabled properties: initialized: type: boolean @@ -717,6 +722,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: @@ -6622,6 +6630,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 @@ -6727,6 +6749,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 0c3a4f0fb6..34994b9256 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -14,6 +14,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 from '@server/utils/requestLock'; import { truncate } from 'lodash'; import { @@ -38,6 +44,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; @@ -155,6 +179,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, @@ -484,7 +518,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 f2793c9aad..5367fcbb24 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -31,6 +31,7 @@ export interface PublicSettingsResponse { applicationUrl: string; hideAvailable: boolean; hideBlocklisted: boolean; + releaseDateRestrictionEnabled: boolean; localLogin: boolean; mediaServerLogin: boolean; movie4kEnabled: boolean; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 9e2538c189..057cdc69ef 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -141,6 +141,7 @@ export interface MainSettings { }; hideAvailable: boolean; hideBlocklisted: boolean; + releaseDateRestrictionEnabled: boolean; localLogin: boolean; mediaServerLogin: boolean; newPlexLogin: boolean; @@ -194,6 +195,7 @@ interface FullPublicSettings extends PublicSettings { applicationUrl: string; hideAvailable: boolean; hideBlocklisted: boolean; + releaseDateRestrictionEnabled: boolean; localLogin: boolean; mediaServerLogin: boolean; movie4kEnabled: boolean; @@ -417,6 +419,7 @@ class Settings { }, hideAvailable: false, hideBlocklisted: false, + releaseDateRestrictionEnabled: false, localLogin: true, mediaServerLogin: true, newPlexLogin: true, @@ -714,6 +717,8 @@ class Settings { applicationUrl: this.data.main.applicationUrl, hideAvailable: this.data.main.hideAvailable, hideBlocklisted: this.data.main.hideBlocklisted, + 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 2c88e84205..682815e3ae 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, @@ -18,6 +20,7 @@ import OverrideRule from '@server/entity/OverrideRule'; import Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { checkUser } from '@server/middleware/auth'; @@ -35,54 +38,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, + }) 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) => ({ @@ -168,6 +188,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(); @@ -222,6 +246,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('friend@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('friend@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('friend@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('friend@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: 'friend@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: 'friend@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('friend@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('friend@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('friend@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: 'friend@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('friend@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('friend@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('friend@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 9ee5697395..83651d398e 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 { Router } from 'express'; const requestRoutes = Router(); @@ -318,6 +323,13 @@ requestRoutes.post( return; } + if (error instanceof ReleaseDateRestrictedError) { + return next({ + status: 403, + message: ApiErrorCode.MediaNotReleased, + }); + } + switch (error.constructor) { case RequestPermissionError: case QuotaRestrictedError: @@ -571,10 +583,39 @@ requestRoutes.put<{ requestId: string }>( }); } + const availableSeasons = (media.seasons ?? []) + .filter( + (season) => + season[request.is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN && + season[request.is4k ? 'status4k' : 'status'] !== + MediaStatus.DELETED + ) + .map((season) => season.seasonNumber); const newSeasons = filteredSeasons.filter( - (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) + (seasonNumber) => + !request.seasons + .map((season) => season.seasonNumber) + .includes(seasonNumber) && + !availableSeasons.includes(seasonNumber) ); + 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 + ); + } + request.seasons = request.seasons.filter((rs) => filteredSeasons.includes(rs.seasonNumber) ); @@ -600,6 +641,13 @@ requestRoutes.put<{ requestId: string }>( return res.status(200).json(request); } 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..7dce3b2d90 --- /dev/null +++ b/server/utils/releaseEligibility.test.ts @@ -0,0 +1,169 @@ +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('treats a Digital release later today as released', () => { + const eligibility = getMovieReleaseEligibility( + movieReleases([[{ release_date: '2026-08-07T23: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..5e819ef75a --- /dev/null +++ b/server/utils/releaseEligibility.ts @@ -0,0 +1,153 @@ +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 validCalendarDateTimestamp = ( + 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 parsedTimestamp = Date.parse(value); + + return Number.isNaN(parsedTimestamp) ? undefined : calendarDate.getTime(); +}; + +const getUtcCalendarDateTimestamp = (date: Date): number => + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); + +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: validCalendarDateTimestamp(release.release_date), + })) + .filter( + (release): release is { releaseDate: string; timestamp: number } => + release.timestamp !== undefined + ); + + const today = getUtcCalendarDateTimestamp(now); + + if (digitalReleases.some((release) => release.timestamp <= today)) { + 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 = validCalendarDateTimestamp(airDate); + + if (timestamp === undefined) { + return { status: 'unknown' }; + } + + return timestamp <= getUtcCalendarDateTimestamp(now) + ? { 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) => + (validCalendarDateTimestamp(first.releaseDate) ?? + Number.MAX_SAFE_INTEGER) - + (validCalendarDateTimestamp(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 cd81505fac..8c34218f16 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -62,6 +62,9 @@ const messages = defineMessages('components.Settings.SettingsMain', { hideAvailable: 'Hide Available Media', hideAvailableTip: 'Hide available media from the discover pages but not search results', + releaseDateRestrictionEnabled: 'Enable Release Date Restrictions', + releaseDateRestrictionEnabledTip: + 'Prevent users from requesting movies before a Digital release in any region 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)', @@ -173,6 +176,8 @@ const SettingsMain = () => { applicationUrl: data?.applicationUrl, hideAvailable: data?.hideAvailable, hideBlocklisted: data?.hideBlocklisted, + releaseDateRestrictionEnabled: + data?.releaseDateRestrictionEnabled ?? false, locale: data?.locale ?? 'en', discoverRegion: data?.discoverRegion, originalLanguage: data?.originalLanguage, @@ -196,6 +201,8 @@ const SettingsMain = () => { applicationUrl: values.applicationUrl, hideAvailable: values.hideAvailable, hideBlocklisted: values.hideBlocklisted, + releaseDateRestrictionEnabled: + values.releaseDateRestrictionEnabled, locale: values.locale, discoverRegion: values.discoverRegion, streamingRegion: values.streamingRegion, @@ -565,6 +572,36 @@ const SettingsMain = () => { /> +
+ +
+ { + setFieldValue( + 'releaseDateRestrictionEnabled', + !values.releaseDateRestrictionEnabled + ); + }} + /> +
+