feat: Radarr & Sonarr Sync (#734)
parent
86efcd82c3
commit
ec5fb83678
@ -0,0 +1,248 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import RadarrAPI, { RadarrMovie } from '../../api/radarr';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import Media from '../../entity/Media';
|
||||
import { getSettings, RadarrSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
|
||||
const BUNDLE_SIZE = 50;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
currentServer: RadarrSettings;
|
||||
servers: RadarrSettings[];
|
||||
}
|
||||
|
||||
class JobRadarrSync {
|
||||
private running = false;
|
||||
private progress = 0;
|
||||
private enable4k = false;
|
||||
private sessionId: string;
|
||||
private servers: RadarrSettings[];
|
||||
private currentServer: RadarrSettings;
|
||||
private radarrApi: RadarrAPI;
|
||||
private items: RadarrMovie[] = [];
|
||||
|
||||
public async run() {
|
||||
const settings = getSettings();
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
this.log('Radarr sync starting', 'info', { sessionId });
|
||||
|
||||
try {
|
||||
this.running = true;
|
||||
|
||||
// Remove any duplicate Radarr servers and assign them to the servers field
|
||||
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
|
||||
return (
|
||||
radarrA.hostname === radarrB.hostname &&
|
||||
radarrA.port === radarrB.port &&
|
||||
radarrA.baseUrl === radarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
this.enable4k = settings.radarr.some((radarr) => radarr.is4k);
|
||||
if (this.enable4k) {
|
||||
this.log(
|
||||
'At least one 4K Radarr server was detected. 4K movie detection is now enabled.',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Radarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.radarrApi = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
this.items = await this.radarrApi.getMovies();
|
||||
|
||||
await this.loop({ sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Radarr sync complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Something went wrong.', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
// If a new scanning session hasnt started, set running back to false
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
private async processRadarrMovie(radarrMovie: RadarrMovie) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const server4k = this.enable4k && this.currentServer.is4k;
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: radarrMovie.tmdbId },
|
||||
});
|
||||
|
||||
if (media) {
|
||||
let isChanged = false;
|
||||
if (media.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Movie already available: ${radarrMovie.title}`);
|
||||
} else {
|
||||
media[server4k ? 'status4k' : 'status'] = radarrMovie.downloaded
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.PROCESSING;
|
||||
this.log(
|
||||
`Updated existing ${server4k ? '4K ' : ''}movie ${
|
||||
radarrMovie.title
|
||||
} to status ${MediaStatus[media[server4k ? 'status4k' : 'status']]}`
|
||||
);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
|
||||
) {
|
||||
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
|
||||
this.log(`Updated service ID for media entity: ${radarrMovie.title}`);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
||||
radarrMovie.id
|
||||
) {
|
||||
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
radarrMovie.id;
|
||||
this.log(
|
||||
`Updated external service ID for media entity: ${radarrMovie.title}`
|
||||
);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
||||
radarrMovie.titleSlug
|
||||
) {
|
||||
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
radarrMovie.titleSlug;
|
||||
this.log(
|
||||
`Updated external service slug for media entity: ${radarrMovie.title}`
|
||||
);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (isChanged) {
|
||||
await mediaRepository.save(media);
|
||||
}
|
||||
} else {
|
||||
const newMedia = new Media({
|
||||
tmdbId: radarrMovie.tmdbId,
|
||||
imdbId: radarrMovie.imdbId,
|
||||
mediaType: MediaType.MOVIE,
|
||||
serviceId: !server4k ? this.currentServer.id : undefined,
|
||||
serviceId4k: server4k ? this.currentServer.id : undefined,
|
||||
externalServiceId: !server4k ? radarrMovie.id : undefined,
|
||||
externalServiceId4k: server4k ? radarrMovie.id : undefined,
|
||||
status:
|
||||
!server4k && radarrMovie.downloaded
|
||||
? MediaStatus.AVAILABLE
|
||||
: !server4k
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
server4k && radarrMovie.downloaded
|
||||
? MediaStatus.AVAILABLE
|
||||
: server4k
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
|
||||
this.log(
|
||||
`Added media for movie ${radarrMovie.title} and set status to ${
|
||||
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
|
||||
}`
|
||||
);
|
||||
await mediaRepository.save(newMedia);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(items: RadarrMovie[]) {
|
||||
await Promise.all(
|
||||
items.map(async (radarrMovie) => {
|
||||
await this.processRadarrMovie(radarrMovie);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async loop({
|
||||
start = 0,
|
||||
end = BUNDLE_SIZE,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}) {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
if (start < this.items.length) {
|
||||
this.progress = start;
|
||||
await this.processItems(slicedItems);
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Radarr Sync', ...optional });
|
||||
}
|
||||
}
|
||||
|
||||
export const jobRadarrSync = new JobRadarrSync();
|
@ -0,0 +1,358 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import SonarrAPI, { SonarrSeries } from '../../api/sonarr';
|
||||
import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import Media from '../../entity/Media';
|
||||
import Season from '../../entity/Season';
|
||||
import { getSettings, SonarrSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
|
||||
const BUNDLE_SIZE = 50;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
currentServer: SonarrSettings;
|
||||
servers: SonarrSettings[];
|
||||
}
|
||||
|
||||
class JobSonarrSync {
|
||||
private running = false;
|
||||
private progress = 0;
|
||||
private enable4k = false;
|
||||
private sessionId: string;
|
||||
private servers: SonarrSettings[];
|
||||
private currentServer: SonarrSettings;
|
||||
private sonarrApi: SonarrAPI;
|
||||
private items: SonarrSeries[] = [];
|
||||
|
||||
public async run() {
|
||||
const settings = getSettings();
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
this.log('Sonarr sync starting', 'info', { sessionId });
|
||||
|
||||
try {
|
||||
this.running = true;
|
||||
|
||||
// Remove any duplicate Sonarr servers and assign them to the servers field
|
||||
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
|
||||
return (
|
||||
sonarrA.hostname === sonarrB.hostname &&
|
||||
sonarrA.port === sonarrB.port &&
|
||||
sonarrA.baseUrl === sonarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
this.enable4k = settings.sonarr.some((sonarr) => sonarr.is4k);
|
||||
if (this.enable4k) {
|
||||
this.log(
|
||||
'At least one 4K Sonarr server was detected. 4K movie detection is now enabled.',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Sonarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.sonarrApi = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
this.items = await this.sonarrApi.getSeries();
|
||||
|
||||
await this.loop({ sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Sonarr sync complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Something went wrong.', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
// If a new scanning session hasnt started, set running back to false
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const server4k = this.enable4k && this.currentServer.is4k;
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tvdbId: sonarrSeries.tvdbId },
|
||||
});
|
||||
|
||||
const currentSeasonsAvailable = (media?.seasons ?? []).filter(
|
||||
(season) =>
|
||||
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
).length;
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
for (const season of sonarrSeries.seasons) {
|
||||
const existingSeason = media?.seasons.find(
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We are already tracking this season so we can work on it directly
|
||||
if (existingSeason) {
|
||||
if (
|
||||
existingSeason[server4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.AVAILABLE &&
|
||||
season.statistics
|
||||
) {
|
||||
existingSeason[server4k ? 'status4k' : 'status'] =
|
||||
season.statistics.episodeFileCount ===
|
||||
season.statistics.totalEpisodeCount
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.statistics.episodeFileCount > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.monitored
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason[server4k ? 'status4k' : 'status'];
|
||||
}
|
||||
} else {
|
||||
if (season.statistics && season.seasonNumber !== 0) {
|
||||
const allEpisodes =
|
||||
season.statistics.episodeFileCount ===
|
||||
season.statistics.totalEpisodeCount;
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.seasonNumber,
|
||||
status:
|
||||
!server4k && allEpisodes
|
||||
? MediaStatus.AVAILABLE
|
||||
: !server4k && season.statistics.episodeFileCount > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !server4k && season.monitored
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
server4k && allEpisodes
|
||||
? MediaStatus.AVAILABLE
|
||||
: server4k && season.statistics.episodeFileCount > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !server4k && season.monitored
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||
(s) => s.seasonNumber !== 0
|
||||
);
|
||||
|
||||
const isAllSeasons =
|
||||
(media?.seasons ?? []).filter(
|
||||
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
).length +
|
||||
newSeasons.filter(
|
||||
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
).length >=
|
||||
filteredSeasons.length;
|
||||
|
||||
if (media) {
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
const newSeasonsAvailable = (media?.seasons ?? []).filter(
|
||||
(season) =>
|
||||
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
).length;
|
||||
|
||||
if (newSeasonsAvailable > currentSeasonsAvailable) {
|
||||
this.log(
|
||||
`Detected ${newSeasonsAvailable - currentSeasonsAvailable} new ${
|
||||
server4k ? '4K ' : ''
|
||||
}season(s) for ${sonarrSeries.title}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
|
||||
) {
|
||||
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
|
||||
this.log(`Updated service ID for media entity: ${sonarrSeries.title}`);
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
||||
sonarrSeries.id
|
||||
) {
|
||||
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
sonarrSeries.id;
|
||||
this.log(
|
||||
`Updated external service ID for media entity: ${sonarrSeries.title}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
||||
sonarrSeries.titleSlug
|
||||
) {
|
||||
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
sonarrSeries.titleSlug;
|
||||
this.log(
|
||||
`Updated external service slug for media entity: ${sonarrSeries.title}`
|
||||
);
|
||||
}
|
||||
|
||||
media[server4k ? 'status4k' : 'status'] = isAllSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some((season) => season.status !== MediaStatus.UNKNOWN)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
|
||||
await mediaRepository.save(media);
|
||||
} else {
|
||||
const tmdb = new TheMovieDb();
|
||||
let tvShow: TmdbTvDetails;
|
||||
|
||||
try {
|
||||
tvShow = await tmdb.getShowByTvdbId({
|
||||
tvdbId: sonarrSeries.tvdbId,
|
||||
});
|
||||
} catch (e) {
|
||||
this.log(
|
||||
'Failed to create new media item during sync. TVDB ID is missing from TMDB?',
|
||||
'warn',
|
||||
{ sonarrSeries, errorMessage: e.message }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newMedia = new Media({
|
||||
tmdbId: tvShow.id,
|
||||
tvdbId: sonarrSeries.tvdbId,
|
||||
mediaType: MediaType.TV,
|
||||
serviceId: !server4k ? this.currentServer.id : undefined,
|
||||
serviceId4k: server4k ? this.currentServer.id : undefined,
|
||||
externalServiceId: !server4k ? sonarrSeries.id : undefined,
|
||||
externalServiceId4k: server4k ? sonarrSeries.id : undefined,
|
||||
externalServiceSlug: !server4k ? sonarrSeries.titleSlug : undefined,
|
||||
externalServiceSlug4k: server4k ? sonarrSeries.titleSlug : undefined,
|
||||
seasons: newSeasons,
|
||||
status:
|
||||
!server4k && isAllSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: !server4k &&
|
||||
newSeasons.some(
|
||||
(s) =>
|
||||
s.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
s.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !server4k
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
server4k && isAllSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: server4k &&
|
||||
newSeasons.some(
|
||||
(s) =>
|
||||
s.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
s.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: server4k
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
|
||||
this.log(
|
||||
`Added media for series ${sonarrSeries.title} and set status to ${
|
||||
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
|
||||
}`
|
||||
);
|
||||
await mediaRepository.save(newMedia);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(items: SonarrSeries[]) {
|
||||
await Promise.all(
|
||||
items.map(async (sonarrSeries) => {
|
||||
await this.processSonarrSeries(sonarrSeries);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async loop({
|
||||
start = 0,
|
||||
end = BUNDLE_SIZE,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}) {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
if (start < this.items.length) {
|
||||
this.progress = start;
|
||||
await this.processItems(slicedItems);
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Sonarr Sync', ...optional });
|
||||
}
|
||||
}
|
||||
|
||||
export const jobSonarrSync = new JobSonarrSync();
|
@ -0,0 +1,195 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import SonarrAPI from '../api/sonarr';
|
||||
import { MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import { getSettings } from './settings';
|
||||
|
||||
export interface DownloadingItem {
|
||||
mediaType: MediaType;
|
||||
externalId: number;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
status: string;
|
||||
timeLeft: string;
|
||||
estimatedCompletionTime: Date;
|
||||
title: string;
|
||||
}
|
||||
|
||||
class DownloadTracker {
|
||||
private radarrServers: Record<number, DownloadingItem[]> = {};
|
||||
private sonarrServers: Record<number, DownloadingItem[]> = {};
|
||||
|
||||
public getMovieProgress(
|
||||
serverId: number,
|
||||
externalServiceId: number
|
||||
): DownloadingItem[] {
|
||||
if (!this.radarrServers[serverId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.radarrServers[serverId].filter(
|
||||
(item) => item.externalId === externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
public getSeriesProgress(
|
||||
serverId: number,
|
||||
externalServiceId: number
|
||||
): DownloadingItem[] {
|
||||
if (!this.sonarrServers[serverId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.sonarrServers[serverId].filter(
|
||||
(item) => item.externalId === externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
public async resetDownloadTracker() {
|
||||
this.radarrServers = {};
|
||||
}
|
||||
|
||||
public updateDownloads() {
|
||||
this.updateRadarrDownloads();
|
||||
this.updateSonarrDownloads();
|
||||
}
|
||||
|
||||
private async updateRadarrDownloads() {
|
||||
const settings = getSettings();
|
||||
|
||||
// Remove duplicate servers
|
||||
const filteredServers = uniqWith(settings.radarr, (radarrA, radarrB) => {
|
||||
return (
|
||||
radarrA.hostname === radarrB.hostname &&
|
||||
radarrA.port === radarrB.port &&
|
||||
radarrA.baseUrl === radarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
// Load downloads from Radarr servers
|
||||
Promise.all(
|
||||
filteredServers.map(async (server) => {
|
||||
if (server.syncEnabled) {
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const queueItems = await radarr.getQueue();
|
||||
|
||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.movieId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.MOVIE,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate this data to matching servers
|
||||
const matchingServers = settings.radarr.filter(
|
||||
(rs) =>
|
||||
rs.hostname === server.hostname &&
|
||||
rs.port === server.port &&
|
||||
rs.baseUrl === server.baseUrl &&
|
||||
rs.id !== server.id
|
||||
);
|
||||
|
||||
if (matchingServers.length > 0) {
|
||||
logger.debug(
|
||||
`Matching download data to ${matchingServers.length} other Radarr server(s)`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
matchingServers.forEach((ms) => {
|
||||
if (ms.syncEnabled) {
|
||||
this.radarrServers[ms.id] = this.radarrServers[server.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async updateSonarrDownloads() {
|
||||
const settings = getSettings();
|
||||
|
||||
// Remove duplicate servers
|
||||
const filteredServers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
|
||||
return (
|
||||
sonarrA.hostname === sonarrB.hostname &&
|
||||
sonarrA.port === sonarrB.port &&
|
||||
sonarrA.baseUrl === sonarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
// Load downloads from Radarr servers
|
||||
Promise.all(
|
||||
filteredServers.map(async (server) => {
|
||||
if (server.syncEnabled) {
|
||||
const radarr = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const queueItems = await radarr.getQueue();
|
||||
|
||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.seriesId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.TV,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate this data to matching servers
|
||||
const matchingServers = settings.sonarr.filter(
|
||||
(rs) =>
|
||||
rs.hostname === server.hostname &&
|
||||
rs.port === server.port &&
|
||||
rs.baseUrl === server.baseUrl &&
|
||||
rs.id !== server.id
|
||||
);
|
||||
|
||||
if (matchingServers.length > 0) {
|
||||
logger.debug(
|
||||
`Matching download data to ${matchingServers.length} other Sonarr server(s)`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
matchingServers.forEach((ms) => {
|
||||
if (ms.syncEnabled) {
|
||||
this.sonarrServers[ms.id] = this.sonarrServers[server.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTracker = new DownloadTracker();
|
||||
|
||||
export default downloadTracker;
|
@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SonarrRadarrSyncServiceFields1611757511674
|
||||
implements MigrationInterface {
|
||||
name = 'SonarrRadarrSyncServiceFields1611757511674';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,149 @@
|
||||
import { Router } from 'express';
|
||||
import RadarrAPI from '../../api/radarr';
|
||||
import { getSettings, RadarrSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
|
||||
const radarrRoutes = Router();
|
||||
|
||||
radarrRoutes.get('/', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.radarr);
|
||||
});
|
||||
|
||||
radarrRoutes.post('/', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const newRadarr = req.body as RadarrSettings;
|
||||
const lastItem = settings.radarr[settings.radarr.length - 1];
|
||||
newRadarr.id = lastItem ? lastItem.id + 1 : 0;
|
||||
|
||||
// If we are setting this as the default, clear any previous defaults for the same type first
|
||||
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
|
||||
// and are the default
|
||||
if (req.body.isDefault) {
|
||||
settings.radarr
|
||||
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
|
||||
.forEach((radarrInstance) => {
|
||||
radarrInstance.isDefault = false;
|
||||
});
|
||||
}
|
||||
|
||||
settings.radarr = [...settings.radarr, newRadarr];
|
||||
settings.save();
|
||||
|
||||
return res.status(201).json(newRadarr);
|
||||
});
|
||||
|
||||
radarrRoutes.post('/test', async (req, res, next) => {
|
||||
try {
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: req.body.apiKey,
|
||||
url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${
|
||||
req.body.port
|
||||
}${req.body.baseUrl ?? ''}/api`,
|
||||
});
|
||||
|
||||
const profiles = await radarr.getProfiles();
|
||||
const folders = await radarr.getRootFolders();
|
||||
|
||||
return res.status(200).json({
|
||||
profiles,
|
||||
rootFolders: folders.map((folder) => ({
|
||||
id: folder.id,
|
||||
path: folder.path,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to test Radarr', {
|
||||
label: 'Radarr',
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
next({ status: 500, message: 'Failed to connect to Radarr' });
|
||||
}
|
||||
});
|
||||
|
||||
radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const radarrIndex = settings.radarr.findIndex(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
);
|
||||
|
||||
if (radarrIndex === -1) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
// If we are setting this as the default, clear any previous defaults for the same type first
|
||||
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
|
||||
// and are the default
|
||||
if (req.body.isDefault) {
|
||||
settings.radarr
|
||||
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
|
||||
.forEach((radarrInstance) => {
|
||||
radarrInstance.isDefault = false;
|
||||
});
|
||||
}
|
||||
|
||||
settings.radarr[radarrIndex] = {
|
||||
...req.body,
|
||||
id: Number(req.params.id),
|
||||
} as RadarrSettings;
|
||||
settings.save();
|
||||
|
||||
return res.status(200).json(settings.radarr[radarrIndex]);
|
||||
});
|
||||
|
||||
radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const radarrSettings = settings.radarr.find(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
);
|
||||
|
||||
if (!radarrSettings) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
radarrSettings.hostname
|
||||
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
|
||||
const profiles = await radarr.getProfiles();
|
||||
|
||||
return res.status(200).json(
|
||||
profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const radarrIndex = settings.radarr.findIndex(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
);
|
||||
|
||||
if (radarrIndex === -1) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
const removed = settings.radarr.splice(radarrIndex, 1);
|
||||
settings.save();
|
||||
|
||||
return res.status(200).json(removed[0]);
|
||||
});
|
||||
|
||||
export default radarrRoutes;
|
@ -0,0 +1,119 @@
|
||||
import { Router } from 'express';
|
||||
import SonarrAPI from '../../api/sonarr';
|
||||
import { getSettings, SonarrSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
|
||||
const sonarrRoutes = Router();
|
||||
|
||||
sonarrRoutes.get('/', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.sonarr);
|
||||
});
|
||||
|
||||
sonarrRoutes.post('/', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const newSonarr = req.body as SonarrSettings;
|
||||
const lastItem = settings.sonarr[settings.sonarr.length - 1];
|
||||
newSonarr.id = lastItem ? lastItem.id + 1 : 0;
|
||||
|
||||
// If we are setting this as the default, clear any previous defaults for the same type first
|
||||
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
|
||||
// and are the default
|
||||
if (req.body.isDefault) {
|
||||
settings.sonarr
|
||||
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
|
||||
.forEach((sonarrInstance) => {
|
||||
sonarrInstance.isDefault = false;
|
||||
});
|
||||
}
|
||||
|
||||
settings.sonarr = [...settings.sonarr, newSonarr];
|
||||
settings.save();
|
||||
|
||||
return res.status(201).json(newSonarr);
|
||||
});
|
||||
|
||||
sonarrRoutes.post('/test', async (req, res, next) => {
|
||||
try {
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: req.body.apiKey,
|
||||
url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${
|
||||
req.body.port
|
||||
}${req.body.baseUrl ?? ''}/api`,
|
||||
});
|
||||
|
||||
const profiles = await sonarr.getProfiles();
|
||||
const folders = await sonarr.getRootFolders();
|
||||
|
||||
return res.status(200).json({
|
||||
profiles,
|
||||
rootFolders: folders.map((folder) => ({
|
||||
id: folder.id,
|
||||
path: folder.path,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to test Sonarr', {
|
||||
label: 'Sonarr',
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
next({ status: 500, message: 'Failed to connect to Sonarr' });
|
||||
}
|
||||
});
|
||||
|
||||
sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const sonarrIndex = settings.sonarr.findIndex(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
);
|
||||
|
||||
if (sonarrIndex === -1) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
// If we are setting this as the default, clear any previous defaults for the same type first
|
||||
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
|
||||
// and are the default
|
||||
if (req.body.isDefault) {
|
||||
settings.sonarr
|
||||
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
|
||||
.forEach((sonarrInstance) => {
|
||||
sonarrInstance.isDefault = false;
|
||||
});
|
||||
}
|
||||
|
||||
settings.sonarr[sonarrIndex] = {
|
||||
...req.body,
|
||||
id: Number(req.params.id),
|
||||
} as SonarrSettings;
|
||||
settings.save();
|
||||
|
||||
return res.status(200).json(settings.sonarr[sonarrIndex]);
|
||||
});
|
||||
|
||||
sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const sonarrIndex = settings.sonarr.findIndex(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
);
|
||||
|
||||
if (sonarrIndex === -1) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
const removed = settings.sonarr.splice(sonarrIndex, 1);
|
||||
settings.save();
|
||||
|
||||
return res.status(200).json(removed[0]);
|
||||
});
|
||||
|
||||
export default sonarrRoutes;
|
After Width: | Height: | Size: 524 B |
@ -0,0 +1,57 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import Button from '../Button';
|
||||
|
||||
interface ConfirmButtonProps {
|
||||
onClick: () => void;
|
||||
confirmText: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ConfirmButton: React.FC<ConfirmButtonProps> = ({
|
||||
onClick,
|
||||
children,
|
||||
confirmText,
|
||||
className,
|
||||
}) => {
|
||||
const ref = useRef(null);
|
||||
useClickOutside(ref, () => setIsClicked(false));
|
||||
const [isClicked, setIsClicked] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
buttonType="danger"
|
||||
className={`relative overflow-hidden ${className}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isClicked) {
|
||||
setIsClicked(true);
|
||||
} else {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute flex items-center justify-center inset-0 w-full h-full duration-300 transition transform-gpu ${
|
||||
isClicked
|
||||
? '-translate-y-full opacity-0'
|
||||
: 'translate-y-0 opacity-100'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute flex items-center justify-center inset-0 w-full h-full duration-300 transition transform-gpu ${
|
||||
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
|
||||
}`}
|
||||
>
|
||||
{confirmText}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmButton;
|
@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { FormattedRelativeTime } from 'react-intl';
|
||||
import { DownloadingItem } from '../../../server/lib/downloadtracker';
|
||||
import Badge from '../Common/Badge';
|
||||
|
||||
interface DownloadBlockProps {
|
||||
downloadItem: DownloadingItem;
|
||||
}
|
||||
|
||||
const DownloadBlock: React.FC<DownloadBlockProps> = ({ downloadItem }) => {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="w-56 mb-2 text-sm truncate sm:w-80 md:w-full">
|
||||
{downloadItem.title}
|
||||
</div>
|
||||
<div className="relative h-6 min-w-0 mb-2 overflow-hidden bg-gray-700 rounded-full">
|
||||
<div
|
||||
className="h-8 transition-all duration-200 ease-in-out bg-indigo-600"
|
||||
style={{
|
||||
width: `${Math.round(
|
||||
((downloadItem.size - downloadItem.sizeLeft) /
|
||||
downloadItem.size) *
|
||||
100
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center w-full h-6 text-xs">
|
||||
<span>
|
||||
{Math.round(
|
||||
((downloadItem.size - downloadItem.sizeLeft) /
|
||||
downloadItem.size) *
|
||||
100
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<Badge className="capitalize">{downloadItem.status}</Badge>
|
||||
<span>
|
||||
ETA{' '}
|
||||
{downloadItem.estimatedCompletionTime ? (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(downloadItem.estimatedCompletionTime).getTime() -
|
||||
Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
/>
|
||||
) : (
|
||||
'N/A'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadBlock;
|
Loading…
Reference in new issue