Skip to content
Open
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
35 changes: 35 additions & 0 deletions server/api/servarr/sonarr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,38 @@ describe('SonarrAPI getSeriesByTvdbId', () => {
});
});
});

describe('SonarrAPI getSeriesByTmdbId', () => {
afterEach(() => mock.restoreAll());

it('looks the series up by its tmdb term', async () => {
const sonarr = buildSonarr();
const get = mock.method(getAxios(sonarr), 'get', async () => ({
data: [{ id: 9, tvdbId: 184871, title: 'The Great British Bake Off' }],
}));

const series = await sonarr.getSeriesByTmdbId(87012);

assert.strictEqual(series?.tvdbId, 184871);
assert.strictEqual(get.mock.calls[0].arguments[0], '/series/lookup');
assert.deepStrictEqual(get.mock.calls[0].arguments[1], {
params: { term: 'tmdb:87012' },
});
});

it('returns null when the lookup finds nothing', async () => {
const sonarr = buildSonarr();
mock.method(getAxios(sonarr), 'get', async () => ({ data: [] }));

assert.strictEqual(await sonarr.getSeriesByTmdbId(87012), null);
});

it('returns null instead of throwing when the lookup fails', async () => {
const sonarr = buildSonarr();
mock.method(getAxios(sonarr), 'get', async () => {
throw new Error('connect ECONNREFUSED');
});

assert.strictEqual(await sonarr.getSeriesByTmdbId(87012), null);
});
});
20 changes: 20 additions & 0 deletions server/api/servarr/sonarr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,26 @@ class SonarrAPI extends ServarrBase<{
return response.data[0];
}

public async getSeriesByTmdbId(id: number): Promise<SonarrSeries | null> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
params: {
term: `tmdb:${id}`,
},
});
Comment thread
fallenbagel marked this conversation as resolved.
Dismissed

return response.data[0] ?? null;
} catch (e) {
logger.error('Error retrieving series by tmdb ID', {
label: 'Sonarr API',
errorMessage: e.message,
tmdbId: id,
});

return null;
}
}

public async addSeries(options: AddSeriesOptions): Promise<SonarrSeries> {
try {
const series = await this.getSeriesByTvdbId(options.tvdbid);
Expand Down
57 changes: 57 additions & 0 deletions server/api/tvdb/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,60 @@ describe('Tvdb resolveTvdbId', () => {
assert.strictEqual(await new Tvdb().resolveTvdbId(87012), null);
});
});

describe('Tvdb getOfficialSeasons', () => {
afterEach(() => mock.restoreAll());

const endpoint = '/series/184871/extended?meta=episodes&short=true';

it('derives each official season year from its earliest episode', async () => {
stubApi({
[endpoint]: {
data: {
seasons: [
{ number: 0, type: { type: 'official' } },
{ number: 1, type: { type: 'official' } },
{ number: 2, type: { type: 'official' } },
{ number: 1, type: { type: 'dvd' } },
],
episodes: [
{ seasonNumber: 1, aired: '2020-11-24' },
{ seasonNumber: 1, aired: '2020-09-22' },
{ seasonNumber: 2, aired: '2021-09-21' },
{ seasonNumber: 0, aired: '2019-12-25' },
],
},
},
});

assert.deepStrictEqual(await new Tvdb().getOfficialSeasons(184871), [
{ seasonNumber: 1, year: 2020 },
{ seasonNumber: 2, year: 2021 },
]);
});

it('returns null when the record has no seasons', async () => {
stubApi({ [endpoint]: { data: { episodes: [] } } });

assert.strictEqual(await new Tvdb().getOfficialSeasons(184871), null);
});

it('returns null when the record has no episodes', async () => {
stubApi({ [endpoint]: { data: { seasons: [] } } });

assert.strictEqual(await new Tvdb().getOfficialSeasons(184871), null);
});

it('returns an empty list when the show has no official seasons', async () => {
stubApi({
[endpoint]: {
data: {
seasons: [{ number: 1, type: { type: 'dvd' } }],
episodes: [{ seasonNumber: 1, aired: '2020-09-22' }],
},
},
});

assert.deepStrictEqual(await new Tvdb().getOfficialSeasons(184871), []);
});
});
31 changes: 31 additions & 0 deletions server/api/tvdb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type TvdbBaseResponse,
type TvdbEpisode,
type TvdbLoginResponse,
type TvdbOfficialSeason,
type TvdbRemoteId,
type TvdbSearchByRemoteIdResult,
type TvdbSeasonDetails,
Expand Down Expand Up @@ -289,6 +290,36 @@ class Tvdb extends ExternalAPI implements TvShowProvider {
}
}

