Compare commits
13 Commits
develop
...
fix/availa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44901b3e4f | ||
|
|
8d96c0ce53 | ||
|
|
1cb691f9cd | ||
|
|
82bf2aa4df | ||
|
|
5c3ea9b036 | ||
|
|
d5e3f1cd0b | ||
|
|
390000d6d4 | ||
|
|
ca01b60e5e | ||
|
|
af6c88b5e3 | ||
|
|
53c51d1837 | ||
|
|
16677a4cdf | ||
|
|
05ea56188b | ||
|
|
1cb31f43ea |
@@ -1053,7 +1053,7 @@ components:
|
||||
status:
|
||||
type: number
|
||||
example: 0
|
||||
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
|
||||
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED`
|
||||
requests:
|
||||
type: array
|
||||
readOnly: true
|
||||
@@ -4955,6 +4955,7 @@ paths:
|
||||
processing,
|
||||
unavailable,
|
||||
failed,
|
||||
deleted,
|
||||
]
|
||||
- in: query
|
||||
name: sort
|
||||
@@ -5694,7 +5695,16 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [all, available, partial, allavailable, processing, pending]
|
||||
enum:
|
||||
[
|
||||
all,
|
||||
available,
|
||||
partial,
|
||||
allavailable,
|
||||
processing,
|
||||
pending,
|
||||
deleted,
|
||||
]
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
@@ -5753,7 +5763,7 @@ paths:
|
||||
example: available
|
||||
schema:
|
||||
type: string
|
||||
enum: [available, partial, processing, pending, unknown]
|
||||
enum: [available, partial, processing, pending, unknown, deleted]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
||||
@@ -3,6 +3,7 @@ export enum MediaRequestStatus {
|
||||
APPROVED,
|
||||
DECLINED,
|
||||
FAILED,
|
||||
COMPLETED,
|
||||
}
|
||||
|
||||
export enum MediaType {
|
||||
@@ -16,4 +17,5 @@ export enum MediaStatus {
|
||||
PROCESSING,
|
||||
PARTIALLY_AVAILABLE,
|
||||
AVAILABLE,
|
||||
DELETED,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
@@ -8,6 +12,7 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import {
|
||||
AfterLoad,
|
||||
AfterUpdate,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@@ -309,6 +314,37 @@ class Media {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
public async updateRelatedMediaRequest(): Promise<void> {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
const relatedRequests = await requestRepository.find({
|
||||
relations: {
|
||||
media: true,
|
||||
},
|
||||
where: {
|
||||
media: { id: this.id },
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
},
|
||||
});
|
||||
|
||||
// Check the media entity status and if
|
||||
// available or deleted, set the related request
|
||||
// to completed
|
||||
if (relatedRequests.length > 0) {
|
||||
relatedRequests.forEach((request) => {
|
||||
if (
|
||||
this[request.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE ||
|
||||
this[request.is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
|
||||
) {
|
||||
request.status = MediaRequestStatus.COMPLETED;
|
||||
}
|
||||
});
|
||||
requestRepository.save(relatedRequests);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Media;
|
||||
|
||||
@@ -167,7 +167,8 @@ export class MediaRequest {
|
||||
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||
if (
|
||||
requestBody.mediaType === MediaType.MOVIE &&
|
||||
existing[0].status !== MediaRequestStatus.DECLINED
|
||||
existing[0].status !== MediaRequestStatus.DECLINED &&
|
||||
existing[0].status !== MediaRequestStatus.COMPLETED
|
||||
) {
|
||||
logger.warn('Duplicate request for media blocked', {
|
||||
tmdbId: tmdbMedia.id,
|
||||
@@ -260,7 +261,8 @@ export class MediaRequest {
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === requestBody.is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
)
|
||||
.reduce((seasons, request) => {
|
||||
const combinedSeasons = request.seasons.map(
|
||||
@@ -279,7 +281,9 @@ export class MediaRequest {
|
||||
.filter(
|
||||
(season) =>
|
||||
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN
|
||||
MediaStatus.UNKNOWN &&
|
||||
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.DELETED
|
||||
)
|
||||
.map((season) => season.seasonNumber),
|
||||
];
|
||||
@@ -583,7 +587,8 @@ export class MediaRequest {
|
||||
|
||||
if (
|
||||
media.mediaType === MediaType.MOVIE &&
|
||||
this.status === MediaRequestStatus.DECLINED
|
||||
this.status === MediaRequestStatus.DECLINED &&
|
||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||
) {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
@@ -601,7 +606,8 @@ export class MediaRequest {
|
||||
media.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
).length === 0 &&
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING &&
|
||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||
) {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||
import {
|
||||
AfterUpdate,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@@ -35,6 +38,37 @@ class Season {
|
||||
constructor(init?: Partial<Season>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
public async updateSeasonRequests(): Promise<void> {
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
|
||||
const relatedSeasonRequests = await seasonRequestRepository.find({
|
||||
relations: {
|
||||
request: true,
|
||||
},
|
||||
where: {
|
||||
request: { media: { id: (await this.media).id } },
|
||||
seasonNumber: this.seasonNumber,
|
||||
},
|
||||
});
|
||||
|
||||
// Check seasons when/if they become available or deleted,
|
||||
// then set the related season request to completed
|
||||
relatedSeasonRequests.forEach((seasonRequest) => {
|
||||
if (
|
||||
this.seasonNumber === seasonRequest.seasonNumber &&
|
||||
((!seasonRequest.request.is4k &&
|
||||
(this.status === MediaStatus.AVAILABLE ||
|
||||
this.status === MediaStatus.DELETED)) ||
|
||||
(seasonRequest.request.is4k &&
|
||||
this.status4k === MediaStatus.AVAILABLE) ||
|
||||
this.status4k === MediaStatus.DELETED)
|
||||
)
|
||||
seasonRequest.status = MediaRequestStatus.COMPLETED;
|
||||
});
|
||||
seasonRequestRepository.save(relatedSeasonRequests);
|
||||
}
|
||||
}
|
||||
|
||||
export default Season;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import {
|
||||
AfterRemove,
|
||||
AfterUpdate,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@@ -37,15 +37,25 @@ class SeasonRequest {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterRemove()
|
||||
public async handleRemoveParent(): Promise<void> {
|
||||
const mediaRequestRepository = getRepository(MediaRequest);
|
||||
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
||||
@AfterUpdate()
|
||||
public async updateMediaRequests(): Promise<void> {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
const relatedRequest = await requestRepository.findOne({
|
||||
where: { id: this.request.id },
|
||||
});
|
||||
|
||||
if (requestToBeDeleted.seasons.length === 0) {
|
||||
await mediaRequestRepository.delete({ id: this.request.id });
|
||||
// Check the parent of the season request and
|
||||
// if every season request is complete
|
||||
// set the parent request to complete as well
|
||||
const isRequestComplete = relatedRequest?.seasons.every(
|
||||
(seasonRequest) => seasonRequest.status === MediaRequestStatus.COMPLETED
|
||||
);
|
||||
|
||||
if (isRequestComplete && relatedRequest) {
|
||||
relatedRequest.status = MediaRequestStatus.COMPLETED;
|
||||
|
||||
requestRepository.save(relatedRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MediaRequest from '@server/entity/MediaRequest';
|
||||
import type Season from '@server/entity/Season';
|
||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
@@ -243,105 +242,66 @@ class AvailabilitySync {
|
||||
} while (mediaPage.length > 0);
|
||||
}
|
||||
|
||||
private findMediaStatus(
|
||||
requests: MediaRequest[],
|
||||
is4k: boolean
|
||||
): MediaStatus {
|
||||
const filteredRequests = requests.filter(
|
||||
(request) => request.is4k === is4k
|
||||
);
|
||||
|
||||
let mediaStatus: MediaStatus;
|
||||
|
||||
if (
|
||||
filteredRequests.some(
|
||||
(request) => request.status === MediaRequestStatus.APPROVED
|
||||
)
|
||||
) {
|
||||
mediaStatus = MediaStatus.PROCESSING;
|
||||
} else if (
|
||||
filteredRequests.some(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
)
|
||||
) {
|
||||
mediaStatus = MediaStatus.PENDING;
|
||||
} else {
|
||||
mediaStatus = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
return mediaStatus;
|
||||
}
|
||||
|
||||
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
try {
|
||||
// Find all related requests only if
|
||||
// the related media has an available status
|
||||
const requests = await requestRepository
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.where('(media.id = :id)', {
|
||||
id: media.id,
|
||||
})
|
||||
.andWhere(
|
||||
`(request.is4k = :is4k AND media.${
|
||||
is4k ? 'status4k' : 'status'
|
||||
} IN (:...mediaStatus))`,
|
||||
{
|
||||
mediaStatus: [
|
||||
MediaStatus.AVAILABLE,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
],
|
||||
is4k: is4k,
|
||||
}
|
||||
)
|
||||
.getMany();
|
||||
|
||||
// Check if a season is processing or pending to
|
||||
// make sure we set the media to the correct status
|
||||
let mediaStatus = MediaStatus.UNKNOWN;
|
||||
// If media type is tv, check if a season is processing
|
||||
//to see if we need to keep the external metadata
|
||||
let isMediaProcessing = false;
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
mediaStatus = this.findMediaStatus(requests, is4k);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
const request = await requestRepository
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.where('(media.id = :id)', {
|
||||
id: media.id,
|
||||
})
|
||||
.andWhere(
|
||||
'(request.is4k = :is4k AND request.status = :requestStatus)',
|
||||
{
|
||||
requestStatus: MediaRequestStatus.APPROVED,
|
||||
is4k: is4k,
|
||||
}
|
||||
)
|
||||
.getOne();
|
||||
|
||||
if (request) {
|
||||
isMediaProcessing = true;
|
||||
}
|
||||
}
|
||||
|
||||
media[is4k ? 'status4k' : 'status'] = mediaStatus;
|
||||
media[is4k ? 'serviceId4k' : 'serviceId'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'serviceId4k' : 'serviceId']
|
||||
: null;
|
||||
// Set the non-4K or 4K media to deleted
|
||||
// and change related columns to null if media
|
||||
// is not processing
|
||||
media[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
|
||||
media[is4k ? 'serviceId4k' : 'serviceId'] = isMediaProcessing
|
||||
? media[is4k ? 'serviceId4k' : 'serviceId']
|
||||
: null;
|
||||
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
isMediaProcessing
|
||||
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
||||
: null;
|
||||
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
isMediaProcessing
|
||||
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
||||
: null;
|
||||
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||
: null;
|
||||
media[is4k ? 'ratingKey4k' : 'ratingKey'] = isMediaProcessing
|
||||
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||
: null;
|
||||
|
||||
logger.info(
|
||||
`The ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'movie' ? 'movie' : 'show'
|
||||
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
||||
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
||||
} and Plex instance. Status will be changed to unknown.`,
|
||||
} and Plex instance. Status will be changed to deleted.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
|
||||
await mediaRepository.save({ media, ...media });
|
||||
|
||||
// Only delete media request if type is movie.
|
||||
// Type tv request deletion is handled
|
||||
// in the season request entity
|
||||
if (requests.length > 0 && media.mediaType === 'movie') {
|
||||
await requestRepository.remove(requests);
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.debug(
|
||||
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||
@@ -361,35 +321,21 @@ class AvailabilitySync {
|
||||
is4k: boolean
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
|
||||
// Filter out only the values that are false
|
||||
// (media that should be deleted)
|
||||
const seasonsPendingRemoval = new Map(
|
||||
// Disabled linter as only the value is needed from the filter
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[...seasons].filter(([_, exists]) => !exists)
|
||||
);
|
||||
// Retrieve the season keys to pass into our log
|
||||
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
||||
|
||||
try {
|
||||
// Need to check and see if there are any related season
|
||||
// requests. If they are, we will need to delete them.
|
||||
const seasonRequests = await seasonRequestRepository
|
||||
.createQueryBuilder('seasonRequest')
|
||||
.leftJoinAndSelect('seasonRequest.request', 'request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.where('(media.id = :id)', { id: media.id })
|
||||
.andWhere(
|
||||
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
|
||||
{
|
||||
seasonNumbers: seasonKeys,
|
||||
is4k: is4k,
|
||||
}
|
||||
)
|
||||
.getMany();
|
||||
|
||||
for (const mediaSeason of media.seasons) {
|
||||
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
||||
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,16 +357,12 @@ class AvailabilitySync {
|
||||
|
||||
await mediaRepository.save({ media, ...media });
|
||||
|
||||
if (seasonRequests.length > 0) {
|
||||
await seasonRequestRepository.remove(seasonRequests);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] was not found in any ${
|
||||
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
|
||||
} and Plex instance. Status will be changed to unknown.`,
|
||||
} and Plex instance. Status will be changed to deleted.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
} catch (ex) {
|
||||
@@ -445,41 +387,43 @@ class AvailabilitySync {
|
||||
// Check for availability in all of the available radarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.radarrServers) {
|
||||
const radarrAPI = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
if (server.is4k === is4k) {
|
||||
const radarrAPI = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
try {
|
||||
let radarr: RadarrMovie | undefined;
|
||||
try {
|
||||
let radarr: RadarrMovie | undefined;
|
||||
|
||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||
radarr = await radarrAPI.getMovie({
|
||||
id: media.externalServiceId,
|
||||
});
|
||||
}
|
||||
if (media.externalServiceId && !is4k) {
|
||||
radarr = await radarrAPI.getMovie({
|
||||
id: media.externalServiceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||
radarr = await radarrAPI.getMovie({
|
||||
id: media.externalServiceId4k,
|
||||
});
|
||||
}
|
||||
if (media.externalServiceId4k && is4k) {
|
||||
radarr = await radarrAPI.getMovie({
|
||||
id: media.externalServiceId4k,
|
||||
});
|
||||
}
|
||||
|
||||
if (radarr && radarr.hasFile) {
|
||||
existsInRadarr = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInRadarr = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] from Radarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
if (radarr && radarr.hasFile) {
|
||||
existsInRadarr = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInRadarr = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${
|
||||
is4k ? '4K' : 'non-4K'
|
||||
} movie [TMDB ID ${media.tmdbId}] from Radarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,42 +441,45 @@ class AvailabilitySync {
|
||||
// Check for availability in all of the available sonarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.sonarrServers) {
|
||||
const sonarrAPI = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
if (server.is4k === is4k) {
|
||||
const sonarrAPI = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
try {
|
||||
let sonarr: SonarrSeries | undefined;
|
||||
try {
|
||||
let sonarr: SonarrSeries | undefined;
|
||||
|
||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
||||
sonarr.seasons;
|
||||
}
|
||||
if (media.externalServiceId && !is4k) {
|
||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
||||
sonarr.seasons;
|
||||
}
|
||||
|
||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
||||
sonarr.seasons;
|
||||
}
|
||||
if (media.externalServiceId4k && is4k) {
|
||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
||||
this.sonarrSeasonsCache[
|
||||
`${server.id}-${media.externalServiceId4k}`
|
||||
] = sonarr.seasons;
|
||||
}
|
||||
|
||||
if (sonarr && sonarr.statistics.episodeFileCount > 0) {
|
||||
existsInSonarr = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInSonarr = true;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] from Sonarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
if (sonarr && sonarr.statistics.episodeFileCount > 0) {
|
||||
existsInSonarr = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInSonarr = true;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] from Sonarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -722,4 +669,5 @@ class AvailabilitySync {
|
||||
}
|
||||
|
||||
const availabilitySync = new AvailabilitySync();
|
||||
|
||||
export default availabilitySync;
|
||||
|
||||
@@ -281,7 +281,9 @@ class BaseScanner<T> {
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride && season.processing
|
||||
: !season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status;
|
||||
|
||||
@@ -294,7 +296,9 @@ class BaseScanner<T> {
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride && season.processing
|
||||
: season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status4k !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
@@ -401,13 +405,18 @@ class BaseScanner<T> {
|
||||
// the status
|
||||
const shouldStayAvailable =
|
||||
media.status === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
|
||||
.length === 0;
|
||||
newSeasons.filter(
|
||||
(season) =>
|
||||
season.status !== MediaStatus.UNKNOWN &&
|
||||
season.status !== MediaStatus.DELETED
|
||||
).length === 0;
|
||||
const shouldStayAvailable4k =
|
||||
media.status4k === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
|
||||
.length === 0;
|
||||
|
||||
newSeasons.filter(
|
||||
(season) =>
|
||||
season.status4k !== MediaStatus.UNKNOWN &&
|
||||
season.status4k !== MediaStatus.DELETED
|
||||
).length === 0;
|
||||
media.status =
|
||||
isAllStandardSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
@@ -417,11 +426,13 @@ class BaseScanner<T> {
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !seasons.length ||
|
||||
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k =
|
||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||
@@ -433,11 +444,13 @@ class BaseScanner<T> {
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !seasons.length ||
|
||||
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status4k === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${title}`);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import TautulliAPI from '@server/api/tautulli';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MediaRequest from '@server/entity/MediaRequest';
|
||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
MediaResultsResponse,
|
||||
@@ -98,6 +104,8 @@ mediaRoutes.post<
|
||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||
async (req, res, next) => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: Number(req.params.id) },
|
||||
@@ -138,6 +146,35 @@ mediaRoutes.post<
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
if (req.params.status === 'available') {
|
||||
// Here we check all related media requests and
|
||||
// then set to completed if the media is marked
|
||||
// as available
|
||||
const requests = await requestRepository.find({
|
||||
relations: {
|
||||
media: true,
|
||||
},
|
||||
where: { media: { id: media.id }, is4k: is4k },
|
||||
});
|
||||
|
||||
const requestIds = requests.map((request) => request.id);
|
||||
|
||||
if (requestIds.length > 0) {
|
||||
await requestRepository.update(
|
||||
{ id: In(requestIds) },
|
||||
{ status: MediaRequestStatus.COMPLETED }
|
||||
);
|
||||
}
|
||||
|
||||
requests
|
||||
.flatMap((request) => request.seasons)
|
||||
.forEach(async (season) => {
|
||||
await seasonRequestRepository.update(season.id, {
|
||||
status: MediaRequestStatus.COMPLETED,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
return res.status(200).json(media);
|
||||
|
||||
@@ -40,7 +40,6 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
switch (req.query.filter) {
|
||||
case 'approved':
|
||||
case 'processing':
|
||||
case 'available':
|
||||
statusFilter = [MediaRequestStatus.APPROVED];
|
||||
break;
|
||||
case 'pending':
|
||||
@@ -55,12 +54,18 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
case 'failed':
|
||||
statusFilter = [MediaRequestStatus.FAILED];
|
||||
break;
|
||||
case 'completed':
|
||||
case 'available':
|
||||
case 'deleted':
|
||||
statusFilter = [MediaRequestStatus.COMPLETED];
|
||||
break;
|
||||
default:
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
MediaRequestStatus.DECLINED,
|
||||
MediaRequestStatus.FAILED,
|
||||
MediaRequestStatus.COMPLETED,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -79,6 +84,9 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
];
|
||||
break;
|
||||
case 'deleted':
|
||||
mediaStatusFilter = [MediaStatus.DELETED];
|
||||
break;
|
||||
default:
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
@@ -86,6 +94,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
MediaStatus.AVAILABLE,
|
||||
MediaStatus.DELETED,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -391,7 +400,8 @@ requestRoutes.put<{ requestId: string }>(
|
||||
(r) =>
|
||||
r.is4k === request.is4k &&
|
||||
r.id !== request.id &&
|
||||
r.status !== MediaRequestStatus.DECLINED
|
||||
r.status !== MediaRequestStatus.DECLINED &&
|
||||
r.status !== MediaRequestStatus.COMPLETED
|
||||
)
|
||||
.reduce((seasons, r) => {
|
||||
const combinedSeasons = r.seasons.map(
|
||||
|
||||
128
server/subscriber/MediaRequestSubscriber.ts
Normal file
128
server/subscriber/MediaRequestSubscriber.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import logger from '@server/logger';
|
||||
import { truncate } from 'lodash';
|
||||
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
|
||||
import { EventSubscriber } from 'typeorm';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaRequestSubscriber
|
||||
implements EntitySubscriberInterface<MediaRequest>
|
||||
{
|
||||
private async notifyAvailableMovie(entity: MediaRequest) {
|
||||
if (entity.media.status === MediaStatus.AVAILABLE) {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const movie = await tmdb.getMovie({
|
||||
movieId: entity.media.tmdbId,
|
||||
});
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: entity.requestedBy,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(movie.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
media: entity.media,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: entity,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(entity: MediaRequest) {
|
||||
// Find all seasons in the related media entity
|
||||
// and see if they are available, then we can check
|
||||
// if the request contains the same seasons
|
||||
const isMediaAvailable = entity.media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
)
|
||||
.every((seasonRequest) =>
|
||||
entity.seasons.find(
|
||||
(season) => season.seasonNumber === seasonRequest.seasonNumber
|
||||
)
|
||||
);
|
||||
|
||||
if (entity.media.status === MediaStatus.AVAILABLE || isMediaAvailable) {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(tv.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: entity.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
media: entity.media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Requested Seasons',
|
||||
value: entity.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: entity,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public afterUpdate(event: UpdateEvent<MediaRequest>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||
this.notifyAvailableMovie(event.entity as MediaRequest);
|
||||
}
|
||||
if (event.entity.media.mediaType === MediaType.TV) {
|
||||
this.notifyAvailableSeries(event.entity as MediaRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public listenTo(): typeof MediaRequest {
|
||||
return MediaRequest;
|
||||
}
|
||||
}
|
||||
@@ -1,179 +1,12 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import Season from '@server/entity/Season';
|
||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import logger from '@server/logger';
|
||||
import { truncate } from 'lodash';
|
||||
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
|
||||
import { EventSubscriber, In, Not } from 'typeorm';
|
||||
import { EventSubscriber } from 'typeorm';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||
private async notifyAvailableMovie(
|
||||
entity: Media,
|
||||
dbEntity: Media,
|
||||
is4k: boolean
|
||||
) {
|
||||
if (
|
||||
entity[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||
dbEntity[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
if (entity.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: {
|
||||
media: {
|
||||
id: entity.id,
|
||||
},
|
||||
is4k,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(
|
||||
Notification.MEDIA_AVAILABLE,
|
||||
{
|
||||
event: `${is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: request.requestedBy,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date
|
||||
? ` (${movie.release_date.slice(0, 4)})`
|
||||
: ''
|
||||
}`,
|
||||
message: truncate(movie.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
media: entity,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request,
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(
|
||||
entity: Media,
|
||||
dbEntity: Media,
|
||||
is4k: boolean
|
||||
) {
|
||||
const seasonRepository = getRepository(Season);
|
||||
const newAvailableSeasons = entity.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
const oldSeasonIds = dbEntity.seasons.map((season) => season.id);
|
||||
const oldSeasons = await seasonRepository.findBy({ id: In(oldSeasonIds) });
|
||||
const oldAvailableSeasons = oldSeasons
|
||||
.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
const changedSeasons = newAvailableSeasons.filter(
|
||||
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
|
||||
);
|
||||
|
||||
if (changedSeasons.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const processedSeasons: number[] = [];
|
||||
|
||||
for (const changedSeasonNumber of changedSeasons) {
|
||||
const requests = await requestRepository.find({
|
||||
where: {
|
||||
media: {
|
||||
id: entity.id,
|
||||
},
|
||||
is4k,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
});
|
||||
const request = requests.find(
|
||||
(request) =>
|
||||
// Check if the season is complete AND it contains the current season that was just marked available
|
||||
request.seasons.every((season) =>
|
||||
newAvailableSeasons.includes(season.seasonNumber)
|
||||
) &&
|
||||
request.seasons.some(
|
||||
(season) => season.seasonNumber === changedSeasonNumber
|
||||
)
|
||||
);
|
||||
|
||||
if (request && !processedSeasons.includes(changedSeasonNumber)) {
|
||||
processedSeasons.push(
|
||||
...request.seasons.map((season) => season.seasonNumber)
|
||||
);
|
||||
|
||||
try {
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${is4k ? '4K ' : ''}Series Request Now Available`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(tv.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
media: entity,
|
||||
extra: [
|
||||
{
|
||||
name: 'Requested Seasons',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async updateChildRequestStatus(event: Media, is4k: boolean) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
@@ -197,52 +30,6 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status4k === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.TV &&
|
||||
(event.entity.status === MediaStatus.AVAILABLE ||
|
||||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.TV &&
|
||||
(event.entity.status4k === MediaStatus.AVAILABLE ||
|
||||
event.entity.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.status === MediaStatus.AVAILABLE &&
|
||||
event.databaseEntity.status === MediaStatus.PENDING
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import { CheckCircleIcon } from '@heroicons/react/20/solid';
|
||||
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
|
||||
import {
|
||||
BellIcon,
|
||||
ClockIcon,
|
||||
MinusSmallIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
|
||||
interface StatusBadgeMiniProps {
|
||||
@@ -50,6 +55,10 @@ const StatusBadgeMini = ({
|
||||
);
|
||||
indicatorIcon = <MinusSmallIcon />;
|
||||
break;
|
||||
case MediaStatus.DELETED:
|
||||
badgeStyle.push('bg-red-500 border-red-400 ring-red-400 text-red-100');
|
||||
indicatorIcon = <TrashIcon />;
|
||||
break;
|
||||
}
|
||||
|
||||
if (inProgress) {
|
||||
|
||||
@@ -204,6 +204,11 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
||||
{intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
)}
|
||||
{request.status === MediaRequestStatus.COMPLETED && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.completed)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
|
||||
|
||||
@@ -264,7 +264,9 @@ const RequestButton = ({
|
||||
|
||||
// Standard request button
|
||||
if (
|
||||
(!media || media.status === MediaStatus.UNKNOWN) &&
|
||||
(!media ||
|
||||
media.status === MediaStatus.UNKNOWN ||
|
||||
(media.status === MediaStatus.DELETED && !activeRequest)) &&
|
||||
hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
@@ -307,7 +309,9 @@ const RequestButton = ({
|
||||
|
||||
// 4K request button
|
||||
if (
|
||||
(!media || media.status4k === MediaStatus.UNKNOWN) &&
|
||||
(!media ||
|
||||
media.status4k === MediaStatus.UNKNOWN ||
|
||||
(media.status4k === MediaStatus.DELETED && !active4kRequest)) &&
|
||||
hasPermission(
|
||||
[
|
||||
Permission.REQUEST_4K,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TrashIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
@@ -411,6 +411,15 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
>
|
||||
{intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : requestData.status === MediaRequestStatus.PENDING &&
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.DELETED ? (
|
||||
<Badge
|
||||
badgeType="warning"
|
||||
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
|
||||
>
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
TrashIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
@@ -474,6 +474,15 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
>
|
||||
{intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : requestData.status === MediaRequestStatus.PENDING &&
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.DELETED ? (
|
||||
<Badge
|
||||
badgeType="warning"
|
||||
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
|
||||
>
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={
|
||||
|
||||
@@ -34,6 +34,7 @@ enum Filter {
|
||||
AVAILABLE = 'available',
|
||||
UNAVAILABLE = 'unavailable',
|
||||
FAILED = 'failed',
|
||||
DELETED = 'deleted',
|
||||
}
|
||||
|
||||
type Sort = 'added' | 'modified';
|
||||
@@ -177,6 +178,9 @@ const RequestList = () => {
|
||||
<option value="unavailable">
|
||||
{intl.formatMessage(globalMessages.unavailable)}
|
||||
</option>
|
||||
<option value="deleted">
|
||||
{intl.formatMessage(globalMessages.deleted)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||
|
||||
@@ -78,7 +78,8 @@ const CollectionRequestModal = ({
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
)
|
||||
.map((part) => part.id),
|
||||
];
|
||||
@@ -167,7 +168,9 @@ const CollectionRequestModal = ({
|
||||
|
||||
return (part?.mediaInfo?.requests ?? []).find(
|
||||
(request) =>
|
||||
request.is4k === is4k && request.status !== MediaRequestStatus.DECLINED
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
);
|
||||
};
|
||||
|
||||
@@ -342,7 +345,9 @@ const CollectionRequestModal = ({
|
||||
const partMedia =
|
||||
part.mediaInfo &&
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN
|
||||
MediaStatus.UNKNOWN &&
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.DELETED
|
||||
? part.mediaInfo
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -243,7 +243,8 @@ const TvRequestModal = ({
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
)
|
||||
.reduce((requestedSeasons, request) => {
|
||||
return [
|
||||
@@ -341,7 +342,8 @@ const TvRequestModal = ({
|
||||
(data.mediaInfo.requests || []).filter(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
).length > 0
|
||||
) {
|
||||
data.mediaInfo.requests
|
||||
@@ -349,7 +351,9 @@ const TvRequestModal = ({
|
||||
.forEach((request) => {
|
||||
if (!seasonRequest) {
|
||||
seasonRequest = request.seasons.find(
|
||||
(season) => season.seasonNumber === seasonNumber
|
||||
(season) =>
|
||||
season.seasonNumber === seasonNumber &&
|
||||
season.status !== MediaRequestStatus.COMPLETED
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -569,7 +573,9 @@ const TvRequestModal = ({
|
||||
(sn) =>
|
||||
sn.seasonNumber === season.seasonNumber &&
|
||||
sn[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN
|
||||
MediaStatus.UNKNOWN &&
|
||||
sn[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.DELETED
|
||||
);
|
||||
return (
|
||||
<tr key={`season-${season.id}`}>
|
||||
|
||||
@@ -299,6 +299,17 @@ const StatusBadge = ({
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case MediaStatus.DELETED:
|
||||
return (
|
||||
<Tooltip content={mediaLinkDescription}>
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
|
||||
status: intl.formatMessage(globalMessages.deleted),
|
||||
})}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -207,7 +207,9 @@ const TitleCard = ({
|
||||
<div
|
||||
className={`px-2 text-white ${
|
||||
!showRequestButton ||
|
||||
(currentStatus && currentStatus !== MediaStatus.UNKNOWN)
|
||||
(currentStatus &&
|
||||
currentStatus !== MediaStatus.UNKNOWN &&
|
||||
currentStatus !== MediaStatus.DELETED)
|
||||
? 'pb-2'
|
||||
: 'pb-11'
|
||||
}`}
|
||||
@@ -235,7 +237,8 @@ const TitleCard = ({
|
||||
WebkitLineClamp:
|
||||
!showRequestButton ||
|
||||
(currentStatus &&
|
||||
currentStatus !== MediaStatus.UNKNOWN)
|
||||
currentStatus !== MediaStatus.UNKNOWN &&
|
||||
currentStatus !== MediaStatus.DELETED)
|
||||
? 5
|
||||
: 3,
|
||||
display: '-webkit-box',
|
||||
@@ -253,7 +256,9 @@ const TitleCard = ({
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
|
||||
{showRequestButton &&
|
||||
(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && (
|
||||
(!currentStatus ||
|
||||
currentStatus === MediaStatus.UNKNOWN ||
|
||||
currentStatus === MediaStatus.DELETED) && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
|
||||
@@ -234,7 +234,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
)
|
||||
.reduce((requestedSeasons, request) => {
|
||||
return [
|
||||
@@ -647,6 +648,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mSeason?.status === MediaStatus.DELETED && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(globalMessages.deleted)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini status={MediaStatus.DELETED} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{((!mSeason4k &&
|
||||
request4k?.status ===
|
||||
MediaRequestStatus.APPROVED) ||
|
||||
@@ -733,6 +746,26 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mSeason4k?.status4k === MediaStatus.DELETED &&
|
||||
show4k && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(messages.status4k, {
|
||||
status: intl.formatMessage(
|
||||
globalMessages.deleted
|
||||
),
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.DELETED}
|
||||
is4k={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={`${
|
||||
open ? 'rotate-180' : ''
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineMessages } from 'react-intl';
|
||||
const globalMessages = defineMessages({
|
||||
available: 'Available',
|
||||
partiallyavailable: 'Partially Available',
|
||||
deleted: 'Deleted',
|
||||
processing: 'Processing',
|
||||
unavailable: 'Unavailable',
|
||||
notrequested: 'Not Requested',
|
||||
@@ -14,6 +15,7 @@ const globalMessages = defineMessages({
|
||||
pending: 'Pending',
|
||||
declined: 'Declined',
|
||||
approved: 'Approved',
|
||||
completed: 'Completed',
|
||||
movie: 'Movie',
|
||||
movies: 'Movies',
|
||||
collection: 'Collection',
|
||||
|
||||
@@ -1190,9 +1190,11 @@
|
||||
"i18n.canceling": "Canceling…",
|
||||
"i18n.close": "Close",
|
||||
"i18n.collection": "Collection",
|
||||
"i18n.completed": "Completed",
|
||||
"i18n.decline": "Decline",
|
||||
"i18n.declined": "Declined",
|
||||
"i18n.delete": "Delete",
|
||||
"i18n.deleted": "Deleted",
|
||||
"i18n.deleting": "Deleting…",
|
||||
"i18n.delimitedlist": "{a}, {b}",
|
||||
"i18n.edit": "Edit",
|
||||
|
||||
Reference in New Issue
Block a user