Compare commits

..

2 Commits

Author SHA1 Message Date
Gauthier
55cf903bf6 fix: add bcrypt to the build only dependencies 2025-01-22 12:56:21 +01:00
gauthier-th
334502a8c9 refactor: upgrade to pnpm 10 2025-01-22 00:11:24 +01:00
22 changed files with 4161 additions and 5743 deletions

View File

@@ -20,7 +20,7 @@ jobs:
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 10
version: 9
- name: Get pnpm store directory
shell: sh
run: |

View File

@@ -21,7 +21,7 @@ jobs:
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 10
version: 9
- name: Cypress run
uses: cypress-io/github-action@v6
with:

View File

@@ -25,7 +25,7 @@ jobs:
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 10
version: 9
- name: Get pnpm store directory
shell: sh

View File

@@ -12,10 +12,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 10
- name: Get the version
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

View File

@@ -35,7 +35,7 @@ jobs:
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 10
version: 9
- name: Get pnpm store directory
shell: sh
run: |

View File

@@ -25,7 +25,7 @@ jobs:
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 10
version: 9
- name: Get pnpm store directory
shell: sh
@@ -42,7 +42,7 @@ jobs:
- name: Install dependencies
run: |
cd gen-docs
cd gen-docs
pnpm install --frozen-lockfile
- name: Build website

View File

@@ -29,7 +29,7 @@ RUN pnpm build
# remove development dependencies
RUN pnpm prune --prod --ignore-scripts
RUN rm -rf src server .next/cache charts gen-docs docs
RUN rm -rf src server .next/cache
RUN touch config/DOCKER

View File

@@ -3,7 +3,7 @@ kubeVersion: ">=1.23.0-0"
name: jellyseerr-chart
description: Jellyseerr helm chart for Kubernetes
type: application
version: 2.1.1
version: 2.1.0
appVersion: "2.3.0"
maintainers:
- name: Jellyseerr

View File

