Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/using-seerr/notifications/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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!
4 changes: 4 additions & 0 deletions docs/using-seerr/settings/notifications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion server/api/servarr/sonarr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface SonarrSeason {
percentOfEpisodes: number;
};
}
interface EpisodeResult {
export interface EpisodeResult {
seriesId: number;
episodeFileId: number;
seasonNumber: number;
Expand Down
60 changes: 60 additions & 0 deletions server/entity/Episode.ts
Original file line number Diff line number Diff line change
@@ -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<Season>;

@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;

@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
public updatedAt: Date;

constructor(init?: Partial<Episode>) {
if (init) {
Object.assign(this, init);
}
}
}

export default Episode;
17 changes: 14 additions & 3 deletions server/entity/Season.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -30,10 +31,20 @@ class Season {
@Index()
public media: Promise<Media>;

/** 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<Season>) {
Expand Down
4 changes: 4 additions & 0 deletions server/lib/notifications/agents/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 5 additions & 0 deletions server/lib/notifications/agents/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' : ''
Expand Down
3 changes: 3 additions & 0 deletions server/lib/notifications/agents/gotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions server/lib/notifications/agents/ntfy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions server/lib/notifications/agents/pushbullet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions server/lib/notifications/agents/pushover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions server/lib/notifications/agents/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions server/lib/notifications/agents/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions server/lib/notifications/agents/webpush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions server/lib/notifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions server/migration/postgres/1772401937242-AddEpisodeTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddEpisodeTable1772401937242 implements MigrationInterface {
name = 'AddEpisodeTable1772401937242';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddEpisodeUniqueConstraint1772502000000
implements MigrationInterface
{
name = 'AddEpisodeUniqueConstraint1772502000000';

public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
await queryRunner.query(
`ALTER TABLE "episode" DROP CONSTRAINT "UQ_episode_season_number"`
);
}
}
Loading
Loading