diff --git a/docs/using-seerr/notifications/index.mdx b/docs/using-seerr/notifications/index.mdx index 4c968644fc..9dd5479e40 100644 --- a/docs/using-seerr/notifications/index.mdx +++ b/docs/using-seerr/notifications/index.mdx @@ -20,6 +20,24 @@ Simply configure your desired notification agents in **Settings -> Notifications Users can customize their notification preferences in their own user notification settings. +## Notification Types + +Seerr supports several notification types for request activity, issues, and +system events. Depending on your permissions, you may see different options for +admin-facing notifications and user-facing notifications. + +For requested TV series only, Seerr can also notify users when a newly aired +episode becomes available. This is separate from the standard +**Request Available** notification, which is used for media becoming available +at the broader request level. + +This notification type is intended for newly aired episodes and is not meant to +notify on older library backfills or imports of episodes that aired before the +request existed. + +If you want per-episode alerts for requested TV series, enable +**Newly Aired Episode Available** in your notification preferences. + ## Requesting New Notification Agents If we do not currently support your preferred notification agent, feel free to [submit a feature request on GitHub](https://github.com/seerr-team/seerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent! diff --git a/docs/using-seerr/settings/notifications.mdx b/docs/using-seerr/settings/notifications.mdx index 71b8b985ce..c1e38dc204 100644 --- a/docs/using-seerr/settings/notifications.mdx +++ b/docs/using-seerr/settings/notifications.mdx @@ -7,3 +7,7 @@ sidebar_position: 5 # Notifications Please see the [Notifications](/using-seerr/notifications) page for more information. + +Users can enable or disable individual notification types from their own +notification settings, including **Newly Aired Episode Available** for +requested TV series. diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 33354e1cda..60c827847c 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -13,7 +13,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..cf93cabfa9 --- /dev/null +++ b/server/entity/Episode.ts @@ -0,0 +1,60 @@ +import { MediaStatus } from '@server/constants/media'; +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; +import { + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import Season from './Season'; + +/** + * Tracks the availability status of individual TV episodes. + * Each episode belongs to a Season and records whether it is available + * in standard and/or 4K quality. This entity enables per-episode + * notifications rather than waiting for an entire season to complete. + * + * Originally introduced in PR #1671 by 0xSysR3ll. + */ +@Entity() +@Unique(['season', 'episodeNumber']) +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; + + @DbAwareColumn({ + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: '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 3e35e6c801..fe62a2c809 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -1,13 +1,14 @@ import { MediaStatus } from '@server/constants/media'; -import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper'; +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { Column, Entity, Index, ManyToOne, + OneToMany, PrimaryGeneratedColumn, - UpdateDateColumn, } from 'typeorm'; +import Episode from './Episode'; import Media from './Media'; @Entity() @@ -30,10 +31,20 @@ class Season { @Index() public media: Promise; + /** Individual episode availability records for this season. */ + @OneToMany(() => Episode, (episode) => episode.season, { + cascade: true, + }) + public episodes?: Episode[]; + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; - @UpdateDateColumn({ type: resolveDbType('datetime') }) + @DbAwareColumn({ + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) public updatedAt: Date; constructor(init?: Partial) { diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index e4c106ae61..f7b2b50c71 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -140,6 +140,10 @@ class DiscordAgent color = EmbedColors.GREEN; status = 'Available'; break; + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: + color = EmbedColors.BLUE; + status = 'Episode Available'; + break; case Notification.MEDIA_DECLINED: color = EmbedColors.RED; status = 'Declined'; diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 0e373c855f..10a21b98c0 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -104,6 +104,11 @@ class EmailAgent is4k ? 'in 4K ' : '' }is now available:`; break; + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: + body = `A new episode of the following series ${ + is4k ? 'in 4K ' : '' + }is now available:`; + break; case Notification.MEDIA_DECLINED: body = `Your request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index 91db546186..0842c0a3bc 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -72,6 +72,9 @@ class GotifyAgent case Notification.MEDIA_AVAILABLE: status = 'Available'; break; + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: + status = 'Episode Available'; + break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; diff --git a/server/lib/notifications/agents/ntfy.ts b/server/lib/notifications/agents/ntfy.ts index cc585508fa..b2b69d6e1c 100644 --- a/server/lib/notifications/agents/ntfy.ts +++ b/server/lib/notifications/agents/ntfy.ts @@ -56,6 +56,9 @@ class NtfyAgent case Notification.MEDIA_AVAILABLE: status = 'Available'; break; + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: + status = 'Episode Available'; + break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index 7dd309dc34..5251abc2a0 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -69,6 +69,9 @@ class PushbulletAgent case Notification.MEDIA_AVAILABLE: status = 'Available'; break; + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: + status = 'Episode Available'; + break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index cd5511a024..7a39e0dc2c 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -121,6 +121,9 @@ class PushoverAgent case Notification.MEDIA_AVAILABLE: status = 'Available'; break; + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: + status = 'Episode Available'; + break; case Notification.MEDIA_DECLINED: status = 'Declined'; priority = 1; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 71360d63ab..72ad57b728 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -87,6 +87,9 @@ class SlackAgent case Notification.MEDIA_AVAILABLE: status = 'Available'; break; + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: + status = 'Episode Available'; + break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index cd54a86d63..f0e37a040f 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -100,6 +100,9 @@ class TelegramAgent case Notification.MEDIA_AVAILABLE: status = 'Available'; break; + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: + status = 'Episode Available'; + break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index 6b98185d35..2e5fe2b6cf 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -93,6 +93,11 @@ class WebPushAgent is4k ? '4K ' : '' }${mediaType} request is now available!`; break; + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: + message = `A new episode of your ${ + is4k ? '4K ' : '' + }series request is now available!`; + break; case Notification.MEDIA_DECLINED: message = `Your ${is4k ? '4K ' : ''}${mediaType} request was declined.`; break; diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index b822ae6eef..a6b976ff82 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -17,6 +17,7 @@ export enum Notification { ISSUE_RESOLVED = 1024, ISSUE_REOPENED = 2048, MEDIA_AUTO_REQUESTED = 4096, + MEDIA_AIRED_EPISODE_AVAILABLE = 8192, } export const hasNotificationType = ( @@ -53,6 +54,7 @@ export const getAdminPermission = (type: Notification): Permission => { case Notification.MEDIA_FAILED: case Notification.MEDIA_DECLINED: case Notification.MEDIA_AUTO_APPROVED: + case Notification.MEDIA_AIRED_EPISODE_AVAILABLE: return Permission.MANAGE_REQUESTS; case Notification.ISSUE_CREATED: case Notification.ISSUE_COMMENT: diff --git a/server/migration/postgres/1772401937242-AddEpisodeTable.ts b/server/migration/postgres/1772401937242-AddEpisodeTable.ts new file mode 100644 index 0000000000..84b8eb2473 --- /dev/null +++ b/server/migration/postgres/1772401937242-AddEpisodeTable.ts @@ -0,0 +1,37 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEpisodeTable1772401937242 implements MigrationInterface { + name = 'AddEpisodeTable1772401937242'; + + 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( + `CREATE SEQUENCE IF NOT EXISTS "blocklist_id_seq" OWNED BY "blocklist"."id"` + ); + await queryRunner.query( + `ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('"blocklist_id_seq"')` + ); + 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( + `ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT` + ); + await queryRunner.query(`DROP SEQUENCE "blocklist_id_seq"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_e73d28c1e5e3c85125163f7c9c"` + ); + await queryRunner.query(`DROP TABLE "episode"`); + } +} diff --git a/server/migration/postgres/1772502000000-AddEpisodeUniqueConstraint.ts b/server/migration/postgres/1772502000000-AddEpisodeUniqueConstraint.ts new file mode 100644 index 0000000000..da5bc3c387 --- /dev/null +++ b/server/migration/postgres/1772502000000-AddEpisodeUniqueConstraint.ts @@ -0,0 +1,27 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEpisodeUniqueConstraint1772502000000 + implements MigrationInterface +{ + name = 'AddEpisodeUniqueConstraint1772502000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Remove any duplicate episodes before adding the constraint. + // Keeps the row with the lowest id for each (seasonId, episodeNumber). + await queryRunner.query( + `DELETE FROM "episode" a USING "episode" b + WHERE a."id" > b."id" + AND a."seasonId" = b."seasonId" + AND a."episodeNumber" = b."episodeNumber"` + ); + await queryRunner.query( + `ALTER TABLE "episode" ADD CONSTRAINT "UQ_episode_season_number" UNIQUE ("seasonId", "episodeNumber")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "episode" DROP CONSTRAINT "UQ_episode_season_number"` + ); + } +} diff --git a/server/migration/sqlite/1772401910127-AddEpisodeTable.ts b/server/migration/sqlite/1772401910127-AddEpisodeTable.ts new file mode 100644 index 0000000000..834d672bcd --- /dev/null +++ b/server/migration/sqlite/1772401910127-AddEpisodeTable.ts @@ -0,0 +1,103 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEpisodeTable1772401910127 implements MigrationInterface { + name = 'AddEpisodeTable1772401910127'; + + 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/migration/sqlite/1772502000000-AddEpisodeUniqueConstraint.ts b/server/migration/sqlite/1772502000000-AddEpisodeUniqueConstraint.ts new file mode 100644 index 0000000000..ea58cb7610 --- /dev/null +++ b/server/migration/sqlite/1772502000000-AddEpisodeUniqueConstraint.ts @@ -0,0 +1,43 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEpisodeUniqueConstraint1772502000000 + implements MigrationInterface +{ + name = 'AddEpisodeUniqueConstraint1772502000000'; + + public async up(queryRunner: QueryRunner): Promise { + // SQLite doesn't support ALTER TABLE ADD CONSTRAINT, so we need to + // recreate the table with the unique constraint. + 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, CONSTRAINT "UQ_episode_season_number" UNIQUE ("seasonId", "episodeNumber"))` + ); + await queryRunner.query( + `INSERT OR IGNORE 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( + `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") ` + ); + } +} diff --git a/server/subscriber/EpisodeSubscriber.ts b/server/subscriber/EpisodeSubscriber.ts new file mode 100644 index 0000000000..1aa1459ee7 --- /dev/null +++ b/server/subscriber/EpisodeSubscriber.ts @@ -0,0 +1,263 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Episode from '@server/entity/Episode'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import Season from '@server/entity/Season'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import logger from '@server/logger'; +import { truncate } from 'lodash'; +import type { + EntitySubscriberInterface, + InsertEvent, + UpdateEvent, +} from 'typeorm'; +import { EventSubscriber, In } from 'typeorm'; + +/** + * Watches Episode entity saves and fires MEDIA_AIRED_EPISODE_AVAILABLE + * notifications when an episode's availability status transitions + * to AVAILABLE. + * + * Spam prevention: only notifies for episodes that aired AFTER the + * user's request was created. This naturally filters out: + * - Bulk library imports (all episodes pre-date the request) + * - Re-scans of existing libraries + * - Backfills of old seasons + * While still allowing notifications for newly released episodes, + * even if the download took days or weeks. + */ +@EventSubscriber() +export class EpisodeSubscriber implements EntitySubscriberInterface { + public listenTo(): typeof Episode { + return Episode; + } + + /** + * Sends a per-episode availability notification to users who + * requested the series containing this episode. + */ + private async sendEpisodeNotification( + episode: Episode, + is4k: boolean + ): Promise { + // Walk up the entity chain: Episode → Season → Media + const season = await episode.season; + if (!season) { + return; + } + + const seasonRepository = getRepository(Season); + const fullSeason = await seasonRepository.findOne({ + where: { id: season.id }, + relations: { media: true }, + }); + if (!fullSeason) { + return; + } + + const media = await fullSeason.media; + if (!media) { + return; + } + + // Find users who requested this series and whose request covers + // this season. Only notify for approved/completed requests — not + // pending or declined ones. + const requestRepository = getRepository(MediaRequest); + const requests = await requestRepository.find({ + relations: { + media: true, + seasons: true, + requestedBy: { + settings: true, + }, + }, + where: { + media: { id: media.id }, + is4k, + status: In([MediaRequestStatus.APPROVED, MediaRequestStatus.COMPLETED]), + }, + }); + + // Filter to requests that include this specific season + const relevantRequests = requests.filter((request) => + request.seasons.some((sr) => sr.seasonNumber === fullSeason.seasonNumber) + ); + + if (relevantRequests.length === 0) { + return; + } + + const tmdb = new TheMovieDb(); + + try { + const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); + + // Fetch episode metadata from TMDB — we need the air date to + // determine if this is a newly released episode, the episode + // title for the notification message, and the season's air date + // distribution to detect batch drops. + let episodeTitle = ''; + let episodeAirDate: Date | null = null; + let seasonEpisodes: { + episode_number: number; + name: string; + air_date: string | null; + }[] = []; + try { + const tmdbSeason = await tmdb.getTvSeason({ + tvId: media.tmdbId, + seasonNumber: fullSeason.seasonNumber, + }); + seasonEpisodes = tmdbSeason.episodes ?? []; + const tmdbEpisode = seasonEpisodes.find( + (ep) => ep.episode_number === episode.episodeNumber + ); + if (tmdbEpisode) { + episodeTitle = tmdbEpisode.name ?? ''; + if (tmdbEpisode.air_date) { + episodeAirDate = new Date(tmdbEpisode.air_date); + } + } + } catch { + // TMDB season/episode lookup is best-effort; if it fails we + // skip the notification since we can't verify the air date + logger.warn( + 'Could not fetch episode metadata from TMDB, skipping notification', + { + label: 'Notifications', + tmdbId: media.tmdbId, + seasonNumber: fullSeason.seasonNumber, + episodeNumber: episode.episodeNumber, + } + ); + return; + } + + // If we couldn't determine the air date, skip — we can't tell + // whether this is a new release or a backfill/import + if (!episodeAirDate) { + return; + } + + // Detect batch-drop seasons (e.g., Netflix releasing all episodes + // on the same day). If every episode in the season shares the same + // air date, this is a batch drop — skip per-episode notifications + // and let the existing MEDIA_AVAILABLE notification handle it. + // Staggered releases (weekly shows, split-cour, premiere batches + // followed by weekly) will have multiple distinct air dates and + // are not suppressed. + const uniqueAirDates = new Set( + seasonEpisodes.filter((ep) => ep.air_date).map((ep) => ep.air_date) + ); + if (uniqueAirDates.size <= 1) { + return; + } + + const showTitle = `${tv.name}${ + tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' + }`; + + // Format episode identifier: "S02E05" or "S02E05 - Episode Title" + const episodeId = `S${String(fullSeason.seasonNumber).padStart(2, '0')}E${String(episode.episodeNumber).padStart(2, '0')}`; + const episodeLabel = episodeTitle + ? `${episodeId} - ${episodeTitle}` + : episodeId; + + // Only notify users whose request was created BEFORE the episode + // aired. This filters out bulk imports of existing libraries + // (all episodes pre-date the request) while allowing notifications + // for newly released episodes, even if the download was slow. + // + // TMDB air_date is date-only (resolves to midnight UTC), while + // request.createdAt has full time precision. To avoid filtering + // out episodes that air the same day as the request, we compare + // at date granularity by truncating createdAt to midnight UTC. + for (const request of relevantRequests) { + const requestDate = new Date(request.createdAt); + requestDate.setUTCHours(0, 0, 0, 0); + + if (episodeAirDate < requestDate) { + continue; + } + + notificationManager.sendNotification( + Notification.MEDIA_AIRED_EPISODE_AVAILABLE, + { + event: `${is4k ? '4K ' : ''}Episode Now Available`, + subject: showTitle, + message: truncate(tv.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + notifyAdmin: false, + notifySystem: true, + notifyUser: request.requestedBy, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, + media, + extra: [ + { + name: 'Episode', + value: episodeLabel, + }, + ], + request, + } + ); + } + } catch (e) { + logger.error( + 'Something went wrong sending episode availability notification(s)', + { + label: 'Notifications', + errorMessage: e.message, + episodeId: episode.id, + } + ); + } + } + + /** + * Fires when a new Episode row is inserted (first time the episode + * is tracked). If it's immediately AVAILABLE, notify. + */ + public async afterInsert(event: InsertEvent): Promise { + if (!event.entity) { + return; + } + + if (event.entity.status === MediaStatus.AVAILABLE) { + await this.sendEpisodeNotification(event.entity, false); + } + + if (event.entity.status4k === MediaStatus.AVAILABLE) { + await this.sendEpisodeNotification(event.entity, true); + } + } + + /** + * Fires when an existing Episode is updated. Only notify if the + * status actually transitioned to AVAILABLE (wasn't already). + */ + public async afterUpdate(event: UpdateEvent): Promise { + if (!event.entity || !event.databaseEntity) { + return; + } + + if ( + event.entity.status === MediaStatus.AVAILABLE && + event.databaseEntity.status !== MediaStatus.AVAILABLE + ) { + await this.sendEpisodeNotification(event.entity as Episode, false); + } + + if ( + event.entity.status4k === MediaStatus.AVAILABLE && + event.databaseEntity.status4k !== MediaStatus.AVAILABLE + ) { + await this.sendEpisodeNotification(event.entity as Episode, true); + } + } +} diff --git a/src/components/NotificationTypeSelector/index.tsx b/src/components/NotificationTypeSelector/index.tsx index 57aa21e88f..07c1c6bc9f 100644 --- a/src/components/NotificationTypeSelector/index.tsx +++ b/src/components/NotificationTypeSelector/index.tsx @@ -26,9 +26,9 @@ const messages = defineMessages('components.NotificationTypeSelector', { 'Get notified when other users submit new media requests which are automatically approved.', mediaavailable: 'Request Available', mediaavailableDescription: - 'Send notifications when media requests become available.', + 'Send notifications when movie or full series requests become available.', usermediaavailableDescription: - 'Get notified when your media requests become available.', + 'Get notified when your movie or full series requests become available.', mediafailed: 'Request Processing Failed', mediafailedDescription: 'Send notifications when media requests fail to be added to Radarr or Sonarr.', @@ -64,6 +64,11 @@ const messages = defineMessages('components.NotificationTypeSelector', { mediaautorequested: 'Request Automatically Submitted', mediaautorequestedDescription: 'Get notified when new media requests are automatically submitted for items on Your Watchlist.', + mediaepisodeavailable: 'Newly Aired Episode Available', + mediaepisodeavailableDescription: + 'Send notifications when newly aired episodes become available for a requested series.', + usermediaepisodeavailableDescription: + 'Get notified as newly aired episodes become available for a series you requested.', }); export const hasNotificationType = ( @@ -106,6 +111,7 @@ export enum Notification { ISSUE_RESOLVED = 1024, ISSUE_REOPENED = 2048, MEDIA_AUTO_REQUESTED = 4096, + MEDIA_AIRED_EPISODE_AVAILABLE = 8192, } export const ALL_NOTIFICATIONS = Object.values(Notification) @@ -274,6 +280,17 @@ const NotificationTypeSelector = ({ value: Notification.MEDIA_AVAILABLE, hasNotifyUser: true, }, + { + id: 'media-episode-available', + name: intl.formatMessage(messages.mediaepisodeavailable), + description: intl.formatMessage( + user + ? messages.usermediaepisodeavailableDescription + : messages.mediaepisodeavailableDescription + ), + value: Notification.MEDIA_AIRED_EPISODE_AVAILABLE, + hasNotifyUser: true, + }, { id: 'media-failed', name: intl.formatMessage(messages.mediafailed), diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index cbef6567be..c232487798 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -378,9 +378,11 @@ "components.NotificationTypeSelector.mediaautorequested": "Request Automatically Submitted", "components.NotificationTypeSelector.mediaautorequestedDescription": "Get notified when new media requests are automatically submitted for items on Your Watchlist.", "components.NotificationTypeSelector.mediaavailable": "Request Available", - "components.NotificationTypeSelector.mediaavailableDescription": "Send notifications when media requests become available.", + "components.NotificationTypeSelector.mediaavailableDescription": "Send notifications when movie or full series requests become available.", "components.NotificationTypeSelector.mediadeclined": "Request Declined", "components.NotificationTypeSelector.mediadeclinedDescription": "Send notifications when media requests are declined.", + "components.NotificationTypeSelector.mediaepisodeavailable": "Newly Aired Episode Available", + "components.NotificationTypeSelector.mediaepisodeavailableDescription": "Send notifications when newly aired episodes become available for a requested series.", "components.NotificationTypeSelector.mediafailed": "Request Processing Failed", "components.NotificationTypeSelector.mediafailedDescription": "Send notifications when media requests fail to be added to Radarr or Sonarr.", "components.NotificationTypeSelector.mediarequested": "Request Pending Approval", @@ -392,8 +394,9 @@ "components.NotificationTypeSelector.userissueresolvedDescription": "Get notified when issues you reported are resolved.", "components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Get notified when other users submit new media requests which are automatically approved.", "components.NotificationTypeSelector.usermediaapprovedDescription": "Get notified when your media requests are approved.", - "components.NotificationTypeSelector.usermediaavailableDescription": "Get notified when your media requests become available.", + "components.NotificationTypeSelector.usermediaavailableDescription": "Get notified when your movie or full series requests become available.", "components.NotificationTypeSelector.usermediadeclinedDescription": "Get notified when your media requests are declined.", + "components.NotificationTypeSelector.usermediaepisodeavailableDescription": "Get notified as newly aired episodes become available for a series you requested.", "components.NotificationTypeSelector.usermediafailedDescription": "Get notified when media requests fail to be added to Radarr or Sonarr.", "components.NotificationTypeSelector.usermediarequestedDescription": "Get notified when other users submit new media requests which require approval.", "components.PermissionEdit.admin": "Admin",