chore: prepare 2.7.3 release
This commit is contained in:
@@ -141,14 +141,83 @@ components:
|
|||||||
UserSettings:
|
UserSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: 'Mr User'
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
example: 'user@example.com'
|
||||||
|
discordId:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: '123456789'
|
||||||
locale:
|
locale:
|
||||||
type: string
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: 'en'
|
||||||
discoverRegion:
|
discoverRegion:
|
||||||
type: string
|
type: string
|
||||||
originalLanguage:
|
nullable: true
|
||||||
type: string
|
example: 'US'
|
||||||
streamingRegion:
|
streamingRegion:
|
||||||
type: string
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: 'US'
|
||||||
|
originalLanguage:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: 'en'
|
||||||
|
movieQuotaLimit:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 'Maximum number of movie requests allowed'
|
||||||
|
example: 10
|
||||||
|
movieQuotaDays:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 'Time period in days for movie quota'
|
||||||
|
example: 30
|
||||||
|
tvQuotaLimit:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 'Maximum number of TV requests allowed'
|
||||||
|
example: 5
|
||||||
|
tvQuotaDays:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 'Time period in days for TV quota'
|
||||||
|
example: 14
|
||||||
|
globalMovieQuotaDays:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 'Global movie quota days setting'
|
||||||
|
example: 30
|
||||||
|
globalMovieQuotaLimit:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 'Global movie quota limit setting'
|
||||||
|
example: 10
|
||||||
|
globalTvQuotaLimit:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 'Global TV quota limit setting'
|
||||||
|
example: 5
|
||||||
|
globalTvQuotaDays:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 'Global TV quota days setting'
|
||||||
|
example: 14
|
||||||
|
watchlistSyncMovies:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
description: 'Enable watchlist sync for movies'
|
||||||
|
example: true
|
||||||
|
watchlistSyncTv:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
description: 'Enable watchlist sync for TV'
|
||||||
|
example: false
|
||||||
MainSettings:
|
MainSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -4469,11 +4538,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
$ref: '#/components/schemas/UserSettings'
|
||||||
properties:
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
example: 'Mr User'
|
|
||||||
post:
|
post:
|
||||||
summary: Update general settings for a user
|
summary: Update general settings for a user
|
||||||
description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||||
@@ -4490,22 +4555,14 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
$ref: '#/components/schemas/UserSettings'
|
||||||
properties:
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Updated user general settings returned
|
description: Updated user general settings returned
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
$ref: '#/components/schemas/UserSettings'
|
||||||
properties:
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
example: 'Mr User'
|
|
||||||
/user/{userId}/settings/password:
|
/user/{userId}/settings/password:
|
||||||
get:
|
get:
|
||||||
summary: Get password page informatiom
|
summary: Get password page informatiom
|
||||||
@@ -6599,9 +6656,16 @@ paths:
|
|||||||
example: '1'
|
example: '1'
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: is4k
|
||||||
|
description: Whether to remove from 4K service instance (true) or regular service instance (false)
|
||||||
|
required: false
|
||||||
|
example: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Succesfully removed media item
|
description: Successfully removed media item
|
||||||
/media/{mediaId}/{status}:
|
/media/{mediaId}/{status}:
|
||||||
post:
|
post:
|
||||||
summary: Update media status
|
summary: Update media status
|
||||||
@@ -7268,11 +7332,22 @@ paths:
|
|||||||
example: 1
|
example: 1
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Keyword returned
|
description: Keyword returned (null if not found)
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
nullable: true
|
||||||
$ref: '#/components/schemas/Keyword'
|
$ref: '#/components/schemas/Keyword'
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: 'Unable to retrieve keyword data.'
|
||||||
/watchproviders/regions:
|
/watchproviders/regions:
|
||||||
get:
|
get:
|
||||||
summary: Get watch provider regions
|
summary: Get watch provider regions
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
headers: {
|
headers: {
|
||||||
'If-None-Match': cachedWatchlist?.etag,
|
'If-None-Match': cachedWatchlist?.etag,
|
||||||
},
|
},
|
||||||
baseURL: 'https://metadata.provider.plex.tv',
|
baseURL: 'https://discover.provider.plex.tv',
|
||||||
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
|
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -315,7 +315,7 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||||
{
|
{
|
||||||
baseURL: 'https://metadata.provider.plex.tv',
|
baseURL: 'https://discover.provider.plex.tv',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1054,7 +1054,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
keywordId,
|
keywordId,
|
||||||
}: {
|
}: {
|
||||||
keywordId: number;
|
keywordId: number;
|
||||||
}): Promise<TmdbKeyword> {
|
}): Promise<TmdbKeyword | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbKeyword>(
|
const data = await this.get<TmdbKeyword>(
|
||||||
`/keyword/${keywordId}`,
|
`/keyword/${keywordId}`,
|
||||||
@@ -1064,6 +1064,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
|||||||
const blacklistedTagsArr = blacklistedTags.split(',');
|
const blacklistedTagsArr = blacklistedTags.split(',');
|
||||||
|
|
||||||
const pageLimit = settings.main.blacklistedTagsLimit;
|
const pageLimit = settings.main.blacklistedTagsLimit;
|
||||||
|
const invalidKeywords = new Set<string>();
|
||||||
|
|
||||||
if (blacklistedTags.length === 0) {
|
if (blacklistedTags.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -87,6 +88,19 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
|||||||
|
|
||||||
// Iterate for each tag
|
// Iterate for each tag
|
||||||
for (const tag of blacklistedTagsArr) {
|
for (const tag of blacklistedTagsArr) {
|
||||||
|
const keywordDetails = await tmdb.getKeywordDetails({
|
||||||
|
keywordId: Number(tag),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keywordDetails === null) {
|
||||||
|
logger.warn('Skipping invalid keyword in blacklisted tags', {
|
||||||
|
label: 'Blacklisted Tags Processor',
|
||||||
|
keywordId: tag,
|
||||||
|
});
|
||||||
|
invalidKeywords.add(tag);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let queryMax = pageLimit * SortOptionsIterable.length;
|
let queryMax = pageLimit * SortOptionsIterable.length;
|
||||||
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
|
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
|
||||||
|
|
||||||
@@ -102,24 +116,51 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
|||||||
throw new AbortTransaction();
|
throw new AbortTransaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getDiscover({
|
try {
|
||||||
page,
|
const response = await getDiscover({
|
||||||
sortBy,
|
page,
|
||||||
keywords: tag,
|
sortBy,
|
||||||
});
|
keywords: tag,
|
||||||
await this.processResults(response, tag, type, em);
|
});
|
||||||
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
|
|
||||||
|
|
||||||
this.progress++;
|
await this.processResults(response, tag, type, em);
|
||||||
if (page === 1 && response.total_pages <= queryMax) {
|
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
|
||||||
// We will finish the tag with less queries than expected, move progress accordingly
|
|
||||||
this.progress += queryMax - response.total_pages;
|
this.progress++;
|
||||||
fixedSortMode = true;
|
if (page === 1 && response.total_pages <= queryMax) {
|
||||||
queryMax = response.total_pages;
|
// We will finish the tag with less queries than expected, move progress accordingly
|
||||||
|
this.progress += queryMax - response.total_pages;
|
||||||
|
fixedSortMode = true;
|
||||||
|
queryMax = response.total_pages;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing keyword in blacklisted tags', {
|
||||||
|
label: 'Blacklisted Tags Processor',
|
||||||
|
keywordId: tag,
|
||||||
|
errorMessage: error.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (invalidKeywords.size > 0) {
|
||||||
|
const currentTags = blacklistedTagsArr.filter(
|
||||||
|
(tag) => !invalidKeywords.has(tag)
|
||||||
|
);
|
||||||
|
const cleanedTags = currentTags.join(',');
|
||||||
|
|
||||||
|
if (cleanedTags !== blacklistedTags) {
|
||||||
|
settings.main.blacklistedTags = cleanedTags;
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
logger.info('Cleaned up invalid keywords from settings', {
|
||||||
|
label: 'Blacklisted Tags Processor',
|
||||||
|
removedKeywords: Array.from(invalidKeywords),
|
||||||
|
newBlacklistedTags: cleanedTags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processResults(
|
private async processResults(
|
||||||
|
|||||||
@@ -128,11 +128,15 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
|||||||
if (keywords) {
|
if (keywords) {
|
||||||
const splitKeywords = keywords.split(',');
|
const splitKeywords = keywords.split(',');
|
||||||
|
|
||||||
keywordData = await Promise.all(
|
const keywordResults = await Promise.all(
|
||||||
splitKeywords.map(async (keywordId) => {
|
splitKeywords.map(async (keywordId) => {
|
||||||
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
keywordData = keywordResults.filter(
|
||||||
|
(keyword): keyword is TmdbKeyword => keyword !== null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
@@ -415,11 +419,15 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
|||||||
if (keywords) {
|
if (keywords) {
|
||||||
const splitKeywords = keywords.split(',');
|
const splitKeywords = keywords.split(',');
|
||||||
|
|
||||||
keywordData = await Promise.all(
|
const keywordResults = await Promise.all(
|
||||||
splitKeywords.map(async (keywordId) => {
|
splitKeywords.map(async (keywordId) => {
|
||||||
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
keywordData = keywordResults.filter(
|
||||||
|
(keyword): keyword is TmdbKeyword => keyword !== null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
|
|||||||
@@ -4,27 +4,40 @@ import { Router } from 'express';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
// Delay the initialization of ImageProxy instances until the proxy (if any) is properly configured
|
||||||
rateLimitOptions: {
|
let _tmdbImageProxy: ImageProxy;
|
||||||
maxRequests: 20,
|
function initTmdbImageProxy() {
|
||||||
maxRPS: 50,
|
if (!_tmdbImageProxy) {
|
||||||
},
|
_tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
||||||
});
|
rateLimitOptions: {
|
||||||
const tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
|
maxRequests: 20,
|
||||||
rateLimitOptions: {
|
maxRPS: 50,
|
||||||
maxRequests: 20,
|
},
|
||||||
maxRPS: 50,
|
});
|
||||||
},
|
}
|
||||||
});
|
return _tmdbImageProxy;
|
||||||
|
}
|
||||||
|
let _tvdbImageProxy: ImageProxy;
|
||||||
|
function initTvdbImageProxy() {
|
||||||
|
if (!_tvdbImageProxy) {
|
||||||
|
_tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
|
||||||
|
rateLimitOptions: {
|
||||||
|
maxRequests: 20,
|
||||||
|
maxRPS: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _tvdbImageProxy;
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/:type/*', async (req, res) => {
|
router.get('/:type/*', async (req, res) => {
|
||||||
const imagePath = req.path.replace(/^\/\w+/, '');
|
const imagePath = req.path.replace(/^\/\w+/, '');
|
||||||
try {
|
try {
|
||||||
let imageData;
|
let imageData;
|
||||||
if (req.params.type === 'tmdb') {
|
if (req.params.type === 'tmdb') {
|
||||||
imageData = await tmdbImageProxy.getImage(imagePath);
|
imageData = await initTmdbImageProxy().getImage(imagePath);
|
||||||
} else if (req.params.type === 'tvdb') {
|
} else if (req.params.type === 'tvdb') {
|
||||||
imageData = await tvdbImageProxy.getImage(imagePath);
|
imageData = await initTvdbImageProxy().getImage(imagePath);
|
||||||
} else {
|
} else {
|
||||||
logger.error('Unsupported image type', {
|
logger.error('Unsupported image type', {
|
||||||
imagePath,
|
imagePath,
|
||||||
|
|||||||
@@ -197,8 +197,10 @@ mediaRoutes.delete(
|
|||||||
const media = await mediaRepository.findOneOrFail({
|
const media = await mediaRepository.findOneOrFail({
|
||||||
where: { id: Number(req.params.id) },
|
where: { id: Number(req.params.id) },
|
||||||
});
|
});
|
||||||
const is4k = media.serviceUrl4k !== undefined;
|
|
||||||
|
const is4k = req.query.is4k === 'true';
|
||||||
const isMovie = media.mediaType === MediaType.MOVIE;
|
const isMovie = media.mediaType === MediaType.MOVIE;
|
||||||
|
|
||||||
let serviceSettings;
|
let serviceSettings;
|
||||||
if (isMovie) {
|
if (isMovie) {
|
||||||
serviceSettings = settings.radarr.find(
|
serviceSettings = settings.radarr.find(
|
||||||
@@ -225,6 +227,7 @@ mediaRoutes.delete(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serviceSettings) {
|
if (!serviceSettings) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`There is no default ${
|
`There is no default ${
|
||||||
@@ -239,6 +242,7 @@ mediaRoutes.delete(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let service;
|
let service;
|
||||||
if (isMovie) {
|
if (isMovie) {
|
||||||
service = new RadarrAPI({
|
service = new RadarrAPI({
|
||||||
|
|||||||
@@ -33,52 +33,93 @@ import { EventSubscriber } from 'typeorm';
|
|||||||
export class MediaRequestSubscriber
|
export class MediaRequestSubscriber
|
||||||
implements EntitySubscriberInterface<MediaRequest>
|
implements EntitySubscriberInterface<MediaRequest>
|
||||||
{
|
{
|
||||||
private async notifyAvailableMovie(entity: MediaRequest) {
|
private async notifyAvailableMovie(
|
||||||
|
entity: MediaRequest,
|
||||||
|
event?: UpdateEvent<MediaRequest>
|
||||||
|
) {
|
||||||
|
// Get fresh media state using event manager
|
||||||
|
let latestMedia: Media | null = null;
|
||||||
|
if (event?.manager) {
|
||||||
|
latestMedia = await event.manager.findOne(Media, {
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!latestMedia) {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
latestMedia = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check availability using fresh media state
|
||||||
if (
|
if (
|
||||||
entity.media[entity.is4k ? 'status4k' : 'status'] ===
|
!latestMedia ||
|
||||||
MediaStatus.AVAILABLE
|
latestMedia[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
const tmdb = new TheMovieDb();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const tmdb = new TheMovieDb();
|
||||||
const movie = await tmdb.getMovie({
|
|
||||||
movieId: entity.media.tmdbId,
|
|
||||||
});
|
|
||||||
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
try {
|
||||||
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
|
const movie = await tmdb.getMovie({
|
||||||
notifyAdmin: false,
|
movieId: entity.media.tmdbId,
|
||||||
notifySystem: true,
|
});
|
||||||
notifyUser: entity.requestedBy,
|
|
||||||
subject: `${movie.title}${
|
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||||
}`,
|
notifyAdmin: false,
|
||||||
message: truncate(movie.overview, {
|
notifySystem: true,
|
||||||
length: 500,
|
notifyUser: entity.requestedBy,
|
||||||
separator: /\s/,
|
subject: `${movie.title}${
|
||||||
omission: '…',
|
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||||
}),
|
}`,
|
||||||
media: entity.media,
|
message: truncate(movie.overview, {
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
length: 500,
|
||||||
request: entity,
|
separator: /\s/,
|
||||||
});
|
omission: '…',
|
||||||
} catch (e) {
|
}),
|
||||||
logger.error('Something went wrong sending media notification(s)', {
|
media: latestMedia,
|
||||||
label: 'Notifications',
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||||
errorMessage: e.message,
|
request: entity,
|
||||||
mediaId: entity.id,
|
});
|
||||||
});
|
} catch (e) {
|
||||||
}
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
|
label: 'Notifications',
|
||||||
|
errorMessage: e.message,
|
||||||
|
mediaId: entity.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyAvailableSeries(entity: MediaRequest) {
|
private async notifyAvailableSeries(
|
||||||
// Find all seasons in the related media entity
|
entity: MediaRequest,
|
||||||
// and see if they are available, then we can check
|
event?: UpdateEvent<MediaRequest>
|
||||||
// if the request contains the same seasons
|
) {
|
||||||
|
// Get fresh media state with seasons using event manager
|
||||||
|
let latestMedia: Media | null = null;
|
||||||
|
if (event?.manager) {
|
||||||
|
latestMedia = await event.manager.findOne(Media, {
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { seasons: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!latestMedia) {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
latestMedia = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { seasons: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latestMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check availability using fresh media state
|
||||||
const requestedSeasons =
|
const requestedSeasons =
|
||||||
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
|
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
|
||||||
const availableSeasons = entity.media.seasons.filter(
|
const availableSeasons = latestMedia.seasons.filter(
|
||||||
(season) =>
|
(season) =>
|
||||||
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||||
requestedSeasons.includes(season.seasonNumber)
|
requestedSeasons.includes(season.seasonNumber)
|
||||||
@@ -87,44 +128,46 @@ export class MediaRequestSubscriber
|
|||||||
availableSeasons.length > 0 &&
|
availableSeasons.length > 0 &&
|
||||||
availableSeasons.length === requestedSeasons.length;
|
availableSeasons.length === requestedSeasons.length;
|
||||||
|
|
||||||
if (isMediaAvailable) {
|
if (!isMediaAvailable) {
|
||||||
const tmdb = new TheMovieDb();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const tmdb = new TheMovieDb();
|
||||||
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
|
||||||
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
try {
|
||||||
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
|
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||||
subject: `${tv.name}${
|
|
||||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||||
}`,
|
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
|
||||||
message: truncate(tv.overview, {
|
subject: `${tv.name}${
|
||||||
length: 500,
|
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||||
separator: /\s/,
|
}`,
|
||||||
omission: '…',
|
message: truncate(tv.overview, {
|
||||||
}),
|
length: 500,
|
||||||
notifyAdmin: false,
|
separator: /\s/,
|
||||||
notifySystem: true,
|
omission: '…',
|
||||||
notifyUser: entity.requestedBy,
|
}),
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
notifyAdmin: false,
|
||||||
media: entity.media,
|
notifySystem: true,
|
||||||
extra: [
|
notifyUser: entity.requestedBy,
|
||||||
{
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||||
name: 'Requested Seasons',
|
media: latestMedia,
|
||||||
value: entity.seasons
|
extra: [
|
||||||
.map((season) => season.seasonNumber)
|
{
|
||||||
.join(', '),
|
name: 'Requested Seasons',
|
||||||
},
|
value: entity.seasons
|
||||||
],
|
.map((season) => season.seasonNumber)
|
||||||
request: entity,
|
.join(', '),
|
||||||
});
|
},
|
||||||
} catch (e) {
|
],
|
||||||
logger.error('Something went wrong sending media notification(s)', {
|
request: entity,
|
||||||
label: 'Notifications',
|
});
|
||||||
errorMessage: e.message,
|
} catch (e) {
|
||||||
mediaId: entity.id,
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
});
|
label: 'Notifications',
|
||||||
}
|
errorMessage: e.message,
|
||||||
|
mediaId: entity.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,10 +825,10 @@ export class MediaRequestSubscriber
|
|||||||
|
|
||||||
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||||
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||||
this.notifyAvailableMovie(event.entity as MediaRequest);
|
this.notifyAvailableMovie(event.entity as MediaRequest, event);
|
||||||
}
|
}
|
||||||
if (event.entity.media.mediaType === MediaType.TV) {
|
if (event.entity.media.mediaType === MediaType.TV) {
|
||||||
this.notifyAvailableSeries(event.entity as MediaRequest);
|
this.notifyAvailableSeries(event.entity as MediaRequest, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,14 +29,10 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
|||||||
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
|
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
|
||||||
Promise.all(
|
Promise.all(
|
||||||
keywordIds.map(async (keywordId) => {
|
keywordIds.map(async (keywordId) => {
|
||||||
try {
|
const { data } = await axios.get<Keyword | null>(
|
||||||
const { data } = await axios.get<Keyword>(
|
`/api/v1/keyword/${keywordId}`
|
||||||
`/api/v1/keyword/${keywordId}`
|
);
|
||||||
);
|
return data?.name || `[Invalid: ${keywordId}]`;
|
||||||
return data.name;
|
|
||||||
} catch (err) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
).then((keywords) => {
|
).then((keywords) => {
|
||||||
setTagNamesBlacklistedFor(keywords.join(', '));
|
setTagNamesBlacklistedFor(keywords.join(', '));
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { ArrowDownIcon } from '@heroicons/react/24/solid';
|
import { ArrowDownIcon } from '@heroicons/react/24/solid';
|
||||||
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
|
import type {
|
||||||
|
TmdbKeyword,
|
||||||
|
TmdbKeywordSearchResponse,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
import type { Keyword } from '@server/models/common';
|
import type { Keyword } from '@server/models/common';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useFormikContext } from 'formik';
|
import { useFormikContext } from 'formik';
|
||||||
@@ -124,15 +127,19 @@ const ControlledKeywordSelector = ({
|
|||||||
|
|
||||||
const keywords = await Promise.all(
|
const keywords = await Promise.all(
|
||||||
defaultValue.split(',').map(async (keywordId) => {
|
defaultValue.split(',').map(async (keywordId) => {
|
||||||
const { data } = await axios.get<Keyword>(
|
const { data } = await axios.get<Keyword | null>(
|
||||||
`/api/v1/keyword/${keywordId}`
|
`/api/v1/keyword/${keywordId}`
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const validKeywords: TmdbKeyword[] = keywords.filter(
|
||||||
|
(keyword): keyword is TmdbKeyword => keyword !== null
|
||||||
|
);
|
||||||
|
|
||||||
onChange(
|
onChange(
|
||||||
keywords.map((keyword) => ({
|
validKeywords.map((keyword) => ({
|
||||||
label: keyword.name,
|
label: keyword.name,
|
||||||
value: keyword.id,
|
value: keyword.id,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -77,16 +77,19 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
|
|
||||||
const keywords = await Promise.all(
|
const keywords = await Promise.all(
|
||||||
slider.data.split(',').map(async (keywordId) => {
|
slider.data.split(',').map(async (keywordId) => {
|
||||||
const keyword = await axios.get<Keyword>(
|
const keyword = await axios.get<Keyword | null>(
|
||||||
`/api/v1/keyword/${keywordId}`
|
`/api/v1/keyword/${keywordId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return keyword.data;
|
return keyword.data;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const validKeywords: Keyword[] = keywords.filter(
|
||||||
|
(keyword): keyword is Keyword => keyword !== null
|
||||||
|
);
|
||||||
|
|
||||||
setDefaultDataValue(
|
setDefaultDataValue(
|
||||||
keywords.map((keyword) => ({
|
validKeywords.map((keyword) => ({
|
||||||
label: keyword.name,
|
label: keyword.name,
|
||||||
value: keyword.id,
|
value: keyword.id,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -118,9 +118,11 @@ const ManageSlideOver = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMediaFile = async () => {
|
const deleteMediaFile = async (is4k = false) => {
|
||||||
if (data.mediaInfo) {
|
if (data.mediaInfo) {
|
||||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
|
await axios.delete(
|
||||||
|
`/api/v1/media/${data.mediaInfo.id}/file?is4k=${is4k}`
|
||||||
|
);
|
||||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||||
revalidate();
|
revalidate();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -414,7 +416,7 @@ const ManageSlideOver = ({
|
|||||||
isDefaultService() && (
|
isDefaultService() && (
|
||||||
<div>
|
<div>
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
onClick={() => deleteMediaFile()}
|
onClick={() => deleteMediaFile(false)}
|
||||||
confirmText={intl.formatMessage(
|
confirmText={intl.formatMessage(
|
||||||
globalMessages.areyousure
|
globalMessages.areyousure
|
||||||
)}
|
)}
|
||||||
@@ -573,7 +575,7 @@ const ManageSlideOver = ({
|
|||||||
{isDefaultService() && (
|
{isDefaultService() && (
|
||||||
<div>
|
<div>
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
onClick={() => deleteMediaFile()}
|
onClick={() => deleteMediaFile(true)}
|
||||||
confirmText={intl.formatMessage(
|
confirmText={intl.formatMessage(
|
||||||
globalMessages.areyousure
|
globalMessages.areyousure
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -343,7 +343,9 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
|
|
||||||
const deleteMediaFile = async () => {
|
const deleteMediaFile = async () => {
|
||||||
if (request.media) {
|
if (request.media) {
|
||||||
await axios.delete(`/api/v1/media/${request.media.id}/file`);
|
await axios.delete(
|
||||||
|
`/api/v1/media/${request.media.id}/file?is4k=${request.is4k}`
|
||||||
|
);
|
||||||
await axios.delete(`/api/v1/media/${request.media.id}`);
|
await axios.delete(`/api/v1/media/${request.media.id}`);
|
||||||
revalidateList();
|
revalidateList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,16 +309,19 @@ export const KeywordSelector = ({
|
|||||||
|
|
||||||
const keywords = await Promise.all(
|
const keywords = await Promise.all(
|
||||||
defaultValue.split(',').map(async (keywordId) => {
|
defaultValue.split(',').map(async (keywordId) => {
|
||||||
const keyword = await axios.get<Keyword>(
|
const keyword = await axios.get<Keyword | null>(
|
||||||
`/api/v1/keyword/${keywordId}`
|
`/api/v1/keyword/${keywordId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return keyword.data;
|
return keyword.data;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const validKeywords: Keyword[] = keywords.filter(
|
||||||
|
(keyword): keyword is Keyword => keyword !== null
|
||||||
|
);
|
||||||
|
|
||||||
setDefaultDataValue(
|
setDefaultDataValue(
|
||||||
keywords.map((keyword) => ({
|
validKeywords.map((keyword) => ({
|
||||||
label: keyword.name,
|
label: keyword.name,
|
||||||
value: keyword.id,
|
value: keyword.id,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -113,12 +113,16 @@ const OverrideRuleTiles = ({
|
|||||||
.flat()
|
.flat()
|
||||||
.filter((keywordId) => keywordId)
|
.filter((keywordId) => keywordId)
|
||||||
.map(async (keywordId) => {
|
.map(async (keywordId) => {
|
||||||
const response = await axios.get(`/api/v1/keyword/${keywordId}`);
|
const response = await axios.get<Keyword | null>(
|
||||||
const keyword: Keyword = response.data;
|
`/api/v1/keyword/${keywordId}`
|
||||||
return keyword;
|
);
|
||||||
|
return response.data;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setKeywords(keywords);
|
const validKeywords: Keyword[] = keywords.filter(
|
||||||
|
(keyword): keyword is Keyword => keyword !== null
|
||||||
|
);
|
||||||
|
setKeywords(validKeywords);
|
||||||
const allUsersFromRules = rules
|
const allUsersFromRules = rules
|
||||||
.map((rule) => rule.users)
|
.map((rule) => rule.users)
|
||||||
.filter((users) => users)
|
.filter((users) => users)
|
||||||
|
|||||||
Reference in New Issue
Block a user