public async getOfficialSeasons(
tvdbId: number
): Promise<TvdbOfficialSeason[] | null> {
await this.refreshToken();

const tvdbData = await this.fetchTvdbShowData(tvdbId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// an incomplete record confirms nothing, unlike a show that really has
// no official seasons
if (!tvdbData?.seasons || !tvdbData.episodes) {
return null;
}

return tvdbData.seasons
.filter((season) => season.type?.type === 'official' && season.number > 0)
.sort((a, b) => a.number - b.number)
.map((season) => {
// the season record's own year is null on this endpoint
const years = tvdbData.episodes
.filter((episode) => episode.seasonNumber === season.number)
.map((episode) => Number(episode.aired?.slice(0, 4)))
.filter((year) => !!year);

return {
seasonNumber: season.number,
year: years.length ? Math.min(...years) : null,
};
});
}

private async fetchTvdbSeriesRemoteIds(
tvdbId: number
): Promise<TvdbRemoteId[]> {
Expand Down
5 changes: 5 additions & 0 deletions server/api/tvdb/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export interface TvdbRemoteId {
// sourceName 'TheMovieDB.com' is shared with movie (10), person (15) and collection (28)
export const TVDB_SOURCE_TYPE_TMDB_TV = 12;

export interface TvdbOfficialSeason {
seasonNumber: number;
year: number | null;
}

export interface TvdbSeriesBaseRecord {
id: number;
name: string;
Expand Down
100 changes: 97 additions & 3 deletions server/entity/MediaRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
PrimaryGeneratedColumn,
RelationCount,
UpdateDateColumn,
type Repository,
} from 'typeorm';
import Media from './Media';
import SeasonRequest from './SeasonRequest';
Expand Down Expand Up @@ -53,6 +54,39 @@ export class MediaRequest {
);
}

private static async saveMediaDroppingTvdbIdOnConflict(
mediaRepository: Repository<Media>,
media: Media
): Promise<void> {
try {
await mediaRepository.save(media);
} catch (e) {
if (!media.tvdbId) {
throw e;
}

const owner = await mediaRepository.findOne({
where: { tvdbId: media.tvdbId },
});

// a transient failure leaves the ID unowned, and dropping it there would
// persist NULL over a valid one
if (!owner || owner.id === media.id) {
throw e;
}

logger.warn('Dropped TVDB ID after a conflict on save', {
label: 'Media Request',
tmdbId: media.tmdbId,
tvdbId: media.tvdbId,
errorMessage: e instanceof Error ? e.message : String(e),
});

media.tvdbId = undefined;
await mediaRepository.save(media);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
private static async createRequest(
requestBody: MediaRequestBody,
user: User,
Expand Down Expand Up @@ -162,9 +196,27 @@ export class MediaRequest {
});

if (!media) {
let tvdbId = requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id;

if (tvdbId) {
const conflict = await mediaRepository.findOne({ where: { tvdbId } });

if (conflict) {
logger.info(
'Skipped TVDB ID on new media, already owned by another media row',
{
label: 'Media Request',
tmdbId: tmdbMedia.id,
tvdbId,
}
);
tvdbId = undefined;
}
}

media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
tvdbId,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
Expand Down Expand Up @@ -195,6 +247,42 @@ export class MediaRequest {
) {
media.status4k = MediaStatus.PENDING;
}

const resolvedTvdbId =
media.tvdbId ?? requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id;

if (resolvedTvdbId && resolvedTvdbId !== media.tvdbId) {
const conflict = await mediaRepository.findOne({
where: { tvdbId: resolvedTvdbId },
});

// written on its own so a unique violation cannot take the status
// changes above down with it
if (!conflict) {
try {
await mediaRepository.update(media.id, {
tvdbId: resolvedTvdbId,
});
media.tvdbId = resolvedTvdbId;
} catch (e) {
logger.warn('Failed to persist TVDB ID for existing media', {
label: 'Media Request',
mediaId: media.id,
tvdbId: resolvedTvdbId,
errorMessage: e instanceof Error ? e.message : String(e),
});
}
} else {
logger.info(
'Skipped TVDB ID backfill, already owned by another media row',
{
label: 'Media Request',
mediaId: media.id,
tvdbId: resolvedTvdbId,
}
);
}
}
}

const existing = await requestRepository
Expand Down Expand Up @@ -283,7 +371,10 @@ export class MediaRequest {
}

if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media);
await MediaRequest.saveMediaDroppingTvdbIdOnConflict(
mediaRepository,
media
);

const request = new MediaRequest({
type: MediaType.MOVIE,
Expand Down Expand Up @@ -398,7 +489,10 @@ export class MediaRequest {
throw new QuotaRestrictedError('Series Quota exceeded.');
}

await mediaRepository.save(media);
await MediaRequest.saveMediaDroppingTvdbIdOnConflict(
mediaRepository,
media
);

const request = new MediaRequest({
type: MediaType.TV,
Expand Down
35 changes: 33 additions & 2 deletions server/lib/scanners/baseScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,11 +570,26 @@ class BaseScanner<T> {
(s) => s.status4k === MediaStatus.AVAILABLE
);

let mediaTvdbId = tvdbId;

if (mediaTvdbId) {
const tvdbConflict = await mediaRepository.findOne({
where: { tvdbId: mediaTvdbId },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (tvdbConflict) {
this.log(
`Skipping TVDB ID ${mediaTvdbId} for ${title}, already owned by TMDB ${tvdbConflict.tmdbId}`
);
mediaTvdbId = undefined;
}
}

const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId,
tvdbId,
tvdbId: mediaTvdbId,
mediaAddedAt,
serviceId: !is4k ? serviceId : undefined,
serviceId4k: is4k ? serviceId : undefined,
Expand Down Expand Up @@ -643,7 +658,23 @@ class BaseScanner<T> {
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);

try {
await mediaRepository.save(newMedia);
} catch (e) {
if (!newMedia.tvdbId) {
throw e;
}

// the ownership check above is per-tmdbId, so a concurrent entry for
// the same series can claim the ID between checking and saving
this.log(
`Dropped TVDB ID ${newMedia.tvdbId} for ${title} after a conflict on save`
);
newMedia.tvdbId = undefined;
await mediaRepository.save(newMedia);
}

this.log(`Saved ${title}`);
}
});
Expand Down
Loading
Loading