Skip to content

Commit 1b35dd4

Browse files
committed
feat(episodes): add episode availability tracking and sync
This allows Jellyseerr to track the availability status of individual episodes, enabling better status reporting for partially available seasons.
1 parent 8da1c92 commit 1b35dd4

File tree

10 files changed

+271
-7
lines changed

10 files changed

+271
-7
lines changed

server/api/servarr/sonarr.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,21 @@ class SonarrAPI extends ServarrBase<{
380380
});
381381
}
382382
};
383+
384+
public async getEpisodesBySeriesId(
385+
seriesId: number
386+
): Promise<EpisodeResult[]> {
387+
try {
388+
const response = await this.axios.get<EpisodeResult[]>(`/episode`, {
389+
params: { seriesId },
390+
});
391+
return response.data;
392+
} catch (e) {
393+
throw new Error(
394+
`[Sonarr] Failed to retrieve episodes for series: ${e.message}`
395+
);
396+
}
397+
}
383398
}
384399

385400
export default SonarrAPI;

server/entity/Episode.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { MediaStatus } from '@server/constants/media';
2+
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
3+
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
4+
import Season from './Season';
5+
6+
@Entity()
7+
class Episode {
8+
@PrimaryGeneratedColumn()
9+
public id: number;
10+
11+
@Column()
12+
public episodeNumber: number;
13+
14+
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
15+
public status: MediaStatus;
16+
17+
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
18+
public status4k: MediaStatus;
19+
20+
@ManyToOne(() => Season, (season: Season) => season.episodes, {
21+
onDelete: 'CASCADE',
22+
nullable: true,
23+
})
24+
public season?: Promise<Season>;
25+
26+
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
27+
public createdAt: Date;
28+
29+
@DbAwareColumn({
30+
type: 'datetime',
31+
default: () => 'CURRENT_TIMESTAMP',
32+
onUpdate: 'CURRENT_TIMESTAMP',
33+
})
34+
public updatedAt: Date;
35+
36+
constructor(init?: Partial<Episode>) {
37+
if (init) {
38+
Object.assign(this, init);
39+
}
40+
}
41+
}
42+
43+
export default Episode;

