mirror of
https://github.com/seerr-team/seerr.git
synced 2026-10-12 02:22:17 +00:00
fix: handle already-removed media in radarr/sonarr delete (#3209)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it, mock } from 'node:test';
|
||||
|
||||
import type { AxiosInstance } from 'axios';
|
||||
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
|
||||
function buildRadarr(): RadarrAPI {
|
||||
return new RadarrAPI({ url: 'http://localhost:7878/api/v3', apiKey: 'test' });
|
||||
}
|
||||
|
||||
function getAxios(radarr: RadarrAPI): AxiosInstance {
|
||||
return (radarr as unknown as { axios: AxiosInstance }).axios;
|
||||
}
|
||||
|
||||
describe('RadarrAPI removeMovie', () => {
|
||||
afterEach(() => mock.restoreAll());
|
||||
|
||||
it('removes the movie when it exists in the library', async () => {
|
||||
const radarr = buildRadarr();
|
||||
mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
|
||||
id: 7,
|
||||
title: 'Test Movie',
|
||||
}));
|
||||
const del = mock.method(getAxios(radarr), 'delete', async () => ({}));
|
||||
|
||||
await radarr.removeMovie(550);
|
||||
|
||||
assert.strictEqual(del.mock.callCount(), 1);
|
||||
assert.strictEqual(del.mock.calls[0].arguments[0], '/movie/7');
|
||||
});
|
||||
|
||||
it('does nothing when the movie is not in the library', async () => {
|
||||
const radarr = buildRadarr();
|
||||
mock.method(getAxios(radarr), 'get', async () => ({
|
||||
data: [{ id: 0, title: 'Fight Club' }],
|
||||
}));
|
||||
const del = mock.method(getAxios(radarr), 'delete', async () => ({}));
|
||||
|
||||
await assert.doesNotReject(() => radarr.removeMovie(550));
|
||||
assert.strictEqual(del.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('rejects when the tmdbId is unknown to the lookup', async () => {
|
||||
const radarr = buildRadarr();
|
||||
mock.method(getAxios(radarr), 'get', async () => ({ data: [] }));
|
||||
const del = mock.method(getAxios(radarr), 'delete', async () => ({}));
|
||||
|
||||
await assert.rejects(() => radarr.removeMovie(550), /Movie not found/);
|
||||
assert.strictEqual(del.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('ignores a 404 when the movie was already removed in Radarr', async () => {
|
||||
const radarr = buildRadarr();
|
||||
mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
|
||||
id: 7,
|
||||
title: 'Test Movie',
|
||||
}));
|
||||
mock.method(getAxios(radarr), 'delete', async () => {
|
||||
throw { response: { status: 404 } };
|
||||
});
|
||||
|
||||
await assert.doesNotReject(() => radarr.removeMovie(550));
|
||||
});
|
||||
|
||||
it('rethrows errors other than 404', async () => {
|
||||
const radarr = buildRadarr();
|
||||
mock.method(RadarrAPI.prototype, 'getMovieByTmdbId', async () => ({
|
||||
id: 7,
|
||||
title: 'Test Movie',
|
||||
}));
|
||||
mock.method(getAxios(radarr), 'delete', async () => {
|
||||
throw { response: { status: 500 } };
|
||||
});
|
||||
|
||||
await assert.rejects(() => radarr.removeMovie(550));
|
||||
});
|
||||
|
||||
it('rethrows a 404 from the lookup instead of treating it as removed', async () => {
|
||||
const radarr = buildRadarr();
|
||||
mock.method(getAxios(radarr), 'get', async () => {
|
||||
throw { response: { status: 404 } };
|
||||
});
|
||||
const del = mock.method(getAxios(radarr), 'delete', async () => ({}));
|
||||
|
||||
await assert.rejects(
|
||||
() => radarr.removeMovie(550),
|
||||
(e: unknown) =>
|
||||
(e as { response?: { status?: number } }).response?.status === 404
|
||||
);
|
||||
assert.strictEqual(del.mock.callCount(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RadarrAPI getMovieByTmdbId', () => {
|
||||
afterEach(() => mock.restoreAll());
|
||||
|
||||
it('rethrows a 401 from the lookup with the status intact', async () => {
|
||||
const radarr = buildRadarr();
|
||||
mock.method(getAxios(radarr), 'get', async () => {
|
||||
throw { response: { status: 401 } };
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => radarr.getMovieByTmdbId(550),
|
||||
(e: unknown) =>
|
||||
(e as { response?: { status?: number } }).response?.status === 401
|
||||
);
|
||||
});
|
||||
|
||||
it('throws "Movie not found" when the lookup returns no results', async () => {
|
||||
const radarr = buildRadarr();
|
||||
mock.method(getAxios(radarr), 'get', async () => ({ data: [] }));
|
||||
|
||||
await assert.rejects(() => radarr.getMovieByTmdbId(550), {
|
||||
message: 'Movie not found',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import logger from '@server/logger';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import ServarrBase from './base';
|
||||
|
||||
export interface RadarrMovieOptions {
|
||||
@@ -93,26 +94,27 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
};
|
||||
|
||||
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
|
||||
let response: AxiosResponse<RadarrMovie[]>;
|
||||
try {
|
||||
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
|
||||
response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
|
||||
params: {
|
||||
term: `tmdb:${id}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data[0]) {
|
||||
throw new Error('Movie not found');
|
||||
}
|
||||
|
||||
return response.data[0];
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving movie by TMDB ID', {
|
||||
label: 'Radarr API',
|
||||
errorMessage: e.message,
|
||||
tmdbId: id,
|
||||
});
|
||||
throw new Error('Movie not found', { cause: e });
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!response.data[0]) {
|
||||
throw new Error('Movie not found');
|
||||
}
|
||||
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
public addMovie = async (
|
||||
@@ -267,9 +269,17 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
);
|
||||
}
|
||||
}
|
||||
public removeMovie = async (movieId: number): Promise<void> => {
|
||||
public removeMovie = async (tmdbId: number): Promise<void> => {
|
||||
const { id, title } = await this.getMovieByTmdbId(tmdbId);
|
||||
|
||||
if (!id) {
|
||||
logger.info(`[Radarr] Movie not in library, nothing to remove`, {
|
||||
tmdbId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, title } = await this.getMovieByTmdbId(movieId);
|
||||
await this.axios.delete(`/movie/${id}`, {
|
||||
params: {
|
||||
deleteFiles: true,
|
||||
@@ -278,9 +288,13 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
});
|
||||
logger.info(`[Radarr] Removed movie ${title}`);
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`, {
|
||||
cause: e,
|
||||
});
|
||||
if (e?.response?.status === 404) {
|
||||
logger.info(`[Radarr] Movie already removed from Radarr`, {
|
||||
tmdbId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it, mock } from 'node:test';
|
||||
|
||||
import type { AxiosInstance } from 'axios';
|
||||
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
|
||||
function buildSonarr(): SonarrAPI {
|
||||
return new SonarrAPI({ url: 'http://localhost:8989/api/v3', apiKey: 'test' });
|
||||
}
|
||||
|
||||
function getAxios(sonarr: SonarrAPI): AxiosInstance {
|
||||
return (sonarr as unknown as { axios: AxiosInstance }).axios;
|
||||
}
|
||||
|
||||
describe('SonarrAPI removeSeries', () => {
|
||||
afterEach(() => mock.restoreAll());
|
||||
|
||||
it('removes the series when it exists in the library', async () => {
|
||||
const sonarr = buildSonarr();
|
||||
mock.method(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({
|
||||
id: 9,
|
||||
title: 'Test Series',
|
||||
}));
|
||||
const del = mock.method(getAxios(sonarr), 'delete', async () => ({}));
|
||||
|
||||
await sonarr.removeSeries(1234);
|
||||
|
||||
assert.strictEqual(del.mock.callCount(), 1);
|
||||
assert.strictEqual(del.mock.calls[0].arguments[0], '/series/9');
|
||||
});
|
||||
|
||||
it('does nothing when the series is not in the library', async () => {
|
||||
const sonarr = buildSonarr();
|
||||
mock.method(getAxios(sonarr), 'get', async () => ({
|
||||
data: [{ id: 0, title: 'Breaking Bad' }],
|
||||
}));
|
||||
const del = mock.method(getAxios(sonarr), 'delete', async () => ({}));
|
||||
|
||||
await assert.doesNotReject(() => sonarr.removeSeries(1234));
|
||||
assert.strictEqual(del.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('rejects when the tvdbId is unknown to the lookup', async () => {
|
||||
const sonarr = buildSonarr();
|
||||
mock.method(getAxios(sonarr), 'get', async () => ({ data: [] }));
|
||||
const del = mock.method(getAxios(sonarr), 'delete', async () => ({}));
|
||||
|
||||
await assert.rejects(() => sonarr.removeSeries(1234), /Series not found/);
|
||||
assert.strictEqual(del.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('ignores a 404 when the series was already removed in Sonarr', async () => {
|
||||
const sonarr = buildSonarr();
|
||||
mock.method(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({
|
||||
id: 9,
|
||||
title: 'Test Series',
|
||||
}));
|
||||
mock.method(getAxios(sonarr), 'delete', async () => {
|
||||
throw { response: { status: 404 } };
|
||||
});
|
||||
|
||||
await assert.doesNotReject(() => sonarr.removeSeries(1234));
|
||||
});
|
||||
|
||||
it('rethrows errors other than 404', async () => {
|
||||
const sonarr = buildSonarr();
|
||||
mock.method(SonarrAPI.prototype, 'getSeriesByTvdbId', async () => ({
|
||||
id: 9,
|
||||
title: 'Test Series',
|
||||
}));
|
||||
mock.method(getAxios(sonarr), 'delete', async () => {
|
||||
throw { response: { status: 500 } };
|
||||
});
|
||||
|
||||
await assert.rejects(() => sonarr.removeSeries(1234));
|
||||
});
|
||||
|
||||
it('rethrows a 404 from the lookup instead of treating it as removed', async () => {
|
||||
const sonarr = buildSonarr();
|
||||
mock.method(getAxios(sonarr), 'get', async () => {
|
||||
throw { response: { status: 404 } };
|
||||
});
|
||||
const del = mock.method(getAxios(sonarr), 'delete', async () => ({}));
|
||||
|
||||
await assert.rejects(
|
||||
() => sonarr.removeSeries(1234),
|
||||
(e: unknown) =>
|
||||
(e as { response?: { status?: number } }).response?.status === 404
|
||||
);
|
||||
assert.strictEqual(del.mock.callCount(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SonarrAPI getSeriesByTvdbId', () => {
|
||||
afterEach(() => mock.restoreAll());
|
||||
|
||||
it('rethrows a 401 from the lookup with the status intact', async () => {
|
||||
const sonarr = buildSonarr();
|
||||
mock.method(getAxios(sonarr), 'get', async () => {
|
||||
throw { response: { status: 401 } };
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => sonarr.getSeriesByTvdbId(1234),
|
||||
(e: unknown) =>
|
||||
(e as { response?: { status?: number } }).response?.status === 401
|
||||
);
|
||||
});
|
||||
|
||||
it('throws "Series not found" when the lookup returns no results', async () => {
|
||||
const sonarr = buildSonarr();
|
||||
mock.method(getAxios(sonarr), 'get', async () => ({ data: [] }));
|
||||
|
||||
await assert.rejects(() => sonarr.getSeriesByTvdbId(1234), {
|
||||
message: 'Series not found',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import logger from '@server/logger';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import ServarrBase from './base';
|
||||
|
||||
export interface SonarrSeason {
|
||||
@@ -166,26 +167,27 @@ class SonarrAPI extends ServarrBase<{
|
||||
}
|
||||
|
||||
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
|
||||
let response: AxiosResponse<SonarrSeries[]>;
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||
response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||
params: {
|
||||
term: `tvdb:${id}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data[0]) {
|
||||
throw new Error('Series not found');
|
||||
}
|
||||
|
||||
return response.data[0];
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving series by tvdb ID', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
tvdbId: id,
|
||||
});
|
||||
throw new Error('Series not found', { cause: e });
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!response.data[0]) {
|
||||
throw new Error('Series not found');
|
||||
}
|
||||
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
public async addSeries(options: AddSeriesOptions): Promise<SonarrSeries> {
|
||||
@@ -410,9 +412,17 @@ class SonarrAPI extends ServarrBase<{
|
||||
|
||||
return newSeasons;
|
||||
}
|
||||
public removeSeries = async (serieId: number): Promise<void> => {
|
||||
public removeSeries = async (tvdbId: number): Promise<void> => {
|
||||
const { id, title } = await this.getSeriesByTvdbId(tvdbId);
|
||||
|
||||
if (!id) {
|
||||
logger.info(`[Sonarr] Series not in library, nothing to remove`, {
|
||||
tvdbId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
||||
await this.axios.delete(`/series/${id}`, {
|
||||
params: {
|
||||
deleteFiles: true,
|
||||
@@ -421,9 +431,13 @@ class SonarrAPI extends ServarrBase<{
|
||||
});
|
||||
logger.info(`[Sonarr] Removed series ${title}`);
|
||||
} catch (e) {
|
||||
throw new Error(`[Sonarr] Failed to remove series: ${e.message}`, {
|
||||
cause: e,
|
||||
});
|
||||
if (e?.response?.status === 404) {
|
||||
logger.info(`[Sonarr] Series already removed from Sonarr`, {
|
||||
tvdbId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+13
-9
@@ -243,18 +243,18 @@ mediaRoutes.delete(
|
||||
}
|
||||
|
||||
if (!serviceSettings) {
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||
}/ server configured. Did you set any of your ${
|
||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||
} servers as default?`,
|
||||
const arrName = `${is4k ? '4K ' : ''}${isMovie ? 'Radarr' : 'Sonarr'}`;
|
||||
logger.info(
|
||||
`There is no default ${arrName} server configured. Did you set any of your ${arrName} servers as default?`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
mediaId: media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
return next({
|
||||
status: 409,
|
||||
message: `No ${arrName} server configured to delete media files`,
|
||||
});
|
||||
}
|
||||
|
||||
let service;
|
||||
@@ -284,11 +284,15 @@ mediaRoutes.delete(
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching media in delete request', {
|
||||
if (e instanceof EntityNotFoundError) {
|
||||
return next({ status: 404, message: 'Media not found' });
|
||||
}
|
||||
logger.error('Something went wrong deleting media file', {
|
||||
label: 'Media',
|
||||
mediaId: req.params.id,
|
||||
message: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Media not found' });
|
||||
next({ status: 500, message: 'Failed to delete media file' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import DownloadBlock from '@app/components/DownloadBlock';
|
||||
import IssueBlock from '@app/components/IssueBlock';
|
||||
import RequestBlock from '@app/components/RequestBlock';
|
||||
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';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -64,6 +65,8 @@ const messages = defineMessages('components.ManageSlideOver', {
|
||||
removearr: 'Remove from {arr}',
|
||||
openarr4k: 'Open in 4K {arr}',
|
||||
removearr4k: 'Remove from 4K {arr}',
|
||||
clearmediadataerror: 'Something went wrong while clearing the media data.',
|
||||
removemediaerror: 'Something went wrong while removing the media.',
|
||||
downloadstatus: 'Downloads',
|
||||
markavailable: 'Mark as Available',
|
||||
mark4kavailable: 'Mark as Available in 4K',
|
||||
@@ -109,6 +112,7 @@ const ManageSlideOver = ({
|
||||
}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => {
|
||||
const { user: currentUser, hasPermission } = useUser();
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const settings = useSettings();
|
||||
const { data: watchData } = useSWR<MediaWatchDataResponse>(
|
||||
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
|
||||
@@ -126,18 +130,36 @@ const ManageSlideOver = ({
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data.mediaInfo) {
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||
revalidate();
|
||||
onClose();
|
||||
try {
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||
revalidate();
|
||||
onClose();
|
||||
} catch {
|
||||
addToast(intl.formatMessage(messages.clearmediadataerror), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMediaFile = async (is4k = false) => {
|
||||
if (data.mediaInfo) {
|
||||
await axios.delete(
|
||||
`/api/v1/media/${data.mediaInfo.id}/file?is4k=${is4k}`
|
||||
);
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||
try {
|
||||
await axios.delete(
|
||||
`/api/v1/media/${data.mediaInfo.id}/file?is4k=${is4k}`
|
||||
);
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e) || e.response?.status !== 404) {
|
||||
addToast(intl.formatMessage(messages.removemediaerror), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
revalidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
revalidate();
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
||||
tvdbid: 'TheTVDB ID',
|
||||
unknowntitle: 'Unknown Title',
|
||||
removearr: 'Remove from {arr}',
|
||||
removemediaerror: 'Something went wrong while removing the media.',
|
||||
profileName: 'Profile',
|
||||
});
|
||||
|
||||
@@ -354,10 +355,21 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
|
||||
const deleteMediaFile = async () => {
|
||||
if (request.media) {
|
||||
await axios.delete(
|
||||
`/api/v1/media/${request.media.id}/file?is4k=${request.is4k}`
|
||||
);
|
||||
await axios.delete(`/api/v1/media/${request.media.id}`);
|
||||
try {
|
||||
await axios.delete(
|
||||
`/api/v1/media/${request.media.id}/file?is4k=${request.is4k}`
|
||||
);
|
||||
await axios.delete(`/api/v1/media/${request.media.id}`);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e) || e.response?.status !== 404) {
|
||||
addToast(intl.formatMessage(messages.removemediaerror), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
revalidateList();
|
||||
return;
|
||||
}
|
||||
}
|
||||
revalidateList();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -305,6 +305,7 @@
|
||||
"components.Login.validationservertyperequired": "Please select a server type",
|
||||
"components.Login.validationusernamerequired": "Username required",
|
||||
"components.ManageSlideOver.alltime": "All Time",
|
||||
"components.ManageSlideOver.clearmediadataerror": "Something went wrong while clearing the media data.",
|
||||
"components.ManageSlideOver.downloadstatus": "Downloads",
|
||||
"components.ManageSlideOver.manageModalAdvanced": "Advanced",
|
||||
"components.ManageSlideOver.manageModalClearMedia": "Clear Data",
|
||||
@@ -329,6 +330,7 @@
|
||||
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
|
||||
"components.ManageSlideOver.removearr": "Remove from {arr}",
|
||||
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
|
||||
"components.ManageSlideOver.removemediaerror": "Something went wrong while removing the media.",
|
||||
"components.ManageSlideOver.tvshow": "series",
|
||||
"components.MediaSlider.ShowMoreCard.seemore": "See More",
|
||||
"components.MetadataSelector.selectMetdataProvider": "Select a metadata provider",
|
||||
@@ -528,6 +530,7 @@
|
||||
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
|
||||
"components.RequestList.RequestItem.profileName": "Profile",
|
||||
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
|
||||
"components.RequestList.RequestItem.removemediaerror": "Something went wrong while removing the media.",
|
||||
"components.RequestList.RequestItem.requested": "Requested",
|
||||
"components.RequestList.RequestItem.requesteddate": "Requested",
|
||||
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
||||
|
||||
Reference in New Issue
Block a user