@@ -1,6 +1,6 @@
# jellyseerr-chart
![Version: 2.1.1](https://img.shields.io/badge/Version-2.1.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.3.0](https://img.shields.io/badge/AppVersion-2.3.0-informational?style=flat-square)
![Version: 2.1.0](https://img.shields.io/badge/Version-2.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.3.0](https://img.shields.io/badge/AppVersion-2.3.0-informational?style=flat-square)
Jellyseerr helm chart for Kubernetes

View File

@@ -15,7 +15,7 @@ import TabItem from '@theme/TabItem';
### Prerequisites
- [Node.js 22.x](https://nodejs.org/en/download/)
- [Pnpm 10.x](https://pnpm.io/installation)
- [Pnpm 9.x](https://pnpm.io/installation)
- [Git](https://git-scm.com/downloads)
## Unix (Linux, macOS)

2
next-env.d.ts vendored
View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.

View File

@@ -42,7 +42,6 @@
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30",
"@types/wink-jaro-distance": "^2.0.2",
"ace-builds": "1.15.2",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
@@ -98,7 +97,6 @@
"typeorm": "0.3.11",
"undici": "^6.20.1",
"web-push": "3.5.0",
"wink-jaro-distance": "^2.0.0",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23",
@@ -171,7 +169,7 @@
"typescript": "4.9.5"
},
"engines": {
"node": "^22.0.0",
"node": "^23.0.0",
"pnpm": "^10.0.0"
},
"overrides": {
@@ -247,5 +245,11 @@
},
"@semantic-release/github"
]
},
"pnpm": {
"onlyBuiltDependencies": [
"sqlite3",
"bcrypt"
]
}
}

9661
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@@ -1,7 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import jaro from 'wink-jaro-distance';
interface RTAlgoliaSearchResponse {
results: {
@@ -16,7 +15,7 @@ interface RTAlgoliaHit {
tmsId: string;
type: string;
title: string;
titles?: string[];
titles: string[];
description: string;
releaseYear: number;
rating: string;
@@ -25,9 +24,9 @@ interface RTAlgoliaHit {
isEmsSearchable: boolean;
rtId: number;
vanity: string;
aka?: string[];
aka: string[];
posterImageUrl: string;
rottenTomatoes?: {
rottenTomatoes: {
audienceScore: number;
criticsIconUrl: string;
wantToSeeCount: number;
@@ -48,47 +47,6 @@ export interface RTRating {
url: string;
}
// Tunables
const INEXACT_TITLE_FACTOR = 0.25;
const ALTERNATE_TITLE_FACTOR = 0.8;
const PER_YEAR_PENALTY = 0.4;
const MINIMUM_SCORE = 0.175;
// Normalization for title comparisons.
// Lowercase and strip non-alphanumeric (unicode-aware).
const norm = (s: string): string =>
s.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, '');
// Title similarity. 1 if exact, quarter-jaro otherwise.
const similarity = (a: string, b: string): number =>
a === b ? 1 : jaro(a, b).similarity * INEXACT_TITLE_FACTOR;
// Gets the best similarity score between the searched title and all alternate
// titles of the search result. Non-main titles are penalized.
const t_score = ({ title, titles, aka }: RTAlgoliaHit, s: string): number => {
const f = (t: string, i: number) =>
similarity(norm(t), norm(s)) * (i ? ALTERNATE_TITLE_FACTOR : 1);
return Math.max(...[title].concat(aka || [], titles || []).map(f));
};
// Year difference to score: 0 -> 1.0, 1 -> 0.6, 2 -> 0.2, 3+ -> 0.0
const y_score = (r: RTAlgoliaHit, y?: number): number =>
y ? Math.max(0, 1 - Math.abs(r.releaseYear - y) * PER_YEAR_PENALTY) : 1;
// Cut score in half if result has no ratings.
const extra_score = (r: RTAlgoliaHit): number => (r.rottenTomatoes ? 1 : 0.5);
// Score search result as product of all subscores
const score = (r: RTAlgoliaHit, name: string, year?: number): number =>
t_score(r, name) * y_score(r, year) * extra_score(r);
// Score each search result and return the highest scoring result, if any
const best = (rs: RTAlgoliaHit[], name: string, year?: number): RTAlgoliaHit =>
rs
.map((r) => ({ score: score(r, name, year), result: r }))
.filter(({ score }) => score > MINIMUM_SCORE)
.sort(({ score: a }, { score: b }) => b - a)[0]?.result;
/**
* This is a best-effort API. The Rotten Tomatoes API is technically
* private and getting access costs money/requires approval.
@@ -132,21 +90,47 @@ class RottenTomatoes extends ExternalAPI {
year: number
): Promise<RTRating | null> {
try {
const filters = encodeURIComponent('isEmsSearchable=1 AND type:"movie"');
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name.replace(/\bthe\b ?/gi, ''),
params: `filters=${filters}&hitsPerPage=20`,
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
const movie = best(contentResults?.hits || [], name, year);
if (!movie?.rottenTomatoes) return null;
if (!contentResults) {
return null;
}
// First, attempt to match exact name and year
let movie = contentResults.hits.find(
(movie) => movie.releaseYear === year && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = contentResults.hits.find(
(movie) => movie.releaseYear === year && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
}
// One last try, try exact name match only
if (!movie) {
movie = contentResults.hits.find((movie) => movie.title === name);
}
if (!movie?.rottenTomatoes) {
return null;
}
return {
title: movie.title,
@@ -174,21 +158,33 @@ class RottenTomatoes extends ExternalAPI {
year?: number
): Promise<RTRating | null> {
try {
const filters = encodeURIComponent('isEmsSearchable=1 AND type:"tv"');
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: `filters=${filters}&hitsPerPage=20`,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
const tvshow = best(contentResults?.hits || [], name, year);
if (!tvshow?.rottenTomatoes) return null;
if (!contentResults) {
return null;
}
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
if (year) {
tvshow = contentResults.hits.find(
(series) => series.releaseYear === year
);
}
if (!tvshow || !tvshow.rottenTomatoes) {
return null;
}
return {
title: tvshow.title,

View File

@@ -83,13 +83,13 @@ export class User {
@Column({ nullable: true })
public jellyfinUserId?: string;
@Column({ nullable: true, select: false })
@Column({ nullable: true })
public jellyfinDeviceId?: string;
@Column({ nullable: true, select: false })
@Column({ nullable: true })
public jellyfinAuthToken?: string;
@Column({ nullable: true, select: false })
@Column({ nullable: true })
public plexToken?: string;
@Column({ type: 'integer', default: 0 })

View File

@@ -165,7 +165,7 @@ app
}
});
if (settings.main.csrfProtection) {
server.use(
server.use(() =>
csurf({
cookie: {
httpOnly: true,

View File

@@ -70,35 +70,6 @@ export const startJobs = (): void => {
running: () => plexFullScanner.status().running,
cancelFn: () => plexFullScanner.cancel(),
});
scheduledJobs.push({
id: 'plex-refresh-token',
name: 'Plex Refresh Token',
type: 'process',
interval: 'fixed',
cronSchedule: jobs['plex-refresh-token'].schedule,
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
logger.info('Starting scheduled job: Plex Refresh Token', {
label: 'Jobs',
});
refreshToken.run();
}),
});
// Watchlist Sync
scheduledJobs.push({
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'seconds',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
@@ -141,6 +112,21 @@ export const startJobs = (): void => {
});
}
// Watchlist Sync
scheduledJobs.push({
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'seconds',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
});
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',
@@ -237,5 +223,19 @@ export const startJobs = (): void => {
}),
});
scheduledJobs.push({
id: 'plex-refresh-token',
name: 'Plex Refresh Token',
type: 'process',
interval: 'fixed',
cronSchedule: jobs['plex-refresh-token'].schedule,
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
logger.info('Starting scheduled job: Plex Refresh Token', {
label: 'Jobs',
});
refreshToken.run();
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

View File

@@ -295,14 +295,6 @@ class DiscordAgent
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
}
logger.debug('Discord notification details', {
username: settings.options.botUsername
? settings.options.botUsername
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content: userMentions.join(' '),
});
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {
@@ -318,12 +310,6 @@ class DiscordAgent
} as DiscordWebhookPayload),
});
if (!response.ok) {
logger.debug('Error sending Discord notification, response not ok', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
response: response.statusText,
});
throw new Error(response.statusText, { cause: response });
}
@@ -342,7 +328,6 @@ class DiscordAgent
subject: payload.subject,
errorMessage: e.message,
response: errorData,
stack: e.stack,
});
return false;

View File

@@ -263,7 +263,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
let user = await userRepository.findOne({
where: { jellyfinUsername: body.username },
select: { id: true, jellyfinDeviceId: true },
});
let deviceId = '';

View File

@@ -133,6 +133,10 @@ const Setup = () => {
setCurrentStep(3);
}
}
if (currentStep === 3) {
validateLibraries();
}
}, [
settings.currentSettings.mediaServerType,
settings.currentSettings.initialized,
@@ -144,13 +148,6 @@ const Setup = () => {
validateLibraries,
]);
useEffect(() => {
if (currentStep === 3) {
validateLibraries();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
const handleComplete = () => {
validateLibraries();
};

View File

@@ -1,10 +1,9 @@
import Modal from '@app/components/Common/Modal';
import PermissionEdit from '@app/components/PermissionEdit';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { hasPermission } from '@server/lib/permissions';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -80,10 +79,7 @@ const BulkEditModal = ({
const { permissions: allPermissionsEqual } = selectedUsers.reduce(
({ permissions: aPerms }, { permissions: bPerms }) => {
return {
permissions:
aPerms === bPerms || hasPermission(Permission.ADMIN, aPerms)
? aPerms
: NaN,
permissions: aPerms === bPerms ? aPerms : NaN,
};
},
{ permissions: selectedUsers[0].permissions }