Compare commits

..

6 Commits

Author SHA1 Message Date
0xsysr3ll
461ed7e5d3 refactor(MediaRequestSubscriber): streamline media availability notifications 2025-08-08 15:10:43 +02:00
0xsysr3ll
308f60ac46 fix(MediaRequestSubscriber): use event manager to get fresh media state for MEDIA_AVAILABLE notifications 2025-08-08 15:10:43 +02:00
0xsysr3ll
c86ee0ddb1 fix(api): make username field nullable in UserSettings API schema (#1835) 2025-08-06 06:22:21 +08:00
0xsysr3ll
e02ee24f70 fix(media): update delete media file logic to include is4k parameter (#1832)
* fix(media): update delete media file logic to include is4k parameter

* fix(media): revert to MANAGE_REQUESTS permission
2025-08-05 11:42:11 +02:00
0xsysr3ll
ca1686425b fix(blacklist): handle invalid keywords gracefully (#1815)
* fix(blacklist): handle invalid keywords gracefully

* fix(blacklist): only remove keywords on 404 errors

* fix(blacklist): remove non-null assertion and add proper type annotation

* refactor(blacklist): return null instead of 404 for missing keywords

* fix(blacklist): add type annotation for validKeywords

* fix(selector): update type annotation for validKeywords
2025-08-01 11:03:22 +02:00
0xsysr3ll
e52c63164f fix(api): add missing user settings' api docs (#1820)
This PR adds new fields to the UserSettings schema, including username, email, discordId, and various quota limits for movies and TV shows.

It also updates API paths to reference the new UserSettings schema.
2025-07-30 23:44:49 +02:00
5 changed files with 214 additions and 99 deletions

View File

@@ -141,14 +141,83 @@ components:
UserSettings:
type: object
properties:
username:
type: string
nullable: true
example: 'Mr User'
email:
type: string
example: 'user@example.com'
discordId:
type: string
nullable: true
example: '123456789'
locale:
type: string
nullable: true
example: 'en'
discoverRegion:
type: string
originalLanguage:
type: string
nullable: true
example: 'US'
streamingRegion:
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:
type: object
properties:
@@ -4469,11 +4538,7 @@ paths:
content:
application/json:
schema:
type: object
properties:
username:
type: string
example: 'Mr User'
$ref: '#/components/schemas/UserSettings'
post:
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.
@@ -4490,22 +4555,14 @@ paths:
content:
application/json:
schema:
type: object
properties:
username:
type: string
nullable: true
$ref: '#/components/schemas/UserSettings'
responses:
'200':
description: Updated user general settings returned
content:
application/json:
schema:
type: object
properties:
username:
type: string
example: 'Mr User'
$ref: '#/components/schemas/UserSettings'
/user/{userId}/settings/password:
get:
summary: Get password page informatiom
@@ -6599,9 +6656,16 @@ paths:
example: '1'
schema:
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:
'204':
description: Succesfully removed media item
description: Successfully removed media item
/media/{mediaId}/{status}:
post:
summary: Update media status

View File

@@ -197,8 +197,10 @@ mediaRoutes.delete(
const media = await mediaRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
const is4k = media.serviceUrl4k !== undefined;
const is4k = req.query.is4k === 'true';
const isMovie = media.mediaType === MediaType.MOVIE;
let serviceSettings;
if (isMovie) {
serviceSettings = settings.radarr.find(
@@ -225,6 +227,7 @@ mediaRoutes.delete(
);
}
}
if (!serviceSettings) {
logger.warn(
`There is no default ${
@@ -239,6 +242,7 @@ mediaRoutes.delete(
);
return;
}
let service;
if (isMovie) {
service = new RadarrAPI({

View File

@@ -33,52 +33,93 @@ import { EventSubscriber } from 'typeorm';
export class MediaRequestSubscriber
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 (
entity.media[entity.is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE
!latestMedia ||
latestMedia[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
) {
const tmdb = new TheMovieDb();
return;
}
try {
const movie = await tmdb.getMovie({
movieId: entity.media.tmdbId,
});
const tmdb = new TheMovieDb();
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,
});
}
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: latestMedia,
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
private async notifyAvailableSeries(
entity: MediaRequest,
event?: UpdateEvent<MediaRequest>
) {
// 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 =
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
const availableSeasons = entity.media.seasons.filter(
const availableSeasons = latestMedia.seasons.filter(
(season) =>
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
requestedSeasons.includes(season.seasonNumber)
@@ -87,44 +128,46 @@ export class MediaRequestSubscriber
availableSeasons.length > 0 &&
availableSeasons.length === requestedSeasons.length;
if (isMediaAvailable) {
const tmdb = new TheMovieDb();
if (!isMediaAvailable) {
return;
}
try {
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
const tmdb = new TheMovieDb();
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,
});
}
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: latestMedia,
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,
});
}
}
@@ -782,10 +825,10 @@ export class MediaRequestSubscriber
if (event.entity.status === MediaRequestStatus.COMPLETED) {
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) {
this.notifyAvailableSeries(event.entity as MediaRequest);
this.notifyAvailableSeries(event.entity as MediaRequest, event);
}
}
}

View File

@@ -118,9 +118,11 @@ const ManageSlideOver = ({
}
};
const deleteMediaFile = async () => {
const deleteMediaFile = async (is4k = false) => {
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}`);
revalidate();
onClose();
@@ -414,7 +416,7 @@ const ManageSlideOver = ({
isDefaultService() && (
<div>
<ConfirmButton
onClick={() => deleteMediaFile()}
onClick={() => deleteMediaFile(false)}
confirmText={intl.formatMessage(
globalMessages.areyousure
)}
@@ -573,7 +575,7 @@ const ManageSlideOver = ({
{isDefaultService() && (
<div>
<ConfirmButton
onClick={() => deleteMediaFile()}
onClick={() => deleteMediaFile(true)}
confirmText={intl.formatMessage(
globalMessages.areyousure
)}

View File

@@ -343,7 +343,9 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const deleteMediaFile = async () => {
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}`);
revalidateList();
}