server/entity/Media.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,27 @@ class Media {
7070
const mediaRepository = getRepository(Media);
7171

7272
try {
73+
const relations: {
74+
requests: boolean;
75+
issues: boolean;
76+
seasons?: {
77+
episodes: boolean;
78+
};
79+
} = {
80+
requests: true,
81+
issues: true,
82+
};
83+
84+
// Only load seasons for TV shows
85+
if (mediaType === MediaType.TV) {
86+
relations.seasons = {
87+
episodes: true,
88+
};
89+
}
90+
7391
const media = await mediaRepository.findOne({
7492
where: { tmdbId: id, mediaType: mediaType },
75-
relations: { requests: true, issues: true },
93+
relations,
7694
});
7795

7896
return media ?? undefined;

server/entity/Season.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { MediaStatus } from '@server/constants/media';
22
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
3-
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
3+
import {
4+
Column,
5+
Entity,
6+
ManyToOne,
7+
OneToMany,
8+
PrimaryGeneratedColumn,
9+
} from 'typeorm';
10+
import Episode from './Episode';
411
import Media from './Media';
512

613
@Entity()
@@ -22,6 +29,12 @@ class Season {
2229
})
2330
public media: Promise<Media>;
2431

32+
@OneToMany(() => Episode, (episode) => episode.season, {
33+
cascade: true,
34+
eager: true,
35+
})
36+
public episodes: Episode[];
37+
2538
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
2639
public createdAt: Date;
2740

server/lib/availabilitySync.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import SonarrAPI from '@server/api/servarr/sonarr';
88
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
99
import { MediaServerType } from '@server/constants/server';
1010
import { getRepository } from '@server/datasource';
11+
import Episode from '@server/entity/Episode';
1112
import Media from '@server/entity/Media';
1213
import MediaRequest from '@server/entity/MediaRequest';
1314
import type Season from '@server/entity/Season';
@@ -804,6 +805,7 @@ class AvailabilitySync {
804805
is4k: boolean
805806
): Promise<boolean> {
806807
let seasonExists = false;
808+
const episodeRepository = getRepository(Episode);
807809

808810
// Check each sonarr instance to see if the media still exists
809811
// If found, we will assume the media exists and prevent removal
@@ -832,6 +834,82 @@ class AvailabilitySync {
832834

833835
if (seasonIsAvailable && sonarrSeasons) {
834836
seasonExists = true;
837+
838+
const sonarrApi = new SonarrAPI({
839+
url: SonarrAPI.buildUrl(server, '/api/v3'),
840+
apiKey: server.apiKey,
841+
});
842+
843+
try {
844+
const serviceId = is4k
845+
? media.externalServiceId4k
846+
: media.externalServiceId;
847+
848+
if (!serviceId) {
849+
logger.error('Missing service ID for episode sync', {
850+
label: 'Availability Sync',
851+
tvId: media.tmdbId,
852+
seasonNumber: season.seasonNumber,
853+
is4k,
854+
});
855+
return seasonExists;
856+
}
857+
858+
const episodes = await sonarrApi.getEpisodesBySeriesId(serviceId);
859+
860+
for (const ep of episodes) {
861+
if (ep.seasonNumber === season.seasonNumber) {
862+
const existingEpisode = await episodeRepository.findOne({
863+
where: {
864+
episodeNumber: ep.episodeNumber,
865+
season: { id: season.id },
866+
},
867+
relations: ['season'],
868+
});
869+
870+
if (existingEpisode) {
871+
existingEpisode[is4k ? 'status4k' : 'status'] = ep.hasFile
872+
? MediaStatus.AVAILABLE
873+
: MediaStatus.UNKNOWN;
874+
await episodeRepository.save(existingEpisode);
875+
} else {
876+
const newEpisode = new Episode();
877+
newEpisode.episodeNumber = ep.episodeNumber;
878+
newEpisode.status = is4k
879+
? MediaStatus.UNKNOWN
880+
: ep.hasFile
881+
? MediaStatus.AVAILABLE
882+
: MediaStatus.UNKNOWN;
883+
newEpisode.status4k = is4k
884+
? ep.hasFile
885+
? MediaStatus.AVAILABLE
886+
: MediaStatus.UNKNOWN
887+
: MediaStatus.UNKNOWN;
888+
newEpisode.season = Promise.resolve(season);
889+
890+
try {
891+
await episodeRepository.save(newEpisode);
892+
} catch (saveError) {
893+
logger.error('Failed to save new episode', {
894+
label: 'Availability Sync',
895+
errorMessage: saveError.message,
896+
tvId: media.tmdbId,
897+
seasonNumber: season.seasonNumber,
898+
episodeNumber: ep.episodeNumber,
899+
});
900+
}
901+
}
902+
}
903+
}
904+
} catch (err) {
905+
logger.error('Failed to update episode availability', {
906+
label: 'Availability Sync',
907+
errorMessage: err.message,
908+
tvId: media.tmdbId,
909+
seasonNumber: season.seasonNumber,
910+
sonarrServerId: server.id,
911+
});
912+
}
835913
}
836914
}
837915

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddEpisodeTable1747690625482 implements MigrationInterface {
4+
name = 'AddEpisodeTable1747690625482';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`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"))`
9+
);
10+
await queryRunner.query(
11+
`ALTER TABLE "episode" ADD CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd" FOREIGN KEY ("seasonId") REFERENCES "season"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
12+
);
13+
}
14+
15+
public async down(queryRunner: QueryRunner): Promise<void> {
16+
await queryRunner.query(
17+
`ALTER TABLE "episode" DROP CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd"`
18+
);
19+
await queryRunner.query(`DROP TABLE "episode"`);
20+
}
21+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddEpisodeTable1747690625482 implements MigrationInterface {
4+
name = 'AddEpisodeTable1747690625482';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`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)`
9+
);
10+
await queryRunner.query(
11+
`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)`
12+
);
13+
await queryRunner.query(
14+
`INSERT INTO "temporary_episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "episode"`
15+
);
16+
await queryRunner.query(`DROP TABLE "episode"`);
17+
await queryRunner.query(
18+
`ALTER TABLE "temporary_episode" RENAME TO "episode"`
19+
);
20+
}
21+
22+
public async down(queryRunner: QueryRunner): Promise<void> {
23+
await queryRunner.query(
24+
`ALTER TABLE "episode" RENAME TO "temporary_episode"`
25+
);
26+
await queryRunner.query(
27+
`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)`
28+
);
29+
await queryRunner.query(
30+
`INSERT INTO "episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "temporary_episode"`
31+
);
32+
await queryRunner.query(`DROP TABLE "temporary_episode"`);
33+
}
34+
}

server/models/Tv.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ interface Episode {
3838
stillPath?: string;
3939
voteAverage: number;
4040
voteCount: number;
41+
available?: boolean;
4142
}
4243

4344
interface Season {
@@ -114,7 +115,10 @@ export interface TvDetails {
114115
onUserWatchlist?: boolean;
115116
}
116117

117-
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
118+
const mapEpisodeResult = (
119+
episode: TmdbTvEpisodeResult,
120+
availableMap?: Record<number, boolean>
121+
): Episode => ({
118122
id: episode.id,
119123
airDate: episode.air_date,
120124
episodeNumber: episode.episode_number,
@@ -126,6 +130,9 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
126130
voteAverage: episode.vote_average,
127131
voteCount: episode.vote_cuont,
128132
stillPath: episode.still_path,
133+
available: availableMap
134+
? availableMap[episode.episode_number] ?? false
135+
: undefined,
129136
});
130137

131138
const mapSeasonResult = (season: TmdbTvSeasonResult): Season => ({
@@ -139,10 +146,13 @@ const mapSeasonResult = (season: TmdbTvSeasonResult): Season => ({
139146
});
140147

141148
export const mapSeasonWithEpisodes = (
142-
season: TmdbSeasonWithEpisodes
149+
season: TmdbSeasonWithEpisodes,
150+
availableMap?: Record<number, boolean>
143151
): SeasonWithEpisodes => ({
144152
airDate: season.air_date,
145-
episodes: season.episodes.map(mapEpisodeResult),
153+
episodes: season.episodes.map((episode) =>
154+
mapEpisodeResult(episode, availableMap)
155+
),
146156
externalIds: mapExternalIds(season.external_ids),
147157
id: season.id,
148158
name: season.name,

server/routes/tv.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import RottenTomatoes from '@server/api/rating/rottentomatoes';
22
import TheMovieDb from '@server/api/themoviedb';
3-
import { MediaType } from '@server/constants/media';
3+
import { MediaStatus, MediaType } from '@server/constants/media';
44
import { getRepository } from '@server/datasource';
55
import Media from '@server/entity/Media';
66
import { Watchlist } from '@server/entity/Watchlist';
@@ -62,7 +62,30 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
6262
language: (req.query.language as string) ?? req.locale,
6363
});
6464

65-
return res.status(200).json(mapSeasonWithEpisodes(season));
65+
const media = await Media.getMedia(Number(req.params.id), MediaType.TV);
66+
const availableMap: Record<number, boolean> = {};
67+
68+
if (media?.seasons) {
69+
const dbSeason = media.seasons.find(
70+
(s) => s.seasonNumber === Number(req.params.seasonNumber)
71+
);
72+
if (dbSeason) {
73+
if (dbSeason.status === MediaStatus.AVAILABLE) {
74+
for (const episode of season.episodes) {
75+
availableMap[episode.episode_number] = true;
76+
}
77+
} else if (dbSeason.status === MediaStatus.PARTIALLY_AVAILABLE) {
78+
if (dbSeason.episodes) {
79+
for (const episode of dbSeason.episodes) {
80+
availableMap[episode.episodeNumber] =
81+
episode.status === MediaStatus.AVAILABLE;
82+
}
83+
}
84+
}
85+
}
86+
}
87+
88+
return res.status(200).json(mapSeasonWithEpisodes(season, availableMap));
6689
} catch (e) {
6790
logger.debug('Something went wrong retrieving season', {
6891
label: 'API',

src/components/TvDetails/Season/index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import AirDateBadge from '@app/components/AirDateBadge';
2+
import Badge from '@app/components/Common/Badge';
23
import CachedImage from '@app/components/Common/CachedImage';
34
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
5+
import globalMessages from '@app/i18n/globalMessages';
46
import defineMessages from '@app/utils/defineMessages';
57
import type { SeasonWithEpisodes } from '@server/models/Tv';
68
import { useIntl } from 'react-intl';
@@ -52,6 +54,13 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => {
5254
{episode.airDate && (
5355
<AirDateBadge airDate={episode.airDate} />
5456
)}
57+
<Badge badgeType={episode.available ? 'success' : 'danger'}>
58+
{intl.formatMessage(
59+
episode.available
60+
? globalMessages.available
61+
: globalMessages.unavailable
62+
)}
63+
</Badge>
5564
</div>
5665
{episode.overview && <p>{episode.overview}</p>}
5766
</div>

0 commit comments

Comments
 (0)