Compare commits

..

7 Commits

Author SHA1 Message Date
fallenbagel
8fa68da481 refactor(serviceavailabilitychecker): add missing closing parenthesis in debug log 2025-12-30 05:01:03 +08:00
fallenbagel
cf5a85ba0b refactor(serviceavailabilitychecker): correct set initialization for season numbers 2025-12-30 04:59:14 +08:00
fallenbagel
9cbd5f4260 refactor(jellyfin-scanner): correct variable name for 4k availability checks 2025-12-30 04:57:08 +08:00
fallenbagel
09233a32b3 fix(jellyfin-scanner): use service instance for 4k availability detection
when 4k services are enabled, jellyfin scanner will now check which arr instance has the file
todetermine availability tier instead of relying solely on resolution detection. This should fix
theincorrecta availability status when a 4k request results in a lower resolution file. Fallbacks
tooriginal resolution based detection when media not found.

fix #1744
2025-12-29 23:29:27 +08:00
fallenbagel
57d583e1bd refactor(jellyfin-scanner): extend BaseScanner for jellyfin scanner (#2226)
* refactor(jellyfin-scanner): extend BaseScanner for jellyfin scanner

Refactors JellyfinScanner to extend BaseScanner class to align the jellyfin scanner architecture
with the plex scanner and reduce code duplication.

* fix(jellyfin-scanner): add imdbId handling back to fix a regression from original behaviour

* fix: add imdbId assignment for existing media entries

* fix: include imdbId in processed 4k media items and improve 4k detection

* fix(jellyfin-scanner): filter seasons based on settings for special episodes (regression)
2025-12-29 20:05:47 +08:00
samohtxotom
8bbe7864af chore(metadata-settings): add autoDismiss to toast notifications (#2254) 2025-12-27 06:27:12 +08:00
Gauthier
66b4e2c871 chore(issuetemplate): add a checkbox to search for existing issues (#2255) 2025-12-27 06:26:16 +08:00
12 changed files with 605 additions and 767 deletions

View File

@@ -91,6 +91,14 @@ body:
attributes:
label: Additional Context
description: Please provide any additional information that may be relevant or helpful.
- type: checkboxes
id: search-existing
attributes:
label: Search Existing Issues
description: Have you searched existing issues to see if this bug has already been reported?
options:
- label: Yes, I have searched existing issues.
required: true
- type: checkboxes
id: terms
attributes:

View File

@@ -27,6 +27,14 @@ body:
attributes:
label: Additional Context
description: Provide any additional information or screenshots that may be relevant or helpful.
- type: checkboxes
id: search-existing
attributes:
label: Search Existing Issues
description: Have you searched existing issues to see if this feature has already been requested?
options:
- label: Yes, I have searched existing issues.
required: true
- type: checkboxes
id: terms
attributes:

View File

@@ -22,17 +22,6 @@ This is typically not needed. Please refer to your webhook provider's documentat
This value will be sent as an `Authorization` HTTP header.
### Custom Headers (optional)
You can add additional custom HTTP headers to be sent with each webhook request. This is useful for API keys, custom authentication schemes, or any other headers your webhook endpoint requires.
- Click "Add Header" to add a new header
- Enter the header name and value
:::warning
You cannot configure both the **Authorization Header** field and a custom `Authorization` header in Custom Headers at the same time. You must choose one method.
:::
### JSON Payload
Customize the JSON payload to suit your needs. Seerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.

View File

@@ -196,33 +196,16 @@ class WebhookAgent
}
try {
const headers: Record<string, string> = {};
if (settings.options.authHeader) {
headers.Authorization = settings.options.authHeader;
}
if (
settings.options.customHeaders &&
settings.options.customHeaders.length > 0
) {
settings.options.customHeaders.forEach((header) => {
if (header.key && header.value) {
// Don't override Authorization header if it's already set via authHeader
if (
header.key.toLowerCase() !== 'authorization' ||
!settings.options.authHeader
) {
headers[header.key] = header.value;
}
}
});
}
await axios.post(
webhookUrl,
this.buildPayload(type, payload),
Object.keys(headers).length > 0 ? { headers } : undefined
settings.options.authHeader
? {
headers: {
Authorization: settings.options.authHeader,
},
}
: undefined
);
return true;

View File

@@ -34,6 +34,8 @@ interface ProcessOptions {
is4k?: boolean;
mediaAddedAt?: Date;
ratingKey?: string;
jellyfinMediaId?: string;
imdbId?: string;
serviceId?: number;
externalServiceId?: number;
externalServiceSlug?: string;
@@ -95,6 +97,8 @@ class BaseScanner<T> {
is4k = false,
mediaAddedAt,
ratingKey,
jellyfinMediaId,
imdbId,
serviceId,
externalServiceId,
externalServiceSlug,
@@ -133,6 +137,21 @@ class BaseScanner<T> {
changedExisting = true;
}
if (
jellyfinMediaId &&
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
jellyfinMediaId
) {
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
jellyfinMediaId;
changedExisting = true;
}
if (imdbId && !existing.imdbId) {
existing.imdbId = imdbId;
changedExisting = true;
}
if (
serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
@@ -173,6 +192,7 @@ class BaseScanner<T> {
} else {
const newMedia = new Media();
newMedia.tmdbId = tmdbId;
newMedia.imdbId = imdbId;
newMedia.status =
!is4k && !processing
@@ -203,6 +223,13 @@ class BaseScanner<T> {
newMedia.ratingKey4k =
is4k && this.enable4kMovie ? ratingKey : undefined;
}
if (jellyfinMediaId) {
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
newMedia.jellyfinMediaId4k =
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
@@ -221,11 +248,12 @@ class BaseScanner<T> {
*/
protected async processShow(
tmdbId: number,
tvdbId: number,
tvdbId: number | undefined,
seasons: ProcessableSeason[],
{
mediaAddedAt,
ratingKey,
jellyfinMediaId,
serviceId,
externalServiceId,
externalServiceSlug,
@@ -257,7 +285,7 @@ class BaseScanner<T> {
(es) => es.seasonNumber === season.seasonNumber
);
// We update the rating keys in the seasons loop because we need episode counts
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey;
}
@@ -271,6 +299,23 @@ class BaseScanner<T> {
media.ratingKey4k = ratingKey;
}
if (
media &&
season.episodes > 0 &&
media.jellyfinMediaId !== jellyfinMediaId
) {
media.jellyfinMediaId = jellyfinMediaId;
}
if (
media &&
season.episodes4k > 0 &&
this.enable4kShow &&
media.jellyfinMediaId4k !== jellyfinMediaId
) {
media.jellyfinMediaId4k = jellyfinMediaId;
}
if (existingSeason) {
// Here we update seasons if they already exist.
// If the season is already marked as available, we
@@ -491,6 +536,22 @@ class BaseScanner<T> {
)
? ratingKey
: undefined,
jellyfinMediaId: newSeasons.some(
(sn) =>
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status === MediaStatus.AVAILABLE
)
? jellyfinMediaId
: undefined,
jellyfinMediaId4k:
this.enable4kShow &&
newSeasons.some(
(sn) =>
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status4k === MediaStatus.AVAILABLE
)
? jellyfinMediaId
: undefined,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
interface InstanceAvailability {
hasStandard: boolean;
has4k: boolean;
serviceStandardId?: number;
service4kId?: number;
externalStandardId?: number;
external4kId?: number;
}
interface SeasonInstanceAvailability {
seasonNumber: number;
episodesStandard: number;
episodes4k: number;
}
interface ShowInstanceAvailability extends InstanceAvailability {
seasons: SeasonInstanceAvailability[];
}
class ServiceAvailabilityChecker {
private movieCache: Map<number, InstanceAvailability>;
private showCache: Map<number, ShowInstanceAvailability>;
constructor() {
this.movieCache = new Map();
this.showCache = new Map();
}
public clearCache(): void {
this.movieCache.clear();
this.showCache.clear();
}
public async checkMovieAvailability(
tmdbid: number
): Promise<InstanceAvailability> {
const cached = this.movieCache.get(tmdbid);
if (cached) {
return cached;
}
const settings = getSettings();
const result: InstanceAvailability = {
hasStandard: false,
has4k: false,
};
if (!settings.radarr || settings.radarr.length === 0) {
return result;
}
for (const radarrSettings of settings.radarr) {
try {
const radarr = this.createRadarrClient(radarrSettings);
const movie = await radarr.getMovieByTmdbId(tmdbid);
if (movie?.hasFile) {
if (radarrSettings.is4k) {
result.has4k = true;
result.service4kId = radarrSettings.id;
result.external4kId = movie.id;
} else {
result.hasStandard = true;
result.serviceStandardId = radarrSettings.id;
result.externalStandardId = movie.id;
}
}
logger.debug(
`Found movie (TMDB: ${tmdbid}) in ${
radarrSettings.is4k ? '4K' : 'Standard'
} Radarr instance (name: ${radarrSettings.name})`,
{
label: 'Service Availability',
radarrId: radarrSettings.id,
movieId: movie?.id,
}
);
} catch {
// movie not found in this instance, continue
}
}
this.movieCache.set(tmdbid, result);
return result;
}
public async checkShowAvailability(
tvdbid: number
): Promise<ShowInstanceAvailability> {
const cached = this.showCache.get(tvdbid);
if (cached) {
return cached;
}
const settings = getSettings();
const result: ShowInstanceAvailability = {
hasStandard: false,
has4k: false,
seasons: [],
};
if (!settings.sonarr || settings.sonarr.length === 0) {
return result;
}
const standardSeasons = new Map<number, number>();
const seasons4k = new Map<number, number>();
for (const sonarrSettings of settings.sonarr) {
try {
const sonarr = this.createSonarrClient(sonarrSettings);
const series = await sonarr.getSeriesByTvdbId(tvdbid);
if (series?.id && series.statistics?.episodeFileCount > 0) {
if (sonarrSettings.is4k) {
result.has4k = true;
result.service4kId = sonarrSettings.id;
result.external4kId = series.id;
} else {
result.hasStandard = true;
result.serviceStandardId = sonarrSettings.id;
result.externalStandardId = series.id;
}
for (const season of series.seasons) {
const episodeCount = season.statistics?.episodeFileCount ?? 0;
if (episodeCount > 0) {
const targetMap = sonarrSettings.is4k
? seasons4k
: standardSeasons;
const current = targetMap.get(season.seasonNumber) ?? 0;
targetMap.set(
season.seasonNumber,
Math.max(current, episodeCount)
);
}
}
logger.debug(
`Found series (TVDB: ${tvdbid}) in ${
sonarrSettings.is4k ? '4K' : 'Standard'
} Sonarr instance (name: ${sonarrSettings.name})`,
{
label: 'Service Availability',
sonarrId: sonarrSettings.id,
seriesId: series.id,
}
);
}
} catch {
// series not found in this instance, continue
}
}
const allSeasonNumbers = new Set([
...standardSeasons.keys(),
...seasons4k.keys(),
]);
result.seasons = Array.from(allSeasonNumbers).map((seasonNumber) => ({
seasonNumber,
episodesStandard: standardSeasons.get(seasonNumber) ?? 0,
episodes4k: seasons4k.get(seasonNumber) ?? 0,
}));
this.showCache.set(tvdbid, result);
return result;
}
private createRadarrClient(settings: RadarrSettings): RadarrAPI {
return new RadarrAPI({
url: RadarrAPI.buildUrl(settings, '/api/v3'),
apiKey: settings.apiKey,
});
}
private createSonarrClient(settings: SonarrSettings): SonarrAPI {
return new SonarrAPI({
url: SonarrAPI.buildUrl(settings, '/api/v3'),
apiKey: settings.apiKey,
});
}
}
const serviceAvailabilityChecker = new ServiceAvailabilityChecker();
export default serviceAvailabilityChecker;

View File

@@ -275,7 +275,6 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
webhookUrl: string;
jsonPayload: string;
authHeader?: string;
customHeaders?: { key: string; value: string }[];
supportVariables?: boolean;
};
}

View File

@@ -279,7 +279,6 @@ notificationRoutes.get('/webhook', (_req, res) => {
'utf8'
)
),
customHeaders: webhookSettings.options.customHeaders ?? [],
supportVariables: webhookSettings.options.supportVariables ?? false,
},
};
@@ -302,7 +301,6 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
),
webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader,
customHeaders: req.body.options.customHeaders ?? [],
supportVariables: req.body.options.supportVariables ?? false,
},
};
@@ -335,7 +333,6 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
),
webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader,
customHeaders: req.body.options.customHeaders ?? [],
supportVariables: req.body.options.supportVariables ?? false,
},
};

View File

@@ -5,12 +5,7 @@ import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { isValidURL } from '@app/utils/urlValidationHelper';
import {
ArrowDownOnSquareIcon,
BeakerIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import {
ArrowPathIcon,
QuestionMarkCircleIcon,
@@ -85,16 +80,6 @@ const messages = defineMessages(
supportVariablesTip:
'Available variables are documented in the webhook template variables section',
authheader: 'Authorization Header',
customHeaders: 'Custom Headers',
customHeadersTip:
'Add custom HTTP headers to include with webhook requests',
customHeadersAdd: 'Add Header',
customHeadersRemove: 'Remove',
customHeadersKey: 'Header Name',
customHeadersValue: 'Header Value',
customHeadersIncomplete: 'All headers must have both name and value',
customHeadersAuthConflict:
'Cannot use both Authorization Header and custom Authorization header. Please remove one.',
validationJsonPayloadRequired: 'You must provide a valid JSON payload',
webhooksettingssaved: 'Webhook notification settings saved successfully!',
webhooksettingsfailed: 'Webhook notification settings failed to save.',
@@ -140,43 +125,6 @@ const NotificationsWebhook = () => {
supportVariables: Yup.boolean(),
customHeaders: Yup.array()
.of(
Yup.object().shape({
key: Yup.string(),
value: Yup.string(),
})
)
.test(
'complete-headers',
intl.formatMessage(messages.customHeadersIncomplete),
function (headers) {
if (!headers || headers.length === 0) return true;
return headers.every(
(header) =>
(!header.key || !header.key.trim()) ===
(!header.value || !header.value.trim())
);
}
)
.test(
'auth-conflict',
intl.formatMessage(messages.customHeadersAuthConflict),
function (headers) {
const { authHeader } = this.parent;
if (!authHeader || !headers || headers.length === 0) return true;
const hasCustomAuthHeader = headers.some(
(header) =>
header.key &&
header.value &&
header.key.toLowerCase() === 'authorization'
);
return !hasCustomAuthHeader;
}
),
jsonPayload: Yup.string()
.when('enabled', {
is: true,
@@ -211,7 +159,6 @@ const NotificationsWebhook = () => {
webhookUrl: data.options.webhookUrl,
jsonPayload: data.options.jsonPayload,
authHeader: data.options.authHeader,
customHeaders: data.options.customHeaders ?? [],
supportVariables: data.options.supportVariables ?? false,
}}
validationSchema={NotificationsWebhookSchema}
@@ -224,9 +171,6 @@ const NotificationsWebhook = () => {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
customHeaders: values.customHeaders.filter(
(h: { key: string; value: string }) => h.key && h.value
),
supportVariables: values.supportVariables,
},
});
@@ -285,9 +229,6 @@ const NotificationsWebhook = () => {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
customHeaders: values.customHeaders.filter(
(h: { key: string; value: string }) => h.key && h.value
),
supportVariables: values.supportVariables ?? false,
},
});
@@ -403,86 +344,6 @@ const NotificationsWebhook = () => {
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="customHeaders" className="text-label">
{intl.formatMessage(messages.customHeaders)}
<span className="label-tip">
{intl.formatMessage(messages.customHeadersTip)}
</span>
</label>
<div className="form-input-area">
<div className="space-y-2">
{values.customHeaders.map(
(header: { key: string; value: string }, index: number) => (
<div key={index} className="flex gap-2">
<div className="flex-1">
<div className="form-input-field">
<Field
name={`customHeaders.${index}.key`}
type="text"
placeholder={intl.formatMessage(
messages.customHeadersKey
)}
/>
</div>
</div>
<div className="flex-1">
<div className="form-input-field">
<Field
name={`customHeaders.${index}.value`}
type="text"
placeholder={intl.formatMessage(
messages.customHeadersValue
)}
/>
</div>
</div>
<div className="flex items-center">
<Button
buttonType="danger"
buttonSize="sm"
onClick={(e) => {
e.preventDefault();
const newHeaders = values.customHeaders.filter(
(
_: { key: string; value: string },
i: number
) => i !== index
);
setFieldValue('customHeaders', newHeaders);
}}
title={intl.formatMessage(
messages.customHeadersRemove
)}
>
<TrashIcon />
</Button>
</div>
</div>
)
)}
<Button
buttonType="default"
buttonSize="sm"
onClick={(e) => {
e.preventDefault();
setFieldValue('customHeaders', [
...values.customHeaders,
{ key: '', value: '' },
]);
}}
>
<PlusIcon />
<span>{intl.formatMessage(messages.customHeadersAdd)}</span>
</Button>
</div>
{errors.customHeaders &&
touched.customHeaders &&
typeof errors.customHeaders === 'string' && (
<div className="error">{errors.customHeaders}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="webhook-json-payload" className="text-label">
{intl.formatMessage(messages.customJson)}

View File

@@ -320,12 +320,14 @@ const SettingsMetadata = () => {
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(
intl.formatMessage(messages.failedToSaveMetadataSettings),
{
appearance: 'error',
autoDismiss: true,
}
);
}
@@ -422,6 +424,7 @@ const SettingsMetadata = () => {
),
{
appearance: 'success',
autoDismiss: true,
}
);
}

View File

@@ -681,14 +681,6 @@
"components.Settings.Notifications.NotificationsSlack.webhookUrlTip": "Create an <WebhookLink>Incoming Webhook</WebhookLink> integration",
"components.Settings.Notifications.NotificationsWebhook.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization Header",
"components.Settings.Notifications.NotificationsWebhook.customHeaders": "Custom Headers",
"components.Settings.Notifications.NotificationsWebhook.customHeadersAdd": "Add Header",
"components.Settings.Notifications.NotificationsWebhook.customHeadersAuthConflict": "Cannot use both Authorization Header and custom Authorization header. Please remove one.",
"components.Settings.Notifications.NotificationsWebhook.customHeadersIncomplete": "All headers must have both name and value",
"components.Settings.Notifications.NotificationsWebhook.customHeadersKey": "Header Name",
"components.Settings.Notifications.NotificationsWebhook.customHeadersRemove": "Remove",
"components.Settings.Notifications.NotificationsWebhook.customHeadersTip": "Add custom HTTP headers to include with webhook requests",
"components.Settings.Notifications.NotificationsWebhook.customHeadersValue": "Header Value",
"components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload",
"components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default",
"components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload reset successfully!",