diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index f485424fcc..5d7dd37029 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -25,6 +25,7 @@ "mediaServerType": 1, "partialRequestsEnabled": true, "enableSpecialEpisodes": false, + "enableEpisodeAvailability": false, "locale": "en" }, "plex": { diff --git a/seerr-api.yml b/seerr-api.yml index 4b33bf7892..708a65fdc4 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -250,6 +250,9 @@ components: enableSpecialEpisodes: type: boolean example: false + enableEpisodeAvailability: + type: boolean + example: false versionCheck: type: boolean example: false @@ -1072,6 +1075,8 @@ components: type: number voteCount: type: number + available: + type: boolean Season: type: object properties: diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index c5b4eec93c..81205edcc5 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -14,7 +14,7 @@ export interface SonarrSeason { percentOfEpisodes: number; }; } -interface EpisodeResult { +export interface EpisodeResult { seriesId: number; episodeFileId: number; seasonNumber: number; diff --git a/server/entity/Episode.ts b/server/entity/Episode.ts new file mode 100644 index 0000000000..3e1a35f067 --- /dev/null +++ b/server/entity/Episode.ts @@ -0,0 +1,50 @@ +import { MediaStatus } from '@server/constants/media'; +import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper'; +import { + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import Season from './Season'; + +@Entity() +class Episode { + @PrimaryGeneratedColumn() + public id: number; + + @Column() + public episodeNumber: number; + + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status: MediaStatus; + + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status4k: MediaStatus; + + @Index() + @ManyToOne(() => Season, (season: Season) => season.episodes, { + onDelete: 'CASCADE', + nullable: true, + }) + public season?: Promise; + + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + public createdAt: Date; + + @UpdateDateColumn({ + type: resolveDbType('datetime'), + default: () => 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + constructor(init?: Partial) { + if (init) { + Object.assign(this, init); + } + } +} + +export default Episode; diff --git a/server/entity/Season.ts b/server/entity/Season.ts index f1e5a11d3e..d45de8a9a0 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -5,9 +5,11 @@ import { Entity, Index, ManyToOne, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import Episode from './Episode'; import Media from './Media'; @Entity() @@ -30,6 +32,11 @@ class Season { @Index() public media: Promise; + @OneToMany(() => Episode, (episode) => episode.season, { + cascade: true, + }) + public episodes?: Episode[]; + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index f2793c9aad..f007d67256 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -41,6 +41,7 @@ export interface PublicSettingsResponse { mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + enableEpisodeAvailability: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; @@ -49,6 +50,10 @@ export interface PublicSettingsResponse { newPlexLogin: boolean; youtubeUrl: string; versionCheck: boolean; + metadataSettings: { + tv: string; + anime: string; + }; plexClientIdentifier: string; } diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 1636744ad1..9a129090b3 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -224,6 +224,25 @@ class AvailabilitySync { let showExists = false; let showExists4k = false; + // Fetch TMDB details once for season enrichment + let tvShow: TmdbTvDetails | undefined; + try { + if (media.tmdbId) { + tvShow = await this.tmdb.getTvShow({ + tvId: Number(media.tmdbId), + }); + } else if (media.tvdbId) { + tvShow = await this.tmdb.getShowByTvdbId({ + tvdbId: Number(media.tvdbId), + }); + } + } catch (e) { + logger.debug( + `Failed to fetch TMDB data for show [TMDB ID ${media.tmdbId}]. Skipping season enrichment.`, + { label: 'AvailabilitySync', errorMessage: e.message } + ); + } + //plex const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } = @@ -360,26 +379,6 @@ class AvailabilitySync { ...sonarrSeasonsMap4k, ]); } - - // We need to fetch from TMDB to get the episode count for each season - let tvShow: TmdbTvDetails | undefined; - try { - if (media.tmdbId) { - tvShow = await this.tmdb.getTvShow({ - tvId: Number(media.tmdbId), - }); - } else if (media.tvdbId) { - tvShow = await this.tmdb.getShowByTvdbId({ - tvdbId: Number(media.tvdbId), - }); - } - } catch (e) { - logger.debug( - `Failed to fetch TMDB data for show [TMDB ID ${media.tmdbId}]. Skipping season enrichment.`, - { label: 'AvailabilitySync', errorMessage: e.message } - ); - } - if (tvShow) { // fill the finalSeasons and finalSeasons4k maps with false for missing seasons media.seasons.forEach((season) => { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 0a896524eb..6f52462ad3 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -154,6 +154,7 @@ export interface MainSettings { mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + enableEpisodeAvailability: boolean; locale: string; youtubeUrl: string; versionCheck: boolean; @@ -207,6 +208,7 @@ interface FullPublicSettings extends PublicSettings { jellyfinServerName?: string; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + enableEpisodeAvailability: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; @@ -216,6 +218,7 @@ interface FullPublicSettings extends PublicSettings { newPlexLogin: boolean; youtubeUrl: string; versionCheck: boolean; + metadataSettings: MetadataSettings; plexClientIdentifier: string; } @@ -429,6 +432,7 @@ class Settings { mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, + enableEpisodeAvailability: false, locale: 'en', youtubeUrl: '', versionCheck: true, @@ -728,6 +732,7 @@ class Settings { mediaServerType: this.main.mediaServerType, partialRequestsEnabled: this.data.main.partialRequestsEnabled, enableSpecialEpisodes: this.data.main.enableSpecialEpisodes, + enableEpisodeAvailability: this.data.main.enableEpisodeAvailability, cacheImages: this.data.main.cacheImages, vapidPublic: this.vapidPublic, enablePushRegistration: this.data.notifications.agents.webpush.enabled, @@ -738,6 +743,7 @@ class Settings { newPlexLogin: this.data.main.newPlexLogin, youtubeUrl: this.data.main.youtubeUrl, versionCheck: this.data.main.versionCheck, + metadataSettings: this.data.metadataSettings, plexClientIdentifier: this.data.clientId, }; } diff --git a/server/migration/postgres/1780843263648-AddEpisodeTable.ts b/server/migration/postgres/1780843263648-AddEpisodeTable.ts new file mode 100644 index 0000000000..f0feb7ceb5 --- /dev/null +++ b/server/migration/postgres/1780843263648-AddEpisodeTable.ts @@ -0,0 +1,27 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEpisodeTable1780843263648 implements MigrationInterface { + name = 'AddEpisodeTable1780843263648'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "episode" ("id" SERIAL NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "status4k" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "seasonId" integer, CONSTRAINT "PK_7258b95d6d2bf7f621845a0e143" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e73d28c1e5e3c85125163f7c9c" ON "episode" ("seasonId") ` + ); + await queryRunner.query( + `ALTER TABLE "episode" ADD CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd" FOREIGN KEY ("seasonId") REFERENCES "season"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "episode" DROP CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_e73d28c1e5e3c85125163f7c9c"` + ); + await queryRunner.query(`DROP TABLE "episode"`); + } +} diff --git a/server/migration/sqlite/1780843239768-AddEpisodeTable.ts b/server/migration/sqlite/1780843239768-AddEpisodeTable.ts new file mode 100644 index 0000000000..6a498a0d02 --- /dev/null +++ b/server/migration/sqlite/1780843239768-AddEpisodeTable.ts @@ -0,0 +1,103 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEpisodeTable1780843239768 implements MigrationInterface { + name = 'AddEpisodeTable1780843239768'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query( + `CREATE TABLE "episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e73d28c1e5e3c85125163f7c9c" ON "episode" ("seasonId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_e73d28c1e5e3c85125163f7c9c"`); + await queryRunner.query( + `CREATE TABLE "temporary_episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer, CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd" FOREIGN KEY ("seasonId") REFERENCES "season" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "episode"` + ); + await queryRunner.query(`DROP TABLE "episode"`); + await queryRunner.query( + `ALTER TABLE "temporary_episode" RENAME TO "episode"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e73d28c1e5e3c85125163f7c9c" ON "episode" ("seasonId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_e73d28c1e5e3c85125163f7c9c"`); + await queryRunner.query( + `ALTER TABLE "episode" RENAME TO "temporary_episode"` + ); + await queryRunner.query( + `CREATE TABLE "episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer)` + ); + await queryRunner.query( + `INSERT INTO "episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "temporary_episode"` + ); + await queryRunner.query(`DROP TABLE "temporary_episode"`); + await queryRunner.query( + `CREATE INDEX "IDX_e73d28c1e5e3c85125163f7c9c" ON "episode" ("seasonId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_e73d28c1e5e3c85125163f7c9c"`); + await queryRunner.query(`DROP TABLE "episode"`); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + } +} diff --git a/server/models/Tv.ts b/server/models/Tv.ts index ef676e4a8d..9c254a1421 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -38,6 +38,7 @@ interface Episode { stillPath?: string; voteAverage: number; voteCount: number; + available?: boolean; } interface Season { @@ -114,7 +115,10 @@ export interface TvDetails { onUserWatchlist?: boolean; } -const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ +const mapEpisodeResult = ( + episode: TmdbTvEpisodeResult, + availableMap?: Record +): Episode => ({ id: episode.id, airDate: episode.air_date, episodeNumber: episode.episode_number, @@ -126,6 +130,9 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ voteAverage: episode.vote_average, voteCount: episode.vote_count, stillPath: episode.still_path, + available: availableMap + ? (availableMap[episode.episode_number] ?? false) + : undefined, }); const mapSeasonResult = (season: TmdbTvSeasonResult): Season => ({ @@ -139,10 +146,13 @@ const mapSeasonResult = (season: TmdbTvSeasonResult): Season => ({ }); export const mapSeasonWithEpisodes = ( - season: TmdbSeasonWithEpisodes + season: TmdbSeasonWithEpisodes, + availableMap?: Record ): SeasonWithEpisodes => ({ airDate: season.air_date, - episodes: season.episodes.map(mapEpisodeResult), + episodes: season.episodes.map((episode) => + mapEpisodeResult(episode, availableMap) + ), externalIds: mapExternalIds(season.external_ids), id: season.id, name: season.name, diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 743f407e48..7560ef5183 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -3,10 +3,12 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; -import { MediaType } from '@server/constants/media'; +import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import Season from '@server/entity/Season'; import { Watchlist } from '@server/entity/Watchlist'; +import { MetadataProviderType, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; @@ -72,9 +74,10 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { const tmdbTv = await tmdb.getTvShow({ tvId: Number(req.params.id), }); - const metadataProvider = tmdbTv.keywords.results.some( + const isAnime = tmdbTv.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID - ) + ); + const metadataProvider = isAnime ? await getMetadataProvider('anime') : await getMetadataProvider('tv'); @@ -84,7 +87,72 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); - return res.status(200).json(mapSeasonWithEpisodes(season)); + let availableMap: Record | undefined; + + const settings = getSettings(); + const shouldTrackEpisodes = + settings.main.enableEpisodeAvailability && + (isAnime + ? settings.metadataSettings.anime === MetadataProviderType.TVDB + : settings.metadataSettings.tv === MetadataProviderType.TVDB); + + if (shouldTrackEpisodes) { + availableMap = {}; + + const dbSeason = await getRepository(Season).findOne({ + where: { + seasonNumber: Number(req.params.seasonNumber), + media: { + tmdbId: Number(req.params.id), + mediaType: MediaType.TV, + }, + }, + relations: { + episodes: true, + }, + }); + + if (dbSeason) { + if (dbSeason.status === MediaStatus.AVAILABLE) { + for (const episode of season.episodes) { + availableMap[episode.episode_number] = true; + } + } else if ( + dbSeason.status === MediaStatus.PARTIALLY_AVAILABLE && + dbSeason.episodes + ) { + const metadataEpisodeNumbers = new Set( + season.episodes.map((episode) => episode.episode_number) + ); + const hasEpisodeNumberMismatch = + metadataEpisodeNumbers.size !== dbSeason.episodes.length || + dbSeason.episodes.some( + (episode) => !metadataEpisodeNumbers.has(episode.episodeNumber) + ); + + if (hasEpisodeNumberMismatch) { + logger.warn( + 'Skipping episode availability due to episode number mismatch', + { + label: 'API', + tvId: req.params.id, + seasonNumber: req.params.seasonNumber, + metadataEpisodeCount: metadataEpisodeNumbers.size, + trackedEpisodeCount: dbSeason.episodes.length, + } + ); + availableMap = undefined; + } else { + for (const episode of dbSeason.episodes) { + availableMap[episode.episodeNumber] = + episode.status === MediaStatus.AVAILABLE; + } + } + } + } + } + + return res.status(200).json(mapSeasonWithEpisodes(season, availableMap)); } catch (e) { logger.debug('Something went wrong retrieving season', { label: 'API', diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 259a297a71..107957eb9e 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -1,4 +1,5 @@ import BlocklistedTagsSelector from '@app/components/BlocklistedTagsSelector'; +import Alert from '@app/components/Common/Alert'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; @@ -9,6 +10,7 @@ import CopyButton from '@app/components/Settings/CopyButton'; import SettingsBadge from '@app/components/Settings/SettingsBadge'; import { availableLanguages } from '@app/context/LanguageContext'; import useLocale from '@app/hooks/useLocale'; +import useSettings from '@app/hooks/useSettings'; import useToasts from '@app/hooks/useToasts'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -70,6 +72,11 @@ const messages = defineMessages('components.Settings.SettingsMain', { validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', partialRequestsEnabled: 'Allow Partial Series Requests', enableSpecialEpisodes: 'Allow Special Episodes Requests', + enableEpisodeAvailability: 'Enable Episode Availability Tracking', + enableEpisodeAvailabilityTip: + 'Track individual episode availability status (requires TVDB as metadata provider for either TV shows or anime)', + enableEpisodeAvailabilityNoTvdbWarning: + 'This setting has no effect because neither TV shows nor anime use TVDB as the metadata provider.', locale: 'Display Language', youtubeUrl: 'YouTube URL', youtubeUrlTip: @@ -85,11 +92,13 @@ const SettingsMain = () => { const { user: currentUser, hasPermission: userHasPermission } = useUser(); const intl = useIntl(); const { setLocale } = useLocale(); + const { currentSettings } = useSettings(); const { data, error, mutate: revalidate, } = useSWR('/api/v1/settings/main'); + const { data: userData } = useSWR( currentUser ? `/api/v1/user/${currentUser.id}/settings/main` : null ); @@ -183,6 +192,7 @@ const SettingsMain = () => { blocklistedTagsLimit: data?.blocklistedTagsLimit || 50, partialRequestsEnabled: data?.partialRequestsEnabled, enableSpecialEpisodes: data?.enableSpecialEpisodes, + enableEpisodeAvailability: data?.enableEpisodeAvailability, cacheImages: data?.cacheImages, youtubeUrl: data?.youtubeUrl, versionCheck: data?.versionCheck, @@ -206,6 +216,7 @@ const SettingsMain = () => { blocklistedTagsLimit: values.blocklistedTagsLimit, partialRequestsEnabled: values.partialRequestsEnabled, enableSpecialEpisodes: values.enableSpecialEpisodes, + enableEpisodeAvailability: values.enableEpisodeAvailability, cacheImages: values.cacheImages, youtubeUrl: values.youtubeUrl, versionCheck: values?.versionCheck, @@ -588,6 +599,45 @@ const SettingsMain = () => { /> +
+ +
+ { + setFieldValue( + 'enableEpisodeAvailability', + !values.enableEpisodeAvailability + ); + }} + /> +
+ {values.enableEpisodeAvailability && + currentSettings.metadataSettings?.tv !== 'tvdb' && + currentSettings.metadataSettings?.anime !== 'tvdb' && ( +
+ + {intl.formatMessage( + messages.enableEpisodeAvailabilityNoTvdbWarning + )} + +
+ )} +
diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index 25fff9c2fa..9b961fe56c 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -24,6 +24,7 @@ const defaultSettings = { mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, + enableEpisodeAvailability: false, cacheImages: false, vapidPublic: '', enablePushRegistration: false, @@ -32,6 +33,10 @@ const defaultSettings = { newPlexLogin: true, youtubeUrl: '', versionCheck: true, + metadataSettings: { + tv: 'tmdb', + anime: 'tmdb', + }, plexClientIdentifier: '', }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d546f6782a..ebcd346142 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1026,6 +1026,9 @@ "components.Settings.SettingsMain.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)", "components.Settings.SettingsMain.discoverRegion": "Discover Region", "components.Settings.SettingsMain.discoverRegionTip": "Filter content by regional availability", + "components.Settings.SettingsMain.enableEpisodeAvailability": "Enable Episode Availability Tracking", + "components.Settings.SettingsMain.enableEpisodeAvailabilityNoTvdbWarning": "This setting has no effect because neither TV shows nor anime use TVDB as the metadata provider.", + "components.Settings.SettingsMain.enableEpisodeAvailabilityTip": "Track individual episode availability status (requires TVDB as metadata provider for either TV shows or anime)", "components.Settings.SettingsMain.enableSpecialEpisodes": "Allow Special Episodes Requests", "components.Settings.SettingsMain.general": "General", "components.Settings.SettingsMain.generalsettings": "General Settings", diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 381a0cf8f9..9742beb416 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -254,6 +254,7 @@ CoreApp.getInitialProps = async (initialProps) => { mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, + enableEpisodeAvailability: false, cacheImages: false, vapidPublic: '', enablePushRegistration: false, @@ -262,6 +263,10 @@ CoreApp.getInitialProps = async (initialProps) => { newPlexLogin: true, youtubeUrl: '', versionCheck: true, + metadataSettings: { + tv: 'tmdb', + anime: 'tmdb', + }, plexClientIdentifier: